首次提交

This commit is contained in:
2026-07-21 09:01:46 +08:00
parent c75555a40d
commit 5cff49f2b2
16 changed files with 6752 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
*.log
+3
View File
@@ -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/
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

+4143
View File
File diff suppressed because it is too large Load Diff
+56
View File
@@ -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"
}
}
+171
View File
@@ -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
View File
@@ -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 };
+29
View File
@@ -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 };
+140
View File
@@ -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();
});
+45
View File
@@ -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'),
},
});
+125
View File
@@ -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,
};
+953
View File
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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);
})();
+163
View File
@@ -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>
+652
View File
@@ -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; }
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long