首次提交
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
@@ -0,0 +1,3 @@
|
||||
registry=https://registry.npmmirror.com
|
||||
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.3 KiB |
Generated
+4143
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"name": "fengai",
|
||||
"version": "1.0.0",
|
||||
"description": "FengAI - a simple OpenAI-compatible LLM chat desktop app",
|
||||
"main": "src/main/main.js",
|
||||
"author": "FengAI",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"dev": "electron . --dev",
|
||||
"icon": "node scripts/make-icon.js",
|
||||
"pack": "electron-builder --dir",
|
||||
"build": "electron-builder --win"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^31.7.0",
|
||||
"electron-builder": "^24.13.3"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.fengai.chat",
|
||||
"productName": "FengAI",
|
||||
"directories": {
|
||||
"output": "dist",
|
||||
"buildResources": "build"
|
||||
},
|
||||
"files": [
|
||||
"src/**/*",
|
||||
"package.json",
|
||||
"!**/*.map"
|
||||
],
|
||||
"win": {
|
||||
"icon": "build/icon.ico",
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"artifactName": "FengAI-Setup-${version}.${ext}"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"perMachine": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true,
|
||||
"shortcutName": "FengAI"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"dompurify": "^3.4.12",
|
||||
"marked": "^18.0.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const zlib = require('zlib');
|
||||
|
||||
function crc32(buf) {
|
||||
let c = ~0 >>> 0;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
c ^= buf[i];
|
||||
for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (0xEDB88320 & -(c & 1));
|
||||
}
|
||||
return ~c >>> 0;
|
||||
}
|
||||
|
||||
function chunk(type, data) {
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(data.length, 0);
|
||||
const t = Buffer.from(type, 'ascii');
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(crc32(Buffer.concat([t, data])), 0);
|
||||
return Buffer.concat([len, t, data, crc]);
|
||||
}
|
||||
|
||||
function encodePNG(rgba, w, h) {
|
||||
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(w, 0);
|
||||
ihdr.writeUInt32BE(h, 4);
|
||||
ihdr[8] = 8; ihdr[9] = 6; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
|
||||
const stride = w * 4;
|
||||
const raw = Buffer.alloc((stride + 1) * h);
|
||||
for (let y = 0; y < h; y++) {
|
||||
raw[y * (stride + 1)] = 0;
|
||||
rgba.copy(raw, y * (stride + 1) + 1, y * stride, y * stride + stride);
|
||||
}
|
||||
const idat = zlib.deflateSync(raw, { level: 9 });
|
||||
return Buffer.concat([sig, chunk('IHDR', ihdr), chunk('IDAT', idat), chunk('IEND', Buffer.alloc(0))]);
|
||||
}
|
||||
|
||||
function makeICO(images) {
|
||||
const n = images.length;
|
||||
const header = Buffer.alloc(6);
|
||||
header.writeUInt16LE(0, 0);
|
||||
header.writeUInt16LE(1, 2);
|
||||
header.writeUInt16LE(n, 4);
|
||||
let offset = 6 + 16 * n;
|
||||
const entries = [];
|
||||
const datas = [];
|
||||
for (const im of images) {
|
||||
const e = Buffer.alloc(16);
|
||||
e[0] = im.w >= 256 ? 0 : im.w;
|
||||
e[1] = im.h >= 256 ? 0 : im.h;
|
||||
e[2] = 0; e[3] = 0;
|
||||
e.writeUInt16LE(1, 4);
|
||||
e.writeUInt16LE(32, 6);
|
||||
e.writeUInt32LE(im.png.length, 8);
|
||||
e.writeUInt32LE(offset, 12);
|
||||
entries.push(e);
|
||||
datas.push(im.png);
|
||||
offset += im.png.length;
|
||||
}
|
||||
return Buffer.concat([header, ...entries, ...datas]);
|
||||
}
|
||||
|
||||
function inRR(x, y, w, h, r) {
|
||||
if (x < r && y < r) return Math.hypot(r - x, r - y) <= r;
|
||||
if (x > w - 1 - r && y < r) return Math.hypot(w - 1 - r - x, r - y) <= r;
|
||||
if (x < r && y > h - 1 - r) return Math.hypot(r - x, h - 1 - r - y) <= r;
|
||||
if (x > w - 1 - r && y > h - 1 - r) return Math.hypot(w - 1 - r - x, h - 1 - r - y) <= r;
|
||||
return x >= 0 && y >= 0 && x < w && y < h;
|
||||
}
|
||||
|
||||
function inCapsule(x, y, x1, x2, cy, r) {
|
||||
const clampX = Math.max(x1, Math.min(x2, x));
|
||||
const dx = x - clampX;
|
||||
const dy = y - cy;
|
||||
return dx * dx + dy * dy <= r * r;
|
||||
}
|
||||
|
||||
function render(W) {
|
||||
const buf = Buffer.alloc(W * W * 4);
|
||||
const rad = W * 0.235;
|
||||
const A = [34, 211, 238];
|
||||
const B = [99, 102, 241];
|
||||
const bars = [
|
||||
{ x1: W * 0.30, x2: W * 0.74, cy: W * 0.40, r: W * 0.030 },
|
||||
{ x1: W * 0.22, x2: W * 0.80, cy: W * 0.515, r: W * 0.030 },
|
||||
{ x1: W * 0.36, x2: W * 0.70, cy: W * 0.63, r: W * 0.030 },
|
||||
];
|
||||
for (let y = 0; y < W; y++) {
|
||||
for (let x = 0; x < W; x++) {
|
||||
const i = (y * W + x) * 4;
|
||||
if (!inRR(x, y, W, W, rad)) {
|
||||
buf[i + 3] = 0;
|
||||
continue;
|
||||
}
|
||||
const t = (x + y) / (2 * (W - 1));
|
||||
let r = A[0] + (B[0] - A[0]) * t;
|
||||
let g = A[1] + (B[1] - A[1]) * t;
|
||||
let b = A[2] + (B[2] - A[2]) * t;
|
||||
let onBar = false;
|
||||
for (const bar of bars) {
|
||||
if (inCapsule(x, y, bar.x1, bar.x2, bar.cy, bar.r)) { onBar = true; break; }
|
||||
}
|
||||
if (onBar) { r = 255; g = 255; b = 255; }
|
||||
buf[i] = r | 0;
|
||||
buf[i + 1] = g | 0;
|
||||
buf[i + 2] = b | 0;
|
||||
buf[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
function downsampleAvg(buf, W, ss) {
|
||||
const size = W / ss;
|
||||
const out = Buffer.alloc(size * size * 4);
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
let r = 0, g = 0, b = 0, a = 0;
|
||||
for (let dy = 0; dy < ss; dy++) {
|
||||
for (let dx = 0; dx < ss; dx++) {
|
||||
const i = ((y * ss + dy) * W + (x * ss + dx)) * 4;
|
||||
r += buf[i]; g += buf[i + 1]; b += buf[i + 2]; a += buf[i + 3];
|
||||
}
|
||||
}
|
||||
const n = ss * ss;
|
||||
const o = (y * size + x) * 4;
|
||||
out[o] = Math.round(r / n);
|
||||
out[o + 1] = Math.round(g / n);
|
||||
out[o + 2] = Math.round(b / n);
|
||||
out[o + 3] = Math.round(a / n);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function nearest(src, sw, sh, dw, dh) {
|
||||
const out = Buffer.alloc(dw * dh * 4);
|
||||
for (let y = 0; y < dh; y++) {
|
||||
for (let x = 0; x < dw; x++) {
|
||||
const sx = Math.min(sw - 1, Math.round((x * sw) / dw));
|
||||
const sy = Math.min(sh - 1, Math.round((y * sh) / dh));
|
||||
const si = (sy * sw + sx) * 4;
|
||||
const di = (y * dw + x) * 4;
|
||||
out[di] = src[si];
|
||||
out[di + 1] = src[si + 1];
|
||||
out[di + 2] = src[si + 2];
|
||||
out[di + 3] = src[si + 3];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const SS = 4;
|
||||
const BASE = 256;
|
||||
const hi = render(BASE * SS);
|
||||
const big = downsampleAvg(hi, BASE * SS, SS);
|
||||
const png256 = encodePNG(big, BASE, BASE);
|
||||
const png48 = encodePNG(nearest(big, BASE, BASE, 48, 48), 48, 48);
|
||||
const png32 = encodePNG(nearest(big, BASE, BASE, 32, 32), 32, 32);
|
||||
const png16 = encodePNG(nearest(big, BASE, BASE, 16, 16), 16, 16);
|
||||
const ico = makeICO([
|
||||
{ png: png256, w: 256, h: 256 },
|
||||
{ png: png48, w: 48, h: 48 },
|
||||
{ png: png32, w: 32, h: 32 },
|
||||
{ png: png16, w: 16, h: 16 },
|
||||
]);
|
||||
const outDir = path.join(__dirname, '..', 'build');
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(outDir, 'icon.ico'), ico);
|
||||
console.log('icon.ico written:', path.join(outDir, 'icon.ico'), ico.length, 'bytes');
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const { URL } = require('url');
|
||||
const logger = require('./logger');
|
||||
|
||||
function normalizeBaseURL(baseURL) {
|
||||
let url = (baseURL || '').trim().replace(/\/+$/, '');
|
||||
if (/\/chat\/completions$/.test(url)) {
|
||||
url = url.replace(/\/chat\/completions$/, '');
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
function buildChatURL(baseURL) {
|
||||
return normalizeBaseURL(baseURL) + '/chat/completions';
|
||||
}
|
||||
|
||||
function buildModelsURL(baseURL) {
|
||||
return normalizeBaseURL(baseURL) + '/models';
|
||||
}
|
||||
|
||||
async function fetchModels({ baseURL, apiKey }) {
|
||||
const url = buildModelsURL(baseURL);
|
||||
logger.log('FETCH_MODELS_START', { url });
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: 'Bearer ' + apiKey, 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
logger.log('FETCH_MODELS_FAIL', { status: res.status, body: text.slice(0, 300) });
|
||||
throw new Error('获取模型列表失败 (HTTP ' + res.status + '): ' + text.slice(0, 300));
|
||||
}
|
||||
const json = await res.json();
|
||||
const data = json.data || json.models || [];
|
||||
const models = data.map((m) => (typeof m === 'string' ? m : m.id || m.name)).filter(Boolean);
|
||||
logger.log('FETCH_MODELS_OK', { count: models.length });
|
||||
return models;
|
||||
}
|
||||
|
||||
function doRequest(url, { method, headers, bodyStr, signal }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const u = new URL(url);
|
||||
const lib = u.protocol === 'https:' ? https : http;
|
||||
const opts = {
|
||||
hostname: u.hostname,
|
||||
port: u.port || (u.protocol === 'https:' ? 443 : 80),
|
||||
path: u.pathname + u.search,
|
||||
method,
|
||||
headers: Object.assign({}, headers, { 'Content-Length': Buffer.byteLength(bodyStr) }),
|
||||
};
|
||||
const req = lib.request(opts, (res) => resolve(res));
|
||||
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
req.destroy();
|
||||
reject(Object.assign(new Error('Aborted'), { name: 'AbortError' }));
|
||||
return;
|
||||
}
|
||||
signal.addEventListener('abort', () => {
|
||||
try { req.destroy(); } catch { /* ignore */ }
|
||||
});
|
||||
}
|
||||
req.on('error', (err) => {
|
||||
if (signal && signal.aborted) reject(Object.assign(new Error('Aborted'), { name: 'AbortError' }));
|
||||
else reject(err);
|
||||
});
|
||||
req.write(bodyStr);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function readAll(stream) {
|
||||
return new Promise((resolve) => {
|
||||
const chunks = [];
|
||||
stream.on('data', (c) => chunks.push(c));
|
||||
stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||
stream.on('error', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||
});
|
||||
}
|
||||
|
||||
async function streamChat({ baseURL, apiKey, model, messages, enableThinking, reasoningEffort, maxTokens, signal, onChunk, onUsage }) {
|
||||
const url = buildChatURL(baseURL);
|
||||
let currentMessages = messages;
|
||||
let accumulated = '';
|
||||
const MAX_ITER = 12;
|
||||
const CONTINUE_PROMPT = '继续,请从刚才中断的地方直接接着输出,不要重复已经输出的任何内容。';
|
||||
|
||||
for (let iter = 0; iter < MAX_ITER; iter++) {
|
||||
const body = { model, messages: currentMessages, stream: true };
|
||||
if (enableThinking) {
|
||||
body.thinking = { type: 'enabled' };
|
||||
if (reasoningEffort) body.reasoning_effort = reasoningEffort;
|
||||
}
|
||||
if (maxTokens && maxTokens > 0) body.max_tokens = maxTokens;
|
||||
const bodyStr = JSON.stringify(body);
|
||||
|
||||
logger.log('REQUEST', { iter, model, msgCount: currentMessages.length, thinking: !!enableThinking, effort: reasoningEffort || null, maxTokens: maxTokens || null, url });
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await doRequest(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + apiKey },
|
||||
bodyStr,
|
||||
signal,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e && e.name === 'AbortError') { logger.log('ABORTED_AT_REQUEST', { iter }); throw e; }
|
||||
logger.log('REQUEST_ERROR', { iter, error: e.message, code: e.code });
|
||||
throw e;
|
||||
}
|
||||
|
||||
logger.log('RESPONSE_STATUS', { iter, status: res.statusCode });
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
const text = await readAll(res);
|
||||
logger.log('HTTP_ERROR', { iter, status: res.statusCode, body: text.slice(0, 500) });
|
||||
throw new Error('请求失败 (HTTP ' + res.statusCode + '): ' + text.slice(0, 500));
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
let finishReason = null;
|
||||
let sawDone = false;
|
||||
let chunkCount = 0;
|
||||
let gotContentThisIter = false;
|
||||
|
||||
try {
|
||||
for await (const chunk of res) {
|
||||
buffer += decoder.decode(chunk, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (const raw of lines) {
|
||||
const line = raw.trim();
|
||||
if (!line || !line.startsWith('data:')) continue;
|
||||
const data = line.slice(5).trim();
|
||||
if (data === '[DONE]') { sawDone = true; break; }
|
||||
try {
|
||||
const json = JSON.parse(data);
|
||||
chunkCount++;
|
||||
if (json.usage && onUsage) onUsage(json.usage);
|
||||
const choice = (json.choices && json.choices[0]) || {};
|
||||
const delta = choice.delta || {};
|
||||
const content = delta.content || '';
|
||||
const reasoning = delta.reasoning_content || delta.reasoning || '';
|
||||
if (content) { accumulated += content; gotContentThisIter = true; onChunk({ content, reasoning: '' }); }
|
||||
if (reasoning) { onChunk({ content: '', reasoning }); }
|
||||
if (choice.finish_reason) finishReason = choice.finish_reason;
|
||||
} catch {
|
||||
/* ignore malformed lines */
|
||||
}
|
||||
}
|
||||
if (sawDone) break;
|
||||
}
|
||||
} catch (e) {
|
||||
if (e && e.name === 'AbortError') { logger.log('ABORTED_IN_STREAM', { iter }); throw e; }
|
||||
logger.log('STREAM_ERROR', { iter, error: e.message, code: e.code, stack: e.stack });
|
||||
throw e;
|
||||
}
|
||||
|
||||
try { res.destroy(); } catch { /* ignore */ }
|
||||
|
||||
logger.log('SEGMENT_END', { iter, finishReason: finishReason || null, sawDone, chunkCount, accumulatedLen: accumulated.length, gotContent: gotContentThisIter });
|
||||
|
||||
if (signal && signal.aborted) { logger.log('SIGNAL_ABORTED_AFTER', { iter }); throw Object.assign(new Error('Aborted'), { name: 'AbortError' }); }
|
||||
|
||||
const truncated = finishReason === 'length' || (!finishReason && !sawDone && gotContentThisIter);
|
||||
|
||||
if (truncated && iter < MAX_ITER - 1 && accumulated.length > 0) {
|
||||
logger.log('AUTO_CONTINUE', { iter, reason: finishReason === 'length' ? 'length' : 'abrupt', accumulatedLen: accumulated.length });
|
||||
currentMessages = messages.concat([
|
||||
{ role: 'assistant', content: accumulated },
|
||||
{ role: 'user', content: CONTINUE_PROMPT },
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
if (iter === MAX_ITER - 1 && truncated) {
|
||||
logger.log('MAX_ITER_REACHED', { accumulatedLen: accumulated.length });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
logger.log('STREAM_COMPLETE', { totalAccumulatedLen: accumulated.length });
|
||||
}
|
||||
|
||||
module.exports = { fetchModels, streamChat, buildChatURL };
|
||||
@@ -0,0 +1,29 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { app } = require('electron');
|
||||
|
||||
let _logPath = null;
|
||||
function logPath() {
|
||||
if (_logPath) return _logPath;
|
||||
_logPath = path.join(app.getPath('userData'), 'fengai.log');
|
||||
return _logPath;
|
||||
}
|
||||
|
||||
function log(...args) {
|
||||
const ts = new Date().toISOString();
|
||||
const parts = args.map((a) => {
|
||||
if (a instanceof Error) return a.stack || a.message;
|
||||
if (typeof a === 'object' && a !== null) {
|
||||
try { return JSON.stringify(a); } catch { return String(a); }
|
||||
}
|
||||
return String(a);
|
||||
});
|
||||
const line = '[' + ts + '] ' + parts.join(' ') + '\n';
|
||||
try {
|
||||
fs.appendFileSync(logPath(), line);
|
||||
} catch {
|
||||
/* ignore write errors */
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { log, logPath };
|
||||
@@ -0,0 +1,140 @@
|
||||
const { app, BrowserWindow, ipcMain, shell } = require('electron');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const store = require('./store');
|
||||
const { fetchModels, streamChat } = require('./api');
|
||||
const logger = require('./logger');
|
||||
|
||||
let mainWindow = null;
|
||||
let abortController = null;
|
||||
|
||||
function createWindow() {
|
||||
const iconPath = path.join(__dirname, '..', '..', 'build', 'icon.ico');
|
||||
const winOpts = {
|
||||
width: 1200,
|
||||
height: 780,
|
||||
minWidth: 860,
|
||||
minHeight: 600,
|
||||
backgroundColor: '#16161d',
|
||||
title: 'FengAI',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: false,
|
||||
},
|
||||
};
|
||||
if (fs.existsSync(iconPath)) winOpts.icon = iconPath;
|
||||
mainWindow = new BrowserWindow(winOpts);
|
||||
|
||||
mainWindow.setMenuBarVisibility(false);
|
||||
mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'));
|
||||
|
||||
if (process.argv.includes('--dev')) {
|
||||
mainWindow.webContents.openDevTools({ mode: 'detach' });
|
||||
}
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url);
|
||||
return { action: 'deny' };
|
||||
});
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
}
|
||||
|
||||
function send(channel, data) {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send(channel, data);
|
||||
}
|
||||
}
|
||||
|
||||
function registerStoreIpc() {
|
||||
ipcMain.handle('store:getProviders', () => store.getProviders());
|
||||
ipcMain.handle('store:upsertProvider', (_e, provider) => store.upsertProvider(provider));
|
||||
ipcMain.handle('store:deleteProvider', (_e, id) => store.deleteProvider(id));
|
||||
|
||||
ipcMain.handle('store:getConversationList', () => store.getConversationList());
|
||||
ipcMain.handle('store:getConversation', (_e, id) => store.getConversation(id));
|
||||
ipcMain.handle('store:saveConversation', (_e, conv) => store.saveConversation(conv));
|
||||
ipcMain.handle('store:deleteConversation', (_e, id) => store.deleteConversation(id));
|
||||
ipcMain.handle('store:clearAllConversations', () => store.clearAllConversations());
|
||||
ipcMain.handle('store:renameConversation', (_e, { id, title }) => store.renameConversation(id, title));
|
||||
}
|
||||
|
||||
function registerChatIpc() {
|
||||
ipcMain.handle('provider:test', async (_e, provider) => {
|
||||
try {
|
||||
const models = await fetchModels(provider);
|
||||
return { ok: true, models };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err.message, models: [] };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('chat:start', async (_e, { providerId, model, messages, enableThinking, reasoningEffort }) => {
|
||||
const providers = store.getProviders();
|
||||
const provider = providers.find((p) => p.id === providerId);
|
||||
if (!provider) return { ok: false, error: '未找到供应商配置' };
|
||||
if (!provider.apiKey) return { ok: false, error: '该供应商未配置密钥' };
|
||||
|
||||
abortController = new AbortController();
|
||||
try {
|
||||
await streamChat({
|
||||
baseURL: provider.baseURL,
|
||||
apiKey: provider.apiKey,
|
||||
model,
|
||||
messages,
|
||||
enableThinking: !!enableThinking,
|
||||
reasoningEffort: reasoningEffort || undefined,
|
||||
maxTokens: provider.maxTokens || 0,
|
||||
signal: abortController.signal,
|
||||
onChunk: (chunk) => send('chat:chunk', chunk),
|
||||
onUsage: (usage) => send('chat:usage', usage),
|
||||
});
|
||||
send('chat:done', { ok: true });
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') {
|
||||
send('chat:done', { ok: true, aborted: true });
|
||||
} else {
|
||||
send('chat:error', { message: err.message });
|
||||
}
|
||||
} finally {
|
||||
abortController = null;
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
ipcMain.handle('chat:stop', () => {
|
||||
if (abortController) abortController.abort();
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('app:openLog', () => {
|
||||
const { logPath } = require('./logger');
|
||||
const p = logPath();
|
||||
shell.showItemInFolder(p);
|
||||
return p;
|
||||
});
|
||||
|
||||
ipcMain.handle('app:logPath', () => {
|
||||
const { logPath } = require('./logger');
|
||||
return logPath();
|
||||
});
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
logger.log('APP_START', { version: app.getVersion(), platform: process.platform, electron: process.versions.electron, node: process.versions.node });
|
||||
registerStoreIpc();
|
||||
registerChatIpc();
|
||||
createWindow();
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') app.quit();
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
const chatHandlers = { chunk: null, done: null, error: null, usage: null };
|
||||
|
||||
ipcRenderer.on('chat:chunk', (_e, data) => {
|
||||
if (chatHandlers.chunk) chatHandlers.chunk(data);
|
||||
});
|
||||
ipcRenderer.on('chat:done', (_e, data) => {
|
||||
if (chatHandlers.done) chatHandlers.done(data);
|
||||
});
|
||||
ipcRenderer.on('chat:error', (_e, data) => {
|
||||
if (chatHandlers.error) chatHandlers.error(data);
|
||||
});
|
||||
ipcRenderer.on('chat:usage', (_e, data) => {
|
||||
if (chatHandlers.usage) chatHandlers.usage(data);
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld('api', {
|
||||
providers: {
|
||||
list: () => ipcRenderer.invoke('store:getProviders'),
|
||||
save: (provider) => ipcRenderer.invoke('store:upsertProvider', provider),
|
||||
remove: (id) => ipcRenderer.invoke('store:deleteProvider', id),
|
||||
test: (provider) => ipcRenderer.invoke('provider:test', provider),
|
||||
},
|
||||
conversations: {
|
||||
list: () => ipcRenderer.invoke('store:getConversationList'),
|
||||
get: (id) => ipcRenderer.invoke('store:getConversation', id),
|
||||
save: (conv) => ipcRenderer.invoke('store:saveConversation', conv),
|
||||
remove: (id) => ipcRenderer.invoke('store:deleteConversation', id),
|
||||
clearAll: () => ipcRenderer.invoke('store:clearAllConversations'),
|
||||
rename: (id, title) => ipcRenderer.invoke('store:renameConversation', { id, title }),
|
||||
},
|
||||
chat: {
|
||||
start: (params) => ipcRenderer.invoke('chat:start', params),
|
||||
stop: () => ipcRenderer.invoke('chat:stop'),
|
||||
onChunk: (cb) => { chatHandlers.chunk = cb; },
|
||||
onDone: (cb) => { chatHandlers.done = cb; },
|
||||
onError: (cb) => { chatHandlers.error = cb; },
|
||||
onUsage: (cb) => { chatHandlers.usage = cb; },
|
||||
},
|
||||
app: {
|
||||
openLog: () => ipcRenderer.invoke('app:openLog'),
|
||||
logPath: () => ipcRenderer.invoke('app:logPath'),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { app } = require('electron');
|
||||
|
||||
function dataDir() {
|
||||
const dir = path.join(app.getPath('userData'), 'data');
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
function providersPath() {
|
||||
return path.join(dataDir(), 'providers.json');
|
||||
}
|
||||
|
||||
function conversationsDir() {
|
||||
const dir = path.join(dataDir(), 'conversations');
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
function readJSON(file, fallback) {
|
||||
try {
|
||||
if (!fs.existsSync(file)) return fallback;
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJSON(file, data) {
|
||||
fs.writeFileSync(file, JSON.stringify(data, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
function getProviders() {
|
||||
return readJSON(providersPath(), []);
|
||||
}
|
||||
|
||||
function saveProviders(list) {
|
||||
writeJSON(providersPath(), list);
|
||||
}
|
||||
|
||||
function upsertProvider(provider) {
|
||||
const list = getProviders();
|
||||
if (!provider.id) provider.id = crypto.randomUUID();
|
||||
const idx = list.findIndex((p) => p.id === provider.id);
|
||||
if (idx >= 0) list[idx] = { ...list[idx], ...provider };
|
||||
else list.push(provider);
|
||||
saveProviders(list);
|
||||
return provider;
|
||||
}
|
||||
|
||||
function deleteProvider(id) {
|
||||
saveProviders(getProviders().filter((p) => p.id !== id));
|
||||
}
|
||||
|
||||
function getConversationList() {
|
||||
const dir = conversationsDir();
|
||||
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.json'));
|
||||
const items = files.map((f) => {
|
||||
try {
|
||||
const c = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
|
||||
return {
|
||||
id: c.id,
|
||||
title: c.title,
|
||||
providerId: c.providerId,
|
||||
model: c.model,
|
||||
createdAt: c.createdAt,
|
||||
updatedAt: c.updatedAt,
|
||||
messageCount: (c.messages || []).length,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}).filter(Boolean);
|
||||
items.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
||||
return items;
|
||||
}
|
||||
|
||||
function getConversation(id) {
|
||||
const file = path.join(conversationsDir(), `${id}.json`);
|
||||
return readJSON(file, null);
|
||||
}
|
||||
|
||||
function saveConversation(conv) {
|
||||
if (!conv.id) conv.id = crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
if (!conv.createdAt) conv.createdAt = now;
|
||||
conv.updatedAt = now;
|
||||
writeJSON(path.join(conversationsDir(), `${conv.id}.json`), conv);
|
||||
return conv;
|
||||
}
|
||||
|
||||
function deleteConversation(id) {
|
||||
const file = path.join(conversationsDir(), `${id}.json`);
|
||||
if (fs.existsSync(file)) fs.unlinkSync(file);
|
||||
}
|
||||
|
||||
function clearAllConversations() {
|
||||
const dir = conversationsDir();
|
||||
fs.readdirSync(dir).filter((f) => f.endsWith('.json')).forEach((f) => {
|
||||
fs.unlinkSync(path.join(dir, f));
|
||||
});
|
||||
}
|
||||
|
||||
function renameConversation(id, title) {
|
||||
const conv = getConversation(id);
|
||||
if (!conv) return null;
|
||||
conv.title = title;
|
||||
conv.updatedAt = Date.now();
|
||||
writeJSON(path.join(conversationsDir(), `${id}.json`), conv);
|
||||
return conv;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getProviders,
|
||||
upsertProvider,
|
||||
deleteProvider,
|
||||
getConversationList,
|
||||
getConversation,
|
||||
saveConversation,
|
||||
deleteConversation,
|
||||
clearAllConversations,
|
||||
renameConversation,
|
||||
};
|
||||
@@ -0,0 +1,953 @@
|
||||
(function () {
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const api = window.api;
|
||||
|
||||
const LS = {
|
||||
theme: 'fengai.theme',
|
||||
ctx: 'fengai.ctx',
|
||||
effort: 'fengai.effort',
|
||||
system: 'fengai.systemPrompt',
|
||||
};
|
||||
function lsGet(key) {
|
||||
return localStorage.getItem(key) !== null ? localStorage.getItem(key) : localStorage.getItem(key.replace('fengai.', 'nova.'));
|
||||
}
|
||||
|
||||
const state = {
|
||||
providers: [],
|
||||
convList: [],
|
||||
currentConv: null,
|
||||
selectedProviderId: null,
|
||||
selectedModel: null,
|
||||
isStreaming: false,
|
||||
contextLimit: 6,
|
||||
thinkingEffort: 'off',
|
||||
systemPrompt: '',
|
||||
streamMsg: null,
|
||||
streamDom: null,
|
||||
flushTimer: null,
|
||||
dirty: false,
|
||||
autoScroll: true,
|
||||
editingProviderId: null,
|
||||
};
|
||||
|
||||
const els = {
|
||||
convList: $('conversationList'),
|
||||
searchInput: $('searchInput'),
|
||||
btnNewChat: $('btnNewChat'),
|
||||
btnSettings: $('btnSettings'),
|
||||
btnClearAll: $('btnClearAll'),
|
||||
themeBtn: $('themeBtn'),
|
||||
topbarTitle: $('topbarTitle'),
|
||||
convMenuBtn: $('convMenuBtn'),
|
||||
convMenu: $('convMenu'),
|
||||
menuRename: $('menuRename'),
|
||||
menuClearCurrent: $('menuClearCurrent'),
|
||||
modelSelect: $('modelSelect'),
|
||||
thinkingToggle: $('thinkingToggle'),
|
||||
ctxMinus: $('ctxMinus'),
|
||||
ctxPlus: $('ctxPlus'),
|
||||
ctxVal: $('ctxVal'),
|
||||
effortSeg: $('effortSeg'),
|
||||
messages: $('messages'),
|
||||
scrollBottomBtn: $('scrollBottomBtn'),
|
||||
input: $('input'),
|
||||
charCount: $('charCount'),
|
||||
btnSend: $('btnSend'),
|
||||
btnStop: $('btnStop'),
|
||||
composerHint: $('composerHint'),
|
||||
settingsModal: $('settingsModal'),
|
||||
closeSettings: $('closeSettings'),
|
||||
providerSelect: $('providerSelect'),
|
||||
btnNewProvider: $('btnNewProvider'),
|
||||
btnDeleteProvider: $('btnDeleteProvider'),
|
||||
pfName: $('pfName'),
|
||||
pfBaseURL: $('pfBaseURL'),
|
||||
pfApiKey: $('pfApiKey'),
|
||||
pfModels: $('pfModels'),
|
||||
pfMaxTokens: $('pfMaxTokens'),
|
||||
pfSystemPrompt: $('pfSystemPrompt'),
|
||||
btnTestAndFetch: $('btnTestAndFetch'),
|
||||
testHint: $('testHint'),
|
||||
btnSaveProvider: $('btnSaveProvider'),
|
||||
btnOpenLog: $('btnOpenLog'),
|
||||
toast: $('toast'),
|
||||
dialogModal: $('dialogModal'),
|
||||
dialogTitle: $('dialogTitle'),
|
||||
dialogMessage: $('dialogMessage'),
|
||||
dialogInput: $('dialogInput'),
|
||||
dialogCancel: $('dialogCancel'),
|
||||
dialogOk: $('dialogOk'),
|
||||
};
|
||||
|
||||
const dialogState = { onOk: null, input: false };
|
||||
function showDialog({ title, message, value, input, okText, onOk }) {
|
||||
els.dialogTitle.textContent = title || '提示';
|
||||
if (input) {
|
||||
els.dialogMessage.hidden = true;
|
||||
els.dialogInput.hidden = false;
|
||||
els.dialogInput.value = value || '';
|
||||
} else {
|
||||
els.dialogMessage.hidden = false;
|
||||
els.dialogInput.hidden = true;
|
||||
els.dialogMessage.textContent = message || '';
|
||||
}
|
||||
els.dialogOk.textContent = okText || '确定';
|
||||
dialogState.onOk = onOk;
|
||||
dialogState.input = !!input;
|
||||
els.dialogModal.hidden = false;
|
||||
if (input) setTimeout(() => { els.dialogInput.focus(); els.dialogInput.select(); }, 60);
|
||||
}
|
||||
function closeDialog() { els.dialogModal.hidden = true; dialogState.onOk = null; }
|
||||
function showConfirm(message, onOk, okText) {
|
||||
showDialog({ title: '确认', message, input: false, okText, onOk });
|
||||
}
|
||||
function showPrompt(title, value, onOk) {
|
||||
showDialog({ title, input: true, value, okText: '保存', onOk: (v) => { onOk(v); } });
|
||||
}
|
||||
|
||||
// ---------- utils ----------
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
function makeTitle(text) {
|
||||
return String(text || '').replace(/\s+/g, ' ').trim().slice(0, 18) || '新对话';
|
||||
}
|
||||
function uid() {
|
||||
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
||||
}
|
||||
function toast(msg) {
|
||||
els.toast.textContent = msg;
|
||||
els.toast.hidden = false;
|
||||
requestAnimationFrame(() => els.toast.classList.add('show'));
|
||||
clearTimeout(toast._t);
|
||||
toast._t = setTimeout(() => {
|
||||
els.toast.classList.remove('show');
|
||||
setTimeout(() => { els.toast.hidden = true; }, 250);
|
||||
}, 1600);
|
||||
}
|
||||
function timeAgo(ts) {
|
||||
if (!ts) return '';
|
||||
const d = Date.now() - ts;
|
||||
const m = Math.floor(d / 60000);
|
||||
if (m < 1) return '刚刚';
|
||||
if (m < 60) return m + '分钟前';
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return h + '小时前';
|
||||
const day = Math.floor(h / 24);
|
||||
if (day < 7) return day + '天前';
|
||||
const dt = new Date(ts);
|
||||
return (dt.getMonth() + 1) + '/' + dt.getDate();
|
||||
}
|
||||
function renderMarkdown(text) {
|
||||
if (!text) return '';
|
||||
try {
|
||||
const html = marked.parse(text, { breaks: true, gfm: true });
|
||||
return DOMPurify.sanitize(html);
|
||||
} catch {
|
||||
return escapeHtml(text);
|
||||
}
|
||||
}
|
||||
function copyText(text) {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(() => toast('已复制')).catch(() => fallbackCopy(text));
|
||||
} else fallbackCopy(text);
|
||||
}
|
||||
function fallbackCopy(text) {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand('copy'); toast('已复制'); } catch { /* ignore */ }
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
function parseUsage(u) {
|
||||
if (!u) return null;
|
||||
const input = u.prompt_tokens != null ? u.prompt_tokens : (u.input_tokens || 0);
|
||||
const completion = u.completion_tokens != null ? u.completion_tokens : (u.output_tokens || 0);
|
||||
const reasoning = (u.completion_tokens_details && u.completion_tokens_details.reasoning_tokens) != null
|
||||
? u.completion_tokens_details.reasoning_tokens
|
||||
: (u.reasoning_tokens || 0);
|
||||
const total = u.total_tokens != null ? u.total_tokens : (input + completion);
|
||||
return { input, completion, reasoning, total };
|
||||
}
|
||||
function fmtTokens(n) {
|
||||
return n >= 10000 ? (n / 1000).toFixed(1) + 'k' : String(n);
|
||||
}
|
||||
function statsText(s) {
|
||||
if (!s) return '';
|
||||
return '输入 ' + fmtTokens(s.input) + ' · 思考 ' + fmtTokens(s.reasoning) + ' · 输出 ' + fmtTokens(s.completion) + ' · 合计 ' + fmtTokens(s.total);
|
||||
}
|
||||
function buildStatsEl(s) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'msg-stats';
|
||||
el.textContent = '📊 ' + statsText(s);
|
||||
return el;
|
||||
}
|
||||
function computeSessionStats() {
|
||||
const conv = state.currentConv;
|
||||
if (!conv) return null;
|
||||
let input = 0, completion = 0, reasoning = 0, total = 0, has = false;
|
||||
conv.messages.forEach((m) => {
|
||||
if (m.usage) {
|
||||
const s = parseUsage(m.usage);
|
||||
input += s.input; completion += s.completion; reasoning += s.reasoning; total += s.total; has = true;
|
||||
}
|
||||
});
|
||||
return has ? { input, completion, reasoning, total } : null;
|
||||
}
|
||||
function updateSessionStatsUI() {
|
||||
if (state.isStreaming) return;
|
||||
const s = computeSessionStats();
|
||||
els.composerHint.textContent = s ? ('本会话 · ' + statsText(s)) : '';
|
||||
}
|
||||
|
||||
// ---------- theme ----------
|
||||
function applyTheme(theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
els.themeBtn.textContent = theme === 'dark' ? '🌙' : '☀️';
|
||||
els.themeBtn.title = theme === 'dark' ? '切换到亮色' : '切换到暗色';
|
||||
}
|
||||
function toggleTheme() {
|
||||
const cur = document.documentElement.getAttribute('data-theme') || 'dark';
|
||||
const next = cur === 'dark' ? 'light' : 'dark';
|
||||
applyTheme(next);
|
||||
localStorage.setItem(LS.theme, next);
|
||||
}
|
||||
|
||||
// ---------- preferences ----------
|
||||
function loadPrefs() {
|
||||
applyTheme(lsGet(LS.theme) || 'dark');
|
||||
state.contextLimit = parseInt(lsGet(LS.ctx) || '6', 10) || 6;
|
||||
state.thinkingEffort = lsGet(LS.effort) || 'off';
|
||||
state.systemPrompt = lsGet(LS.system) || '';
|
||||
els.ctxVal.textContent = state.contextLimit;
|
||||
els.pfSystemPrompt.value = state.systemPrompt;
|
||||
setEffort(state.thinkingEffort);
|
||||
}
|
||||
function setEffort(v) {
|
||||
if (v !== 'off' && v !== 'high' && v !== 'max') v = 'off';
|
||||
state.thinkingEffort = v;
|
||||
localStorage.setItem(LS.effort, v);
|
||||
els.effortSeg.querySelectorAll('.effort-opt').forEach((b) => {
|
||||
b.classList.toggle('active', b.dataset.v === v);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- providers ----------
|
||||
async function loadProviders() {
|
||||
state.providers = await api.providers.list();
|
||||
refreshModelSelect();
|
||||
}
|
||||
function refreshModelSelect() {
|
||||
const sel = els.modelSelect;
|
||||
sel.innerHTML = '';
|
||||
if (state.providers.length === 0) {
|
||||
const opt = document.createElement('option');
|
||||
opt.textContent = '请先配置供应商';
|
||||
sel.appendChild(opt);
|
||||
sel.disabled = true;
|
||||
return;
|
||||
}
|
||||
sel.disabled = false;
|
||||
state.providers.forEach((p) => {
|
||||
(p.models || []).forEach((m) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.id + '::' + m;
|
||||
opt.textContent = p.name + ' / ' + m;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
});
|
||||
const target = state.selectedProviderId && state.selectedModel
|
||||
? state.selectedProviderId + '::' + state.selectedModel : '';
|
||||
if (target && [...sel.options].some((o) => o.value === target)) {
|
||||
sel.value = target;
|
||||
} else {
|
||||
state.selectedProviderId = state.providers[0].id;
|
||||
state.selectedModel = (state.providers[0].models || [])[0] || null;
|
||||
if (state.selectedModel) sel.value = state.selectedProviderId + '::' + state.selectedModel;
|
||||
}
|
||||
}
|
||||
function getSelectedProvider() {
|
||||
const val = els.modelSelect.value;
|
||||
if (!val || !val.includes('::')) return null;
|
||||
const [pid, model] = val.split('::');
|
||||
const provider = state.providers.find((p) => p.id === pid);
|
||||
return provider ? { provider, model } : null;
|
||||
}
|
||||
|
||||
// ---------- conversation list ----------
|
||||
async function loadConvList() {
|
||||
state.convList = await api.conversations.list();
|
||||
renderConvList();
|
||||
}
|
||||
function renderConvList() {
|
||||
const q = els.searchInput.value.trim().toLowerCase();
|
||||
const list = state.convList.filter((c) => !q || (c.title || '').toLowerCase().includes(q));
|
||||
els.convList.innerHTML = '';
|
||||
if (list.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'conv-item';
|
||||
empty.style.color = 'var(--text-faint)';
|
||||
empty.style.cursor = 'default';
|
||||
empty.textContent = state.convList.length === 0 ? '暂无对话' : '无匹配结果';
|
||||
els.convList.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
list.forEach((c) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'conv-item' + (state.currentConv && state.currentConv.id === c.id ? ' active' : '');
|
||||
const main = document.createElement('div');
|
||||
main.className = 'conv-main';
|
||||
const title = document.createElement('div');
|
||||
title.className = 'conv-title';
|
||||
title.textContent = c.title || '新对话';
|
||||
const time = document.createElement('div');
|
||||
time.className = 'conv-time';
|
||||
time.textContent = timeAgo(c.updatedAt);
|
||||
main.appendChild(title);
|
||||
main.appendChild(time);
|
||||
main.addEventListener('dblclick', (e) => {
|
||||
e.stopPropagation();
|
||||
startRename(c, title);
|
||||
});
|
||||
const del = document.createElement('button');
|
||||
del.className = 'conv-del';
|
||||
del.textContent = '×';
|
||||
del.title = '删除对话';
|
||||
del.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
await api.conversations.remove(c.id);
|
||||
if (state.currentConv && state.currentConv.id === c.id) {
|
||||
state.currentConv = null;
|
||||
renderMessages();
|
||||
}
|
||||
await loadConvList();
|
||||
});
|
||||
item.appendChild(main);
|
||||
item.appendChild(del);
|
||||
item.addEventListener('click', () => openConversation(c.id));
|
||||
els.convList.appendChild(item);
|
||||
});
|
||||
}
|
||||
function startRename(c, titleEl) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.value = c.title || '';
|
||||
input.className = 'search-input';
|
||||
input.style.padding = '2px 6px';
|
||||
titleEl.replaceWith(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
const done = async (commit) => {
|
||||
const v = input.value.trim();
|
||||
if (commit && v) {
|
||||
await api.conversations.rename(c.id, v);
|
||||
c.title = v;
|
||||
if (state.currentConv && state.currentConv.id === c.id) {
|
||||
state.currentConv.title = v;
|
||||
els.topbarTitle.textContent = v;
|
||||
}
|
||||
}
|
||||
await loadConvList();
|
||||
};
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); done(true); }
|
||||
else if (e.key === 'Escape') { done(false); }
|
||||
});
|
||||
input.addEventListener('blur', () => done(true));
|
||||
}
|
||||
async function openConversation(id) {
|
||||
const conv = await api.conversations.get(id);
|
||||
if (!conv) return;
|
||||
state.currentConv = conv;
|
||||
if (conv.providerId && conv.model) {
|
||||
state.selectedProviderId = conv.providerId;
|
||||
state.selectedModel = conv.model;
|
||||
refreshModelSelect();
|
||||
}
|
||||
els.topbarTitle.textContent = conv.title || '对话';
|
||||
renderConvList();
|
||||
renderMessages();
|
||||
}
|
||||
function newConversation() {
|
||||
if (state.isStreaming) { stopStreaming(); }
|
||||
state.currentConv = { id: uid(), title: '新对话', messages: [], providerId: null, model: null };
|
||||
els.topbarTitle.textContent = '新对话';
|
||||
renderConvList();
|
||||
renderMessages();
|
||||
els.input.focus();
|
||||
}
|
||||
|
||||
// ---------- message rendering ----------
|
||||
function renderMessages() {
|
||||
const m = els.messages;
|
||||
m.innerHTML = '';
|
||||
state.autoScroll = true;
|
||||
els.scrollBottomBtn.hidden = true;
|
||||
if (!state.currentConv || state.currentConv.messages.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'empty-state';
|
||||
empty.innerHTML = '<div class="empty-logo">✦</div><h2>FengAI</h2><p>选择模型,输入消息开始对话</p>';
|
||||
m.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
const msgs = state.currentConv.messages;
|
||||
msgs.forEach((msg, i) => {
|
||||
m.appendChild(buildMessageEl(msg, i === msgs.length - 1));
|
||||
});
|
||||
scrollToBottom();
|
||||
updateSessionStatsUI();
|
||||
}
|
||||
function buildMessageEl(msg, isLast) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'message ' + msg.role;
|
||||
|
||||
const head = document.createElement('div');
|
||||
head.className = 'message-head';
|
||||
const role = document.createElement('div');
|
||||
role.className = 'message-role';
|
||||
const avatar = document.createElement('span');
|
||||
avatar.className = 'avatar';
|
||||
avatar.textContent = msg.role === 'user' ? '你' : '✦';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'message-role-name';
|
||||
name.textContent = msg.role === 'user' ? '你' : 'FengAI';
|
||||
role.appendChild(avatar);
|
||||
role.appendChild(name);
|
||||
head.appendChild(role);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'msg-actions';
|
||||
const copyBtn = document.createElement('button');
|
||||
copyBtn.className = 'msg-action';
|
||||
copyBtn.textContent = '复制';
|
||||
copyBtn.addEventListener('click', () => copyText(msg.content || ''));
|
||||
actions.appendChild(copyBtn);
|
||||
if (msg.role === 'assistant' && isLast && !msg.streaming) {
|
||||
const regen = document.createElement('button');
|
||||
regen.className = 'msg-action';
|
||||
regen.textContent = '重新生成';
|
||||
regen.addEventListener('click', regenerate);
|
||||
actions.appendChild(regen);
|
||||
}
|
||||
head.appendChild(actions);
|
||||
wrap.appendChild(head);
|
||||
|
||||
if (msg.reasoning) {
|
||||
wrap.appendChild(buildReasoningEl(msg.reasoning, true));
|
||||
}
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'message-body';
|
||||
if (msg.role === 'assistant') {
|
||||
body.classList.add('md');
|
||||
body.innerHTML = renderMarkdown(msg.content || '');
|
||||
} else {
|
||||
body.textContent = msg.content || '';
|
||||
}
|
||||
wrap.appendChild(body);
|
||||
if (msg.role === 'assistant' && msg.usage) {
|
||||
wrap.appendChild(buildStatsEl(parseUsage(msg.usage)));
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
function buildReasoningEl(text, collapsed) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'reasoning' + (collapsed ? ' collapsed done' : ' done');
|
||||
const header = document.createElement('div');
|
||||
header.className = 'reasoning-header';
|
||||
header.innerHTML = '<span class="dot"></span><span class="arrow">▼</span><span>思考过程</span>';
|
||||
const body = document.createElement('div');
|
||||
body.className = 'reasoning-body';
|
||||
body.textContent = text;
|
||||
el.appendChild(header);
|
||||
el.appendChild(body);
|
||||
header.addEventListener('click', () => el.classList.toggle('collapsed'));
|
||||
return el;
|
||||
}
|
||||
function scrollToBottom() {
|
||||
els.messages.scrollTop = els.messages.scrollHeight;
|
||||
}
|
||||
|
||||
// ---------- streaming DOM ----------
|
||||
function createStreamingEl() {
|
||||
const m = els.messages;
|
||||
m.querySelectorAll('.empty-state').forEach((n) => n.remove());
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'message assistant';
|
||||
const head = document.createElement('div');
|
||||
head.className = 'message-head';
|
||||
head.innerHTML = '<div class="message-role"><span class="avatar">✦</span><span class="message-role-name">FengAI</span></div>';
|
||||
wrap.appendChild(head);
|
||||
const body = document.createElement('div');
|
||||
body.className = 'message-body';
|
||||
const cursor = document.createElement('span');
|
||||
cursor.className = 'cursor-blink';
|
||||
wrap.appendChild(body);
|
||||
wrap.appendChild(cursor);
|
||||
m.appendChild(wrap);
|
||||
state.streamDom = { wrap, head, reasoningEl: null, reasoningBody: null, body, cursor };
|
||||
}
|
||||
function flush() {
|
||||
const dom = state.streamDom;
|
||||
const msg = state.streamMsg;
|
||||
if (!dom || !msg) return;
|
||||
if (msg.reasoning) {
|
||||
if (!dom.reasoningEl) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'reasoning';
|
||||
const header = document.createElement('div');
|
||||
header.className = 'reasoning-header';
|
||||
header.innerHTML = '<span class="dot"></span><span class="arrow">▼</span><span>思考中…</span>';
|
||||
const rbody = document.createElement('div');
|
||||
rbody.className = 'reasoning-body';
|
||||
el.appendChild(header);
|
||||
el.appendChild(rbody);
|
||||
header.addEventListener('click', () => el.classList.toggle('collapsed'));
|
||||
dom.reasoningUserAtBottom = true;
|
||||
rbody.addEventListener('scroll', () => {
|
||||
const nearBottom = (rbody.scrollHeight - rbody.scrollTop - rbody.clientHeight) < 28;
|
||||
dom.reasoningUserAtBottom = nearBottom;
|
||||
}, { passive: true });
|
||||
dom.wrap.insertBefore(el, dom.body);
|
||||
dom.reasoningEl = el;
|
||||
dom.reasoningBody = rbody;
|
||||
}
|
||||
dom.reasoningBody.textContent = msg.reasoning;
|
||||
if (dom.reasoningUserAtBottom && !dom.reasoningEl.classList.contains('collapsed')) {
|
||||
dom.reasoningBody.scrollTop = dom.reasoningBody.scrollHeight;
|
||||
}
|
||||
}
|
||||
if (msg.content.length < 40000) {
|
||||
dom.body.classList.remove('streaming-text');
|
||||
dom.body.innerHTML = renderMarkdown(msg.content);
|
||||
} else {
|
||||
dom.body.classList.add('streaming-text');
|
||||
dom.body.textContent = msg.content;
|
||||
}
|
||||
dom.cursor.style.display = state.isStreaming ? '' : 'none';
|
||||
if (state.autoScroll) scrollToBottom();
|
||||
}
|
||||
function finalizeStreamingEl() {
|
||||
const dom = state.streamDom;
|
||||
const msg = state.streamMsg;
|
||||
if (!dom || !msg) return;
|
||||
dom.body.classList.remove('streaming-text');
|
||||
dom.body.innerHTML = renderMarkdown(msg.content || '');
|
||||
dom.cursor.style.display = 'none';
|
||||
if (msg.usage) {
|
||||
const existing = dom.wrap.querySelector('.msg-stats');
|
||||
if (!existing) dom.wrap.appendChild(buildStatsEl(parseUsage(msg.usage)));
|
||||
}
|
||||
if (dom.reasoningEl) {
|
||||
dom.reasoningEl.classList.add('done');
|
||||
const label = dom.reasoningEl.querySelector('.reasoning-header span:last-child');
|
||||
if (label) label.textContent = '思考过程';
|
||||
}
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'msg-actions';
|
||||
const copyBtn = document.createElement('button');
|
||||
copyBtn.className = 'msg-action';
|
||||
copyBtn.textContent = '复制';
|
||||
copyBtn.addEventListener('click', () => copyText(msg.content || ''));
|
||||
actions.appendChild(copyBtn);
|
||||
const regen = document.createElement('button');
|
||||
regen.className = 'msg-action';
|
||||
regen.textContent = '重新生成';
|
||||
regen.addEventListener('click', regenerate);
|
||||
actions.appendChild(regen);
|
||||
dom.head.appendChild(actions);
|
||||
state.streamDom = null;
|
||||
state.streamMsg = null;
|
||||
}
|
||||
|
||||
function setStreamingUI(streaming) {
|
||||
state.isStreaming = streaming;
|
||||
els.btnSend.hidden = streaming;
|
||||
els.btnStop.hidden = !streaming;
|
||||
els.input.disabled = streaming;
|
||||
els.btnNewChat.disabled = streaming;
|
||||
els.modelSelect.disabled = streaming || state.providers.length === 0;
|
||||
els.composerHint.textContent = streaming ? '正在生成… 点击停止可中断' : '';
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
const box = document.createElement('div');
|
||||
box.className = 'error-box';
|
||||
box.textContent = msg;
|
||||
els.messages.appendChild(box);
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
// ---------- send / stream ----------
|
||||
function ensureCurrentConv() {
|
||||
if (!state.currentConv) {
|
||||
state.currentConv = { id: uid(), title: '新对话', messages: [], providerId: null, model: null };
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
if (state.isStreaming) return;
|
||||
const text = els.input.value.trim();
|
||||
if (!text) return;
|
||||
const sel = getSelectedProvider();
|
||||
if (!sel) { toast('请先配置供应商和模型'); return; }
|
||||
ensureCurrentConv();
|
||||
const conv = state.currentConv;
|
||||
conv.messages.push({ role: 'user', content: text });
|
||||
conv.providerId = sel.provider.id;
|
||||
conv.model = sel.model;
|
||||
if (conv.messages.filter((m) => m.role === 'user').length === 1) {
|
||||
conv.title = makeTitle(text);
|
||||
els.topbarTitle.textContent = conv.title;
|
||||
}
|
||||
els.input.value = '';
|
||||
autoResize();
|
||||
updateCharCount();
|
||||
renderMessages();
|
||||
await runStream();
|
||||
}
|
||||
|
||||
async function runStream() {
|
||||
const sel = getSelectedProvider();
|
||||
if (!sel) { toast('请先配置供应商和模型'); return; }
|
||||
const { provider, model } = sel;
|
||||
const conv = state.currentConv;
|
||||
setStreamingUI(true);
|
||||
const effort = state.thinkingEffort;
|
||||
const msg = { role: 'assistant', content: '', reasoning: '', streaming: true };
|
||||
conv.messages.push(msg);
|
||||
state.streamMsg = msg;
|
||||
state.dirty = false;
|
||||
createStreamingEl();
|
||||
flush();
|
||||
state.flushTimer = setInterval(flush, 100);
|
||||
|
||||
const history = conv.messages.filter((m) => !(m.role === 'assistant' && m.streaming));
|
||||
const sys = state.systemPrompt.trim() ? [{ role: 'system', content: state.systemPrompt.trim() }] : [];
|
||||
const trimmed = history.slice(-state.contextLimit);
|
||||
const apiMessages = sys.concat(trimmed.map((m) => ({ role: m.role, content: m.content })));
|
||||
|
||||
let finished = false;
|
||||
const finish = async () => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
clearInterval(state.flushTimer);
|
||||
state.flushTimer = null;
|
||||
msg.streaming = false;
|
||||
if (!msg.content && !msg.reasoning) {
|
||||
conv.messages.pop();
|
||||
}
|
||||
finalizeStreamingEl();
|
||||
setStreamingUI(false);
|
||||
await api.conversations.save(conv);
|
||||
await loadConvList();
|
||||
if (state.autoScroll) scrollToBottom();
|
||||
updateSessionStatsUI();
|
||||
};
|
||||
|
||||
api.chat.onChunk((chunk) => {
|
||||
if (chunk.content) msg.content += chunk.content;
|
||||
if (chunk.reasoning) msg.reasoning += chunk.reasoning;
|
||||
state.dirty = true;
|
||||
});
|
||||
api.chat.onUsage((u) => { if (state.streamMsg) state.streamMsg.usage = u; });
|
||||
api.chat.onDone(() => finish());
|
||||
api.chat.onError((data) => {
|
||||
showError(data.message);
|
||||
finish();
|
||||
});
|
||||
|
||||
try {
|
||||
await api.chat.start({
|
||||
providerId: provider.id,
|
||||
model,
|
||||
messages: apiMessages,
|
||||
enableThinking: effort !== 'off',
|
||||
reasoningEffort: effort !== 'off' ? effort : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
||||
function stopStreaming() {
|
||||
api.chat.stop();
|
||||
}
|
||||
|
||||
async function regenerate() {
|
||||
if (state.isStreaming) return;
|
||||
const conv = state.currentConv;
|
||||
if (!conv || !conv.messages.length) return;
|
||||
if (conv.messages[conv.messages.length - 1].role === 'assistant') {
|
||||
conv.messages.pop();
|
||||
}
|
||||
if (!conv.messages.length || conv.messages[conv.messages.length - 1].role !== 'user') {
|
||||
toast('没有可重新生成的消息');
|
||||
renderMessages();
|
||||
return;
|
||||
}
|
||||
renderMessages();
|
||||
await runStream();
|
||||
}
|
||||
|
||||
function clearCurrent() {
|
||||
if (!state.currentConv) return;
|
||||
showConfirm('清空当前对话的全部消息?', async () => {
|
||||
state.currentConv.messages = [];
|
||||
state.currentConv.title = '新对话';
|
||||
els.topbarTitle.textContent = '新对话';
|
||||
await api.conversations.save(state.currentConv);
|
||||
renderMessages();
|
||||
await loadConvList();
|
||||
toast('已清空当前对话');
|
||||
}, '清空');
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
if (state.convList.length === 0) { toast('暂无对话'); return; }
|
||||
showConfirm('确定清空全部对话?此操作不可恢复。', async () => {
|
||||
if (state.isStreaming) stopStreaming();
|
||||
await api.conversations.clearAll();
|
||||
state.currentConv = null;
|
||||
await loadConvList();
|
||||
newConversation();
|
||||
toast('已清空全部对话');
|
||||
}, '全部清空');
|
||||
}
|
||||
|
||||
// ---------- settings modal ----------
|
||||
function openSettings() {
|
||||
els.settingsModal.hidden = false;
|
||||
els.pfSystemPrompt.value = state.systemPrompt;
|
||||
refreshProviderSelect();
|
||||
}
|
||||
function closeSettings() {
|
||||
state.systemPrompt = els.pfSystemPrompt.value;
|
||||
localStorage.setItem(LS.system, state.systemPrompt);
|
||||
els.settingsModal.hidden = true;
|
||||
refreshModelSelect();
|
||||
}
|
||||
function refreshProviderSelect() {
|
||||
const sel = els.providerSelect;
|
||||
sel.innerHTML = '';
|
||||
if (state.providers.length === 0) {
|
||||
const opt = document.createElement('option');
|
||||
opt.textContent = '(无供应商,请新建)';
|
||||
sel.appendChild(opt);
|
||||
state.editingProviderId = null;
|
||||
loadProviderForm(null);
|
||||
return;
|
||||
}
|
||||
state.providers.forEach((p) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.id;
|
||||
opt.textContent = p.name;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
if (!state.editingProviderId || !state.providers.find((p) => p.id === state.editingProviderId)) {
|
||||
state.editingProviderId = state.providers[0].id;
|
||||
}
|
||||
sel.value = state.editingProviderId;
|
||||
loadProviderForm(state.editingProviderId);
|
||||
}
|
||||
function loadProviderForm(id) {
|
||||
state.editingProviderId = id;
|
||||
const p = state.providers.find((x) => x.id === id);
|
||||
els.pfName.value = p ? p.name : '';
|
||||
els.pfBaseURL.value = p ? p.baseURL : '';
|
||||
els.pfApiKey.value = p ? p.apiKey : '';
|
||||
els.pfModels.value = p && p.models ? p.models.join('\n') : '';
|
||||
els.pfMaxTokens.value = p && p.maxTokens ? p.maxTokens : '';
|
||||
els.testHint.textContent = '';
|
||||
els.testHint.className = 'hint';
|
||||
}
|
||||
function parseModels(text) {
|
||||
return text.split(/[\n,]/).map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
// ---------- composer ----------
|
||||
function autoResize() {
|
||||
els.input.style.height = 'auto';
|
||||
els.input.style.height = Math.min(els.input.scrollHeight, 220) + 'px';
|
||||
}
|
||||
function updateCharCount() {
|
||||
const len = els.input.value.length;
|
||||
els.charCount.textContent = len > 999 ? (len / 1000).toFixed(1) + 'k' : len;
|
||||
}
|
||||
|
||||
// ---------- events ----------
|
||||
function initEvents() {
|
||||
els.themeBtn.addEventListener('click', toggleTheme);
|
||||
els.btnNewChat.addEventListener('click', newConversation);
|
||||
els.btnSettings.addEventListener('click', openSettings);
|
||||
|
||||
// generic dialog
|
||||
els.dialogOk.addEventListener('click', () => {
|
||||
const cb = dialogState.onOk;
|
||||
const val = dialogState.input ? els.dialogInput.value : true;
|
||||
closeDialog();
|
||||
if (cb) cb(val);
|
||||
});
|
||||
els.dialogCancel.addEventListener('click', closeDialog);
|
||||
els.dialogInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); els.dialogOk.click(); }
|
||||
else if (e.key === 'Escape') { closeDialog(); }
|
||||
});
|
||||
els.dialogModal.addEventListener('click', (e) => { if (e.target === els.dialogModal) closeDialog(); });
|
||||
els.btnClearAll.addEventListener('click', clearAll);
|
||||
els.closeSettings.addEventListener('click', closeSettings);
|
||||
els.settingsModal.addEventListener('click', (e) => { if (e.target === els.settingsModal) closeSettings(); });
|
||||
els.searchInput.addEventListener('input', renderConvList);
|
||||
|
||||
els.modelSelect.addEventListener('change', async () => {
|
||||
const sel = getSelectedProvider();
|
||||
if (sel) {
|
||||
state.selectedProviderId = sel.provider.id;
|
||||
state.selectedModel = sel.model;
|
||||
if (state.currentConv) {
|
||||
state.currentConv.providerId = sel.provider.id;
|
||||
state.currentConv.model = sel.model;
|
||||
await api.conversations.save(state.currentConv);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
els.ctxMinus.addEventListener('click', () => setCtx(state.contextLimit - 1));
|
||||
els.ctxPlus.addEventListener('click', () => setCtx(state.contextLimit + 1));
|
||||
els.effortSeg.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.effort-opt');
|
||||
if (btn) setEffort(btn.dataset.v);
|
||||
});
|
||||
|
||||
// conv menu
|
||||
els.convMenuBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
els.convMenu.hidden = !els.convMenu.hidden;
|
||||
});
|
||||
document.addEventListener('click', () => { els.convMenu.hidden = true; });
|
||||
els.convMenu.addEventListener('click', (e) => e.stopPropagation());
|
||||
els.menuRename.addEventListener('click', () => {
|
||||
els.convMenu.hidden = true;
|
||||
if (!state.currentConv) return;
|
||||
showPrompt('重命名对话', state.currentConv.title, (v) => {
|
||||
if (v && v.trim()) renameCurrent(v.trim());
|
||||
});
|
||||
});
|
||||
els.menuClearCurrent.addEventListener('click', () => {
|
||||
els.convMenu.hidden = true;
|
||||
clearCurrent();
|
||||
});
|
||||
|
||||
// input
|
||||
els.input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
|
||||
});
|
||||
els.input.addEventListener('input', () => { autoResize(); updateCharCount(); });
|
||||
els.btnSend.addEventListener('click', sendMessage);
|
||||
els.btnStop.addEventListener('click', stopStreaming);
|
||||
|
||||
// smart scroll
|
||||
els.messages.addEventListener('scroll', () => {
|
||||
const nearBottom = (els.messages.scrollHeight - els.messages.scrollTop - els.messages.clientHeight) < 80;
|
||||
state.autoScroll = nearBottom;
|
||||
els.scrollBottomBtn.hidden = nearBottom;
|
||||
}, { passive: true });
|
||||
els.scrollBottomBtn.addEventListener('click', () => {
|
||||
state.autoScroll = true;
|
||||
els.scrollBottomBtn.hidden = true;
|
||||
scrollToBottom();
|
||||
});
|
||||
|
||||
// provider settings
|
||||
els.providerSelect.addEventListener('change', () => loadProviderForm(els.providerSelect.value));
|
||||
els.btnNewProvider.addEventListener('click', () => {
|
||||
loadProviderForm(null);
|
||||
els.pfName.focus();
|
||||
});
|
||||
els.btnDeleteProvider.addEventListener('click', () => {
|
||||
if (!state.editingProviderId) return;
|
||||
showConfirm('确定删除该供应商配置吗?', async () => {
|
||||
await api.providers.remove(state.editingProviderId);
|
||||
state.editingProviderId = null;
|
||||
state.providers = await api.providers.list();
|
||||
refreshProviderSelect();
|
||||
}, '删除');
|
||||
});
|
||||
els.btnTestAndFetch.addEventListener('click', async () => {
|
||||
els.testHint.textContent = '测试中…';
|
||||
els.testHint.className = 'hint';
|
||||
const provider = {
|
||||
id: state.editingProviderId || undefined,
|
||||
name: els.pfName.value.trim() || '(未命名)',
|
||||
baseURL: els.pfBaseURL.value.trim(),
|
||||
apiKey: els.pfApiKey.value.trim(),
|
||||
models: parseModels(els.pfModels.value),
|
||||
maxTokens: parseInt(els.pfMaxTokens.value.trim(), 10) || 0,
|
||||
};
|
||||
const res = await api.providers.test(provider);
|
||||
if (res.ok) {
|
||||
els.testHint.textContent = res.models.length
|
||||
? '连接成功,发现 ' + res.models.length + ' 个模型'
|
||||
: '连接成功,但未返回模型列表(可手动填写)';
|
||||
els.testHint.className = 'hint ok';
|
||||
if (res.models.length) els.pfModels.value = res.models.join('\n');
|
||||
} else {
|
||||
els.testHint.textContent = '失败:' + res.error;
|
||||
els.testHint.className = 'hint err';
|
||||
}
|
||||
});
|
||||
els.btnSaveProvider.addEventListener('click', async () => {
|
||||
const provider = {
|
||||
id: state.editingProviderId || undefined,
|
||||
name: els.pfName.value.trim() || '(未命名)',
|
||||
baseURL: els.pfBaseURL.value.trim(),
|
||||
apiKey: els.pfApiKey.value.trim(),
|
||||
models: parseModels(els.pfModels.value),
|
||||
maxTokens: parseInt(els.pfMaxTokens.value.trim(), 10) || 0,
|
||||
};
|
||||
if (!provider.baseURL) { toast('请填写 API 地址'); return; }
|
||||
const saved = await api.providers.save(provider);
|
||||
state.editingProviderId = saved.id;
|
||||
state.providers = await api.providers.list();
|
||||
refreshProviderSelect();
|
||||
els.testHint.textContent = '已保存';
|
||||
els.testHint.className = 'hint ok';
|
||||
});
|
||||
els.btnOpenLog.addEventListener('click', () => {
|
||||
api.app.openLog();
|
||||
toast('已在资源管理器中定位日志文件');
|
||||
});
|
||||
}
|
||||
|
||||
async function renameCurrent(title) {
|
||||
if (!state.currentConv) return;
|
||||
await api.conversations.rename(state.currentConv.id, title);
|
||||
state.currentConv.title = title;
|
||||
els.topbarTitle.textContent = title;
|
||||
await loadConvList();
|
||||
}
|
||||
|
||||
function setCtx(v) {
|
||||
v = Math.max(0, Math.min(50, v));
|
||||
state.contextLimit = v;
|
||||
els.ctxVal.textContent = v;
|
||||
localStorage.setItem(LS.ctx, String(v));
|
||||
}
|
||||
|
||||
// ---------- boot ----------
|
||||
async function init() {
|
||||
if (window.marked) { try { marked.setOptions({ breaks: true, gfm: true }); } catch { /* ignore */ } }
|
||||
loadPrefs();
|
||||
initEvents();
|
||||
await loadProviders();
|
||||
await loadConvList();
|
||||
newConversation();
|
||||
autoResize();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
})();
|
||||
@@ -0,0 +1,163 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data:;" />
|
||||
<title>FengAI</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<span class="brand-logo">✦</span>
|
||||
<span class="brand-name">FengAI</span>
|
||||
<button class="icon-btn theme-btn" id="themeBtn" title="切换主题">🌙</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-actions">
|
||||
<button class="btn-new" id="btnNewChat">+ 新建对话</button>
|
||||
<div class="search-wrap">
|
||||
<input type="text" id="searchInput" class="search-input" placeholder="搜索对话..." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="conversation-list" id="conversationList"></div>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<button class="btn-footer danger" id="btnClearAll">🗑 清空全部</button>
|
||||
<button class="btn-footer" id="btnSettings">⚙ 供应商与模型</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main -->
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<div class="topbar-left">
|
||||
<div class="topbar-title" id="topbarTitle">新对话</div>
|
||||
<div class="conv-menu-wrap">
|
||||
<button class="icon-btn" id="convMenuBtn" title="对话操作">⋯</button>
|
||||
<div class="dropdown-menu" id="convMenu" hidden>
|
||||
<button class="dropdown-item" id="menuRename">✏ 重命名对话</button>
|
||||
<button class="dropdown-item danger" id="menuClearCurrent">🗑 清空当前对话</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<div class="stepper" title="保留最近几轮上下文">
|
||||
<span class="stepper-label">上下文</span>
|
||||
<button class="step-btn" id="ctxMinus">−</button>
|
||||
<span class="step-val" id="ctxVal">6</span>
|
||||
<button class="step-btn" id="ctxPlus">+</button>
|
||||
</div>
|
||||
<div class="effort-seg" id="effortSeg" title="思考强度(reasoning_effort)">
|
||||
<span class="effort-label">思考</span>
|
||||
<button class="effort-opt active" data-v="off">关闭</button>
|
||||
<button class="effort-opt" data-v="high">增强</button>
|
||||
<button class="effort-opt" data-v="max">深度</button>
|
||||
</div>
|
||||
<select id="modelSelect" class="model-select" title="选择模型"></select>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="messages-wrap">
|
||||
<div class="messages" id="messages"></div>
|
||||
<button class="scroll-bottom-btn" id="scrollBottomBtn" title="回到底部" hidden>↓</button>
|
||||
</div>
|
||||
|
||||
<footer class="composer">
|
||||
<div class="composer-inner">
|
||||
<textarea id="input" class="input" placeholder="输入消息,Enter 发送 · Shift+Enter 换行" rows="1"></textarea>
|
||||
<div class="composer-side">
|
||||
<span class="char-count" id="charCount">0</span>
|
||||
<button id="btnSend" class="btn-send">发送</button>
|
||||
<button id="btnStop" class="btn-stop" hidden>停止</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="composer-hint" id="composerHint"></div>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Settings Modal -->
|
||||
<div class="modal-overlay" id="settingsModal" hidden>
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h2>供应商与模型配置</h2>
|
||||
<button class="modal-close" id="closeSettings">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="provider-section">
|
||||
<div class="provider-toolbar">
|
||||
<select id="providerSelect" class="provider-select"></select>
|
||||
<button class="btn-sm" id="btnNewProvider">+ 新建</button>
|
||||
<button class="btn-sm btn-danger" id="btnDeleteProvider">删除</button>
|
||||
</div>
|
||||
|
||||
<div class="provider-form" id="providerForm">
|
||||
<label class="field">
|
||||
<span>供应商名称</span>
|
||||
<input type="text" id="pfName" placeholder="GLM5.1" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>API 地址(将自动拼接 /chat/completions)</span>
|
||||
<input type="text" id="pfBaseURL" placeholder="https://ark.cn-beijing.volces.com/api/coding/v3" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>密钥</span>
|
||||
<input type="password" id="pfApiKey" placeholder="sk-..." />
|
||||
</label>
|
||||
<div class="field-row">
|
||||
<button class="btn-sm" id="btnTestAndFetch">测试并获取模型</button>
|
||||
<span class="hint" id="testHint"></span>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>模型列表(每行一个,或逗号分隔)</span>
|
||||
<textarea id="pfModels" rows="5" placeholder="glm-5.2,kimi-k2.6"></textarea>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>最大输出 tokens(留空=不限制;GLM-5.2 可填 131072)</span>
|
||||
<input type="text" id="pfMaxTokens" placeholder="131072" />
|
||||
</label>
|
||||
<div class="field">
|
||||
<span>系统提示词(可选,对所有对话生效)</span>
|
||||
<textarea id="pfSystemPrompt" rows="3" placeholder="你是一个乐于助人的助手..."></textarea>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<button class="btn-sm btn-primary" id="btnSaveProvider">保存供应商</button>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<button class="btn-sm" id="btnOpenLog">📂 打开日志文件</button>
|
||||
<span class="hint">排查流式中断等问题时查看 fengai.log</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast" hidden></div>
|
||||
|
||||
<!-- Generic dialog (replaces native alert/confirm/prompt) -->
|
||||
<div class="modal-overlay" id="dialogModal" hidden>
|
||||
<div class="modal dialog-modal">
|
||||
<div class="modal-header">
|
||||
<h2 id="dialogTitle">提示</h2>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="dialog-message" id="dialogMessage"></div>
|
||||
<input type="text" class="dialog-input" id="dialogInput" hidden />
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-sm" id="dialogCancel">取消</button>
|
||||
<button class="btn-sm btn-primary" id="dialogOk">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="vendor/marked.min.js"></script>
|
||||
<script src="vendor/purify.min.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,652 @@
|
||||
/* ============ Themes ============ */
|
||||
:root {
|
||||
--radius: 14px;
|
||||
--radius-sm: 9px;
|
||||
--transition: 0.18s ease;
|
||||
--grad: linear-gradient(135deg, #7b5cff 0%, #4a9eff 100%);
|
||||
--grad-soft: linear-gradient(135deg, rgba(123,92,255,0.18), rgba(74,158,255,0.18));
|
||||
--shadow: 0 8px 28px rgba(0,0,0,0.28);
|
||||
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", Roboto, sans-serif;
|
||||
--mono: "JetBrains Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
}
|
||||
[data-theme="dark"] {
|
||||
--bg: #14151b;
|
||||
--bg-2: #1c1d26;
|
||||
--bg-3: #262833;
|
||||
--bg-4: #2e313f;
|
||||
--bg-hover: #32344370;
|
||||
--surface: #1f2030;
|
||||
--border: #313447;
|
||||
--border-soft: #2a2c3a;
|
||||
--text: #ecedf2;
|
||||
--text-dim: #a2a4b4;
|
||||
--text-faint: #6c6e80;
|
||||
--accent: #7b5cff;
|
||||
--accent-2: #4a9eff;
|
||||
--danger: #ef5d6a;
|
||||
--user-bubble: #2b3354;
|
||||
--code-bg: #16161e;
|
||||
--reasoning-bg: rgba(123,92,255,0.06);
|
||||
--reasoning-border: rgba(123,92,255,0.22);
|
||||
--reasoning-text: #b9a7e6;
|
||||
--scrollbar: #3a3d50;
|
||||
}
|
||||
[data-theme="light"] {
|
||||
--bg: #f4f5fa;
|
||||
--bg-2: #ffffff;
|
||||
--bg-3: #eef0f7;
|
||||
--bg-4: #e6e9f3;
|
||||
--bg-hover: #e3e6f0;
|
||||
--surface: #ffffff;
|
||||
--border: #e0e3ee;
|
||||
--border-soft: #eceef5;
|
||||
--text: #1c1e2a;
|
||||
--text-dim: #62677c;
|
||||
--text-faint: #9aa0b4;
|
||||
--accent: #6b4cff;
|
||||
--accent-2: #2f8fff;
|
||||
--danger: #e0455a;
|
||||
--user-bubble: #e7ebff;
|
||||
--code-bg: #f0f1f7;
|
||||
--reasoning-bg: rgba(107,76,255,0.05);
|
||||
--reasoning-border: rgba(107,76,255,0.2);
|
||||
--reasoning-text: #6d5ac4;
|
||||
--scrollbar: #cfd4e2;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
font-family: var(--font);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
transition: background var(--transition), color var(--transition);
|
||||
}
|
||||
#app { display: flex; height: 100vh; }
|
||||
|
||||
/* ============ Sidebar ============ */
|
||||
.sidebar {
|
||||
width: 264px;
|
||||
min-width: 264px;
|
||||
background: var(--bg-2);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 16px 16px 12px;
|
||||
}
|
||||
.brand-logo {
|
||||
font-size: 22px;
|
||||
background: var(--grad);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
filter: drop-shadow(0 2px 6px rgba(123,92,255,0.5));
|
||||
}
|
||||
.brand-name {
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
background: var(--grad);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
flex: 1;
|
||||
}
|
||||
.icon-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: var(--transition);
|
||||
}
|
||||
.icon-btn:hover { background: var(--bg-hover); color: var(--text); }
|
||||
|
||||
.sidebar-actions { padding: 4px 12px 10px; }
|
||||
.btn-new {
|
||||
width: 100%;
|
||||
padding: 11px;
|
||||
background: var(--grad);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: var(--transition);
|
||||
box-shadow: 0 4px 14px rgba(123,92,255,0.32);
|
||||
}
|
||||
.btn-new:hover { filter: brightness(1.08); transform: translateY(-1px); }
|
||||
.search-wrap { margin-top: 10px; }
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 8px 11px;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.search-input:focus { outline: none; border-color: var(--accent); background: var(--bg-2); }
|
||||
|
||||
.conversation-list { flex: 1; overflow-y: auto; padding: 6px 8px; }
|
||||
.conv-item {
|
||||
padding: 10px 11px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
margin-bottom: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: background var(--transition);
|
||||
position: relative;
|
||||
}
|
||||
.conv-item:hover { background: var(--bg-hover); }
|
||||
.conv-item.active { background: var(--grad-soft); }
|
||||
.conv-item.active::before {
|
||||
content: '';
|
||||
position: absolute; left: 0; top: 22%; bottom: 22%;
|
||||
width: 3px; border-radius: 3px;
|
||||
background: var(--grad);
|
||||
}
|
||||
.conv-main { flex: 1; min-width: 0; }
|
||||
.conv-title {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 13.5px;
|
||||
color: var(--text);
|
||||
}
|
||||
.conv-time { font-size: 11px; color: var(--text-faint); }
|
||||
.conv-del {
|
||||
opacity: 0;
|
||||
background: none; border: none;
|
||||
color: var(--text-faint);
|
||||
cursor: pointer; font-size: 15px;
|
||||
padding: 2px 5px; border-radius: 5px;
|
||||
flex-shrink: 0;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.conv-item:hover .conv-del { opacity: 1; }
|
||||
.conv-del:hover { color: var(--danger); background: var(--bg-hover); }
|
||||
|
||||
.sidebar-footer { padding: 10px 12px; border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 6px; }
|
||||
.btn-footer {
|
||||
width: 100%;
|
||||
padding: 9px;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: var(--transition);
|
||||
text-align: left;
|
||||
}
|
||||
.btn-footer:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.btn-footer.danger:hover { color: var(--danger); border-color: var(--danger); }
|
||||
|
||||
/* ============ Main ============ */
|
||||
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||
.topbar {
|
||||
height: 54px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
flex-shrink: 0;
|
||||
gap: 12px;
|
||||
}
|
||||
.topbar-left { display: flex; align-items: center; gap: 6px; min-width: 0; flex: 1; }
|
||||
.topbar-title {
|
||||
font-size: 14.5px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.conv-menu-wrap { position: relative; flex-shrink: 0; }
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px); right: 0;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow);
|
||||
min-width: 168px;
|
||||
padding: 5px;
|
||||
z-index: 50;
|
||||
animation: fadeIn 0.12s ease;
|
||||
}
|
||||
.dropdown-menu[hidden] { display: none; }
|
||||
.dropdown-item {
|
||||
display: block; width: 100%;
|
||||
padding: 8px 11px;
|
||||
background: none; border: none;
|
||||
color: var(--text); cursor: pointer;
|
||||
font-size: 13px; text-align: left;
|
||||
border-radius: 6px;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.dropdown-item:hover { background: var(--bg-hover); }
|
||||
.dropdown-item.danger:hover { background: rgba(239,93,106,0.12); color: var(--danger); }
|
||||
.topbar-right { display: flex; align-items: center; gap: 12px; }
|
||||
|
||||
.stepper { display: flex; align-items: center; gap: 5px; font-size: 12px; color: var(--text-dim); }
|
||||
.stepper-label { user-select: none; }
|
||||
.step-btn {
|
||||
width: 22px; height: 22px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-3);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.step-btn:hover { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
.step-val { min-width: 16px; text-align: center; font-weight: 600; color: var(--text); }
|
||||
|
||||
.thinking-toggle {
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
font-size: 12.5px; color: var(--text-dim);
|
||||
cursor: pointer; user-select: none;
|
||||
}
|
||||
.thinking-toggle input { cursor: pointer; accent-color: var(--accent); width: 14px; height: 14px; }
|
||||
|
||||
.effort-seg {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 3px;
|
||||
}
|
||||
.effort-label { font-size: 12px; color: var(--text-dim); padding: 0 6px 0 4px; user-select: none; }
|
||||
.effort-opt {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
user-select: none;
|
||||
}
|
||||
.effort-opt:hover { color: var(--text); }
|
||||
.effort-opt.active {
|
||||
background: var(--grad);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 6px rgba(123,92,255,0.35);
|
||||
}
|
||||
|
||||
.model-select {
|
||||
padding: 7px 11px;
|
||||
background: var(--bg-3);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
max-width: 230px;
|
||||
}
|
||||
.model-select:focus { outline: none; border-color: var(--accent); }
|
||||
|
||||
/* ============ Messages ============ */
|
||||
.messages-wrap { flex: 1; position: relative; min-height: 0; }
|
||||
.messages { height: 100%; overflow-y: auto; padding: 22px 0 8px; scroll-behavior: smooth; }
|
||||
|
||||
.message {
|
||||
max-width: 820px;
|
||||
margin: 0 auto 24px;
|
||||
padding: 0 24px;
|
||||
user-select: text;
|
||||
position: relative;
|
||||
}
|
||||
.message-head {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
.message-role {
|
||||
font-size: 12px; font-weight: 700;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.message-role .avatar {
|
||||
width: 22px; height: 22px; border-radius: 7px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 12px; color: #fff; font-weight: 700;
|
||||
}
|
||||
.message.user .avatar { background: var(--accent-2); }
|
||||
.message.assistant .avatar { background: var(--grad); }
|
||||
.message-role-name { color: var(--text-dim); font-size: 12.5px; }
|
||||
.msg-actions { margin-left: auto; display: flex; gap: 4px; opacity: 0; transition: var(--transition); }
|
||||
.message:hover .msg-actions { opacity: 1; }
|
||||
.msg-action {
|
||||
background: var(--bg-3); border: 1px solid var(--border);
|
||||
color: var(--text-dim); cursor: pointer;
|
||||
font-size: 11px; padding: 3px 9px; border-radius: 6px;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.msg-action:hover { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
|
||||
.message.user .message-body {
|
||||
background: var(--user-bubble);
|
||||
border-radius: 14px;
|
||||
padding: 11px 15px;
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.message.assistant .message-body {
|
||||
line-height: 1.75;
|
||||
word-wrap: break-word;
|
||||
user-select: text;
|
||||
}
|
||||
.message.assistant .message-body.streaming-text { white-space: pre-wrap; }
|
||||
|
||||
.msg-stats {
|
||||
margin-top: 8px;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-faint);
|
||||
font-family: var(--mono);
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
/* reasoning */
|
||||
.reasoning {
|
||||
background: var(--reasoning-bg);
|
||||
border: 1px solid var(--reasoning-border);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.reasoning-header {
|
||||
padding: 8px 12px;
|
||||
font-size: 12.5px;
|
||||
color: var(--reasoning-text);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
user-select: none;
|
||||
transition: background var(--transition);
|
||||
}
|
||||
.reasoning-header:hover { background: rgba(123,92,255,0.06); }
|
||||
.reasoning-header .arrow { transition: transform var(--transition); font-size: 9px; }
|
||||
.reasoning.collapsed .arrow { transform: rotate(-90deg); }
|
||||
.reasoning-header .dot {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
background: var(--accent); animation: pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
.reasoning.done .dot { animation: none; background: var(--text-faint); }
|
||||
@keyframes pulse { 0%,100% { opacity: 0.4; } 50% { opacity: 1; } }
|
||||
.reasoning-body {
|
||||
padding: 4px 12px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.65;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
border-top: 1px solid var(--reasoning-border);
|
||||
margin-top: 4px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
.reasoning.collapsed .reasoning-body { display: none; }
|
||||
|
||||
.cursor-blink::after { content: '▋'; animation: blink 1s step-end infinite; color: var(--accent); margin-left: 1px; }
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
margin-top: 12vh;
|
||||
padding: 0 24px;
|
||||
}
|
||||
.empty-state .empty-logo {
|
||||
font-size: 46px;
|
||||
background: var(--grad);
|
||||
-webkit-background-clip: text; background-clip: text; color: transparent;
|
||||
filter: drop-shadow(0 6px 18px rgba(123,92,255,0.4));
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.empty-state h2 { font-size: 22px; margin-bottom: 8px; color: var(--text); font-weight: 700; }
|
||||
.empty-state p { color: var(--text-faint); font-size: 14px; }
|
||||
|
||||
.error-box {
|
||||
max-width: 820px;
|
||||
margin: 0 auto 16px;
|
||||
padding: 12px 15px;
|
||||
background: rgba(239,93,106,0.1);
|
||||
border: 1px solid var(--danger);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--danger);
|
||||
font-size: 13px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* ============ Markdown styling ============ */
|
||||
.md > *:first-child { margin-top: 0; }
|
||||
.md > *:last-child { margin-bottom: 0; }
|
||||
.md p { margin: 0 0 12px; }
|
||||
.md h1,.md h2,.md h3,.md h4 { margin: 18px 0 10px; font-weight: 700; line-height: 1.3; }
|
||||
.md h1 { font-size: 1.5em; } .md h2 { font-size: 1.3em; } .md h3 { font-size: 1.15em; }
|
||||
.md ul,.md ol { margin: 0 0 12px; padding-left: 24px; }
|
||||
.md li { margin: 4px 0; }
|
||||
.md a { color: var(--accent-2); text-decoration: none; }
|
||||
.md a:hover { text-decoration: underline; }
|
||||
.md blockquote {
|
||||
margin: 0 0 12px; padding: 8px 14px;
|
||||
border-left: 3px solid var(--accent);
|
||||
background: var(--bg-3); border-radius: 0 8px 8px 0;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.md code {
|
||||
font-family: var(--mono);
|
||||
background: var(--code-bg);
|
||||
padding: 2px 6px;
|
||||
border-radius: 5px;
|
||||
font-size: 0.88em;
|
||||
}
|
||||
.md pre {
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
overflow-x: auto;
|
||||
margin: 0 0 12px;
|
||||
position: relative;
|
||||
}
|
||||
.md pre code { background: none; padding: 0; font-size: 0.86em; line-height: 1.55; }
|
||||
.md table { border-collapse: collapse; margin: 0 0 12px; display: block; overflow-x: auto; }
|
||||
.md th,.md td { border: 1px solid var(--border); padding: 7px 12px; text-align: left; }
|
||||
.md th { background: var(--bg-3); font-weight: 600; }
|
||||
.md hr { border: none; border-top: 1px solid var(--border); margin: 16px 0; }
|
||||
|
||||
/* ============ Composer ============ */
|
||||
.composer {
|
||||
flex-shrink: 0;
|
||||
padding: 12px 24px 16px;
|
||||
background: var(--bg);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.composer-inner {
|
||||
max-width: 820px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-end;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 9px 10px 9px 16px;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.composer-inner:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(123,92,255,0.15); }
|
||||
.input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
resize: none;
|
||||
max-height: 220px;
|
||||
line-height: 1.55;
|
||||
outline: none;
|
||||
user-select: text;
|
||||
}
|
||||
.input::placeholder { color: var(--text-faint); }
|
||||
.composer-side { display: flex; align-items: center; gap: 10px; }
|
||||
.char-count { font-size: 11px; color: var(--text-faint); min-width: 28px; text-align: right; }
|
||||
.btn-send, .btn-stop {
|
||||
padding: 9px 20px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.btn-send { background: var(--grad); color: #fff; box-shadow: 0 3px 12px rgba(123,92,255,0.3); }
|
||||
.btn-send:hover { filter: brightness(1.08); }
|
||||
.btn-send:disabled { opacity: 0.45; cursor: not-allowed; box-shadow: none; }
|
||||
.btn-stop { background: var(--danger); color: #fff; }
|
||||
.btn-stop:hover { filter: brightness(1.08); }
|
||||
.composer-hint { max-width: 820px; margin: 6px auto 0; font-size: 11px; color: var(--text-faint); min-height: 14px; }
|
||||
|
||||
/* scroll to bottom */
|
||||
.scroll-bottom-btn {
|
||||
position: absolute;
|
||||
right: 28px; bottom: 14px;
|
||||
width: 38px; height: 38px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-2);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-size: 17px;
|
||||
box-shadow: var(--shadow);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: var(--transition);
|
||||
z-index: 5;
|
||||
}
|
||||
.scroll-bottom-btn:hover { background: var(--accent); color: #fff; border-color: var(--accent); transform: translateY(-2px); }
|
||||
|
||||
/* ============ Modal ============ */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.55);
|
||||
backdrop-filter: blur(3px);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 100;
|
||||
animation: fadeIn 0.15s ease;
|
||||
}
|
||||
.modal-overlay[hidden] { display: none; }
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
.modal {
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
width: 620px; max-width: 92vw; max-height: 88vh;
|
||||
display: flex; flex-direction: column;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.modal-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.modal-header h2 { font-size: 16px; font-weight: 700; }
|
||||
.modal-close { background: none; border: none; color: var(--text-dim); font-size: 26px; cursor: pointer; line-height: 1; }
|
||||
.modal-close:hover { color: var(--text); }
|
||||
.modal-body { padding: 20px; overflow-y: auto; }
|
||||
.provider-toolbar { display: flex; gap: 8px; margin-bottom: 18px; align-items: center; }
|
||||
.provider-select {
|
||||
flex: 1; padding: 8px 10px;
|
||||
background: var(--bg-3); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
}
|
||||
.provider-select:focus { outline: none; border-color: var(--accent); }
|
||||
.btn-sm {
|
||||
padding: 8px 14px;
|
||||
background: var(--bg-3); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||
cursor: pointer; font-size: 13px;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.btn-sm:hover { background: var(--bg-hover); }
|
||||
.btn-sm.btn-primary { background: var(--grad); border: none; color: #fff; box-shadow: 0 3px 10px rgba(123,92,255,0.28); }
|
||||
.btn-sm.btn-primary:hover { filter: brightness(1.08); }
|
||||
.btn-sm.btn-danger { color: var(--danger); }
|
||||
.btn-sm.btn-danger:hover { background: rgba(239,93,106,0.12); }
|
||||
|
||||
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
|
||||
.field > span { font-size: 12px; color: var(--text-dim); }
|
||||
.field input, .field textarea {
|
||||
padding: 9px 11px;
|
||||
background: var(--bg-3); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||
font-size: 13px; font-family: inherit; resize: vertical;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.field input:focus, .field textarea:focus { outline: none; border-color: var(--accent); background: var(--bg-2); }
|
||||
.field-row { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; }
|
||||
.hint { font-size: 12px; color: var(--text-dim); }
|
||||
.hint.ok { color: #3ec77a; }
|
||||
.hint.err { color: var(--danger); }
|
||||
|
||||
/* toast */
|
||||
.toast {
|
||||
position: fixed; bottom: 30px; left: 50%; transform: translateX(-50%) translateY(20px);
|
||||
background: var(--bg-4); color: var(--text);
|
||||
padding: 10px 20px; border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow);
|
||||
font-size: 13px;
|
||||
z-index: 200;
|
||||
opacity: 0; transition: all 0.25s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
.toast[hidden] { display: none; }
|
||||
|
||||
.dialog-modal { width: 420px; max-width: 92vw; }
|
||||
.dialog-message { font-size: 14px; line-height: 1.6; color: var(--text); white-space: pre-wrap; }
|
||||
.dialog-input {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
padding: 9px 11px;
|
||||
background: var(--bg-3); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
}
|
||||
.dialog-input:focus { outline: none; border-color: var(--accent); }
|
||||
.modal-footer {
|
||||
display: flex; justify-content: flex-end; gap: 8px;
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* scrollbars */
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--scrollbar); border-radius: 6px; border: 2px solid transparent; background-clip: content-box; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text-faint); background-clip: content-box; }
|
||||
Vendored
+79
File diff suppressed because one or more lines are too long
Vendored
+3
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user