首次提交
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