Initial commit: NekoAI Cloudflare chat app
This commit is contained in:
4
.dev.vars.example
Normal file
4
.dev.vars.example
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
OPENAI_BASE_URL="https://your-openai-compatible-api.example.com"
|
||||||
|
OPENAI_API_KEY="YOUR_UPSTREAM_API_KEY"
|
||||||
|
ACCESS_KEY="YOUR_SHARED_ACCESS_KEY"
|
||||||
|
ALLOWED_ORIGIN="https://your-pages-project.pages.dev"
|
||||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
.wrangler/
|
||||||
|
.env
|
||||||
|
public/config.js
|
||||||
134
README.md
Normal file
134
README.md
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
# NekoAI
|
||||||
|
|
||||||
|
一个部署在 Cloudflare 上的极简网页聊天工具:
|
||||||
|
|
||||||
|
- OpenAI 兼容接口
|
||||||
|
- 单访问密钥鉴权
|
||||||
|
- 动态读取 `/v1/models`
|
||||||
|
- 本地浏览器保存聊天记录
|
||||||
|
- 不依赖数据库
|
||||||
|
- 前后端分离:**Pages 前端 + Workers API**
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
- `src/index.js`:Cloudflare Worker API
|
||||||
|
- `public/`:Cloudflare Pages 前端静态文件
|
||||||
|
- `public/config.js`:前端 API 地址配置
|
||||||
|
- `wrangler.toml`:Workers API 配置
|
||||||
|
- `.dev.vars.example`:本地开发环境变量示例
|
||||||
|
|
||||||
|
## 需要的环境变量
|
||||||
|
|
||||||
|
Workers API 需要:
|
||||||
|
|
||||||
|
- `OPENAI_BASE_URL`:你的 OpenAI 兼容接口地址(不要带 `/v1/models`)
|
||||||
|
- `OPENAI_API_KEY`:上游 API Key
|
||||||
|
- `ACCESS_KEY`:给朋友使用的共享访问密钥
|
||||||
|
- `ALLOWED_ORIGIN`:允许访问 API 的前端站点域名(可选)
|
||||||
|
|
||||||
|
例如:
|
||||||
|
|
||||||
|
```env
|
||||||
|
OPENAI_BASE_URL="https://api.example.com"
|
||||||
|
OPENAI_API_KEY="sk-xxxx"
|
||||||
|
ACCESS_KEY="nekoai-2026"
|
||||||
|
ALLOWED_ORIGIN="https://nekoai.pages.dev"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 本地运行 API
|
||||||
|
|
||||||
|
先安装依赖:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
cp .dev.vars.example .dev.vars
|
||||||
|
```
|
||||||
|
|
||||||
|
然后启动 Worker API:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 部署方式
|
||||||
|
|
||||||
|
### 1. 部署 Workers API
|
||||||
|
|
||||||
|
先登录 Wrangler:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx wrangler login
|
||||||
|
```
|
||||||
|
|
||||||
|
设置线上 secrets:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx wrangler secret put OPENAI_BASE_URL
|
||||||
|
npx wrangler secret put OPENAI_API_KEY
|
||||||
|
npx wrangler secret put ACCESS_KEY
|
||||||
|
npx wrangler secret put ALLOWED_ORIGIN
|
||||||
|
```
|
||||||
|
|
||||||
|
部署:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
默认 Worker 名称:
|
||||||
|
- `nekoai-api`
|
||||||
|
|
||||||
|
默认地址类似:
|
||||||
|
- `https://nekoai-api.<your-subdomain>.workers.dev`
|
||||||
|
|
||||||
|
### 2. 部署 Pages 前端
|
||||||
|
|
||||||
|
把 `public/` 目录作为静态站点部署到 Cloudflare Pages。
|
||||||
|
|
||||||
|
在正式部署前,修改:
|
||||||
|
|
||||||
|
- `public/config.js`
|
||||||
|
|
||||||
|
把里面的:
|
||||||
|
|
||||||
|
```js
|
||||||
|
window.NEKOAI_CONFIG = {
|
||||||
|
API_BASE_URL: "https://nekoai-api.git.llc"
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
改成你自己的 Workers API 地址。
|
||||||
|
|
||||||
|
## 当前接口
|
||||||
|
|
||||||
|
### `GET /api/models`
|
||||||
|
- 需要 `Authorization: Bearer <ACCESS_KEY>`
|
||||||
|
- Worker 代理上游 `/v1/models`
|
||||||
|
- 不做 fallback
|
||||||
|
|
||||||
|
### `POST /api/chat`
|
||||||
|
- 需要 `Authorization: Bearer <ACCESS_KEY>`
|
||||||
|
- 代理上游 `/v1/chat/completions`
|
||||||
|
- 支持流式返回
|
||||||
|
|
||||||
|
## 当前实现说明
|
||||||
|
|
||||||
|
这是一个最简可跑版本,特点是:
|
||||||
|
|
||||||
|
- 不接数据库
|
||||||
|
- 不做用户系统
|
||||||
|
- 不做聊天记录云端同步
|
||||||
|
- 模型列表不写死
|
||||||
|
- 上游失败就直接报错
|
||||||
|
- 前端和 API 分离部署
|
||||||
|
|
||||||
|
## 后续可以继续加的东西
|
||||||
|
|
||||||
|
- Markdown 渲染
|
||||||
|
- 代码高亮
|
||||||
|
- 删除会话
|
||||||
|
- 导出会话
|
||||||
|
- IP 限流
|
||||||
|
- 模型过滤
|
||||||
|
- 自定义系统提示词
|
||||||
|
- 自定义域名
|
||||||
13
package.json
Normal file
13
package.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "nekoai",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "A minimal Cloudflare Workers AI chat frontend for OpenAI-compatible APIs.",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "wrangler dev",
|
||||||
|
"deploy": "wrangler deploy"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"wrangler": "^4.57.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
865
public/app.js
Normal file
865
public/app.js
Normal file
@@ -0,0 +1,865 @@
|
|||||||
|
const STORAGE_KEY = 'nekoai.conversations';
|
||||||
|
const ACCESS_KEY_STORAGE = 'nekoai.accessKey';
|
||||||
|
const MODEL_STORAGE = 'nekoai.selectedModel';
|
||||||
|
const API_BASE_URL = (window.NEKOAI_CONFIG?.API_BASE_URL || '').replace(/\/$/, '');
|
||||||
|
const MAX_ATTACHMENT_SIZE = 15 * 1024 * 1024;
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
conversations: loadConversations(),
|
||||||
|
activeId: null,
|
||||||
|
models: [],
|
||||||
|
selectedModel: localStorage.getItem(MODEL_STORAGE) || '',
|
||||||
|
sending: false,
|
||||||
|
pendingAttachments: [],
|
||||||
|
authenticated: false,
|
||||||
|
liveThinkTimer: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const loginScreenEl = document.getElementById('loginScreen');
|
||||||
|
const appShellEl = document.getElementById('appShell');
|
||||||
|
const loginFormEl = document.getElementById('loginForm');
|
||||||
|
const loginAccessKeyInputEl = document.getElementById('loginAccessKeyInput');
|
||||||
|
const loginErrorEl = document.getElementById('loginError');
|
||||||
|
const conversationListEl = document.getElementById('conversationList');
|
||||||
|
const messagesEl = document.getElementById('messages');
|
||||||
|
const chatFormEl = document.getElementById('chatForm');
|
||||||
|
const messageInputEl = document.getElementById('messageInput');
|
||||||
|
const newChatBtnEl = document.getElementById('newChatBtn');
|
||||||
|
const sendBtnEl = document.getElementById('sendBtn');
|
||||||
|
const clearAllBtnEl = document.getElementById('clearAllBtn');
|
||||||
|
const modelDropdownEl = document.getElementById('modelDropdown');
|
||||||
|
const modelDropdownButtonEl = document.getElementById('modelDropdownButton');
|
||||||
|
const modelDropdownMenuEl = document.getElementById('modelDropdownMenu');
|
||||||
|
const modelDropdownLabelEl = document.getElementById('modelDropdownLabel');
|
||||||
|
const mobileSidebarToggleEl = document.getElementById('mobileSidebarToggle');
|
||||||
|
const mobileSidebarBackdropEl = document.getElementById('mobileSidebarBackdrop');
|
||||||
|
const fileInputEl = document.getElementById('fileInput');
|
||||||
|
const attachBtnEl = document.getElementById('attachBtn');
|
||||||
|
const attachmentListEl = document.getElementById('attachmentList');
|
||||||
|
|
||||||
|
boot();
|
||||||
|
|
||||||
|
function boot() {
|
||||||
|
applyThemeByTime();
|
||||||
|
|
||||||
|
const savedKey = localStorage.getItem(ACCESS_KEY_STORAGE) || '';
|
||||||
|
loginAccessKeyInputEl.value = savedKey;
|
||||||
|
|
||||||
|
if (!state.conversations.length) {
|
||||||
|
const id = createConversation();
|
||||||
|
state.activeId = id;
|
||||||
|
} else {
|
||||||
|
state.activeId = state.conversations[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEvents();
|
||||||
|
autoResizeTextarea();
|
||||||
|
renderConversationList();
|
||||||
|
renderMessages();
|
||||||
|
renderAttachments();
|
||||||
|
renderModelDropdown();
|
||||||
|
updateSendingState();
|
||||||
|
|
||||||
|
if (savedKey) {
|
||||||
|
loginWithKey(savedKey, false);
|
||||||
|
} else {
|
||||||
|
showLogin();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindEvents() {
|
||||||
|
loginFormEl.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const key = loginAccessKeyInputEl.value.trim();
|
||||||
|
if (!key) {
|
||||||
|
setLoginError('请先输入访问密钥');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loginWithKey(key, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
newChatBtnEl.addEventListener('click', () => {
|
||||||
|
state.activeId = createConversation();
|
||||||
|
persistConversations();
|
||||||
|
renderConversationList();
|
||||||
|
renderMessages();
|
||||||
|
renderModelDropdown();
|
||||||
|
focusComposer();
|
||||||
|
});
|
||||||
|
|
||||||
|
clearAllBtnEl.addEventListener('click', () => {
|
||||||
|
if (!state.conversations.length) return;
|
||||||
|
const confirmed = window.confirm('确认清空全部会话吗?清空后当前浏览器中的聊天记录将无法恢复。');
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
state.conversations = [];
|
||||||
|
state.activeId = createConversation();
|
||||||
|
persistConversations();
|
||||||
|
renderConversationList();
|
||||||
|
renderMessages();
|
||||||
|
renderModelDropdown();
|
||||||
|
focusComposer();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelDropdownButtonEl.addEventListener('click', () => {
|
||||||
|
if (!state.models.length) return;
|
||||||
|
const expanded = modelDropdownEl.classList.toggle('open');
|
||||||
|
modelDropdownButtonEl.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', (event) => {
|
||||||
|
if (!modelDropdownEl.contains(event.target)) {
|
||||||
|
modelDropdownEl.classList.remove('open');
|
||||||
|
modelDropdownButtonEl.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
mobileSidebarToggleEl?.addEventListener('click', () => {
|
||||||
|
appShellEl.classList.add('mobile-sidebar-open');
|
||||||
|
mobileSidebarBackdropEl?.classList.remove('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
mobileSidebarBackdropEl?.addEventListener('click', () => {
|
||||||
|
closeMobileSidebar();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
fileInputEl.addEventListener('change', async () => {
|
||||||
|
const files = Array.from(fileInputEl.files || []);
|
||||||
|
await addPendingFiles(files);
|
||||||
|
fileInputEl.value = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
messageInputEl.addEventListener('input', () => {
|
||||||
|
autoResizeTextarea();
|
||||||
|
});
|
||||||
|
|
||||||
|
messageInputEl.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
chatFormEl.requestSubmit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
chatFormEl.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (state.sending) return;
|
||||||
|
|
||||||
|
const text = messageInputEl.value.trim();
|
||||||
|
if (!text && !state.pendingAttachments.length) return;
|
||||||
|
|
||||||
|
const accessKey = localStorage.getItem(ACCESS_KEY_STORAGE) || '';
|
||||||
|
if (!accessKey) {
|
||||||
|
showLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = state.selectedModel;
|
||||||
|
if (!model) {
|
||||||
|
alert('当前没有可用模型,请先刷新模型列表');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversation = getActiveConversation();
|
||||||
|
conversation.updatedAt = Date.now();
|
||||||
|
conversation.model = model;
|
||||||
|
|
||||||
|
const userMessage = {
|
||||||
|
role: 'user',
|
||||||
|
content: text,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
attachments: state.pendingAttachments.map((item) => ({
|
||||||
|
name: item.name,
|
||||||
|
type: item.type,
|
||||||
|
size: item.size,
|
||||||
|
kind: item.kind,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
conversation.messages.push(userMessage);
|
||||||
|
|
||||||
|
if (!conversation.title || conversation.title === '新会话') {
|
||||||
|
conversation.title = (text || state.pendingAttachments[0]?.name || '新会话').slice(0, 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
const assistantMessage = { role: 'assistant', content: '', createdAt: Date.now() };
|
||||||
|
conversation.messages.push(assistantMessage);
|
||||||
|
|
||||||
|
const pendingAttachments = [...state.pendingAttachments];
|
||||||
|
state.pendingAttachments = [];
|
||||||
|
renderAttachments();
|
||||||
|
messageInputEl.value = '';
|
||||||
|
autoResizeTextarea();
|
||||||
|
state.sending = true;
|
||||||
|
updateSendingState();
|
||||||
|
persistConversations();
|
||||||
|
renderConversationList();
|
||||||
|
renderMessages();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendChat(accessKey, model, conversation, assistantMessage, pendingAttachments);
|
||||||
|
} catch (error) {
|
||||||
|
assistantMessage.content = `请求失败:${error.message}`;
|
||||||
|
} finally {
|
||||||
|
state.sending = false;
|
||||||
|
updateSendingState();
|
||||||
|
conversation.updatedAt = Date.now();
|
||||||
|
persistConversations();
|
||||||
|
renderConversationList();
|
||||||
|
renderMessages();
|
||||||
|
focusComposer();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginWithKey(key, surfaceError = true) {
|
||||||
|
setLoginError('');
|
||||||
|
loginAccessKeyInputEl.value = key;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/models`, {
|
||||||
|
headers: { Authorization: `Bearer ${key}` },
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || '访问密钥无效');
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(ACCESS_KEY_STORAGE, key);
|
||||||
|
state.authenticated = true;
|
||||||
|
state.models = Array.isArray(data.models) ? data.models : [];
|
||||||
|
const activeConversation = getActiveConversation();
|
||||||
|
const preferred = activeConversation?.model || state.selectedModel;
|
||||||
|
state.selectedModel = state.models.find((item) => item.id === preferred)?.id || state.models[0]?.id || '';
|
||||||
|
localStorage.setItem(MODEL_STORAGE, state.selectedModel);
|
||||||
|
renderModelDropdown();
|
||||||
|
showApp();
|
||||||
|
focusComposer();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
state.authenticated = false;
|
||||||
|
state.models = [];
|
||||||
|
state.selectedModel = '';
|
||||||
|
localStorage.removeItem(MODEL_STORAGE);
|
||||||
|
localStorage.removeItem(ACCESS_KEY_STORAGE);
|
||||||
|
renderModelDropdown();
|
||||||
|
if (surfaceError) setLoginError(error.message || '登录失败');
|
||||||
|
showLogin();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLogin() {
|
||||||
|
loginScreenEl.classList.remove('hidden');
|
||||||
|
appShellEl.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showApp() {
|
||||||
|
loginScreenEl.classList.add('hidden');
|
||||||
|
appShellEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMobileSidebar() {
|
||||||
|
appShellEl.classList.remove('mobile-sidebar-open');
|
||||||
|
mobileSidebarBackdropEl?.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLoginError(text) {
|
||||||
|
loginErrorEl.textContent = text || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function renderModelDropdown() {
|
||||||
|
const activeConversation = getActiveConversation();
|
||||||
|
if (activeConversation?.model && state.models.find((item) => item.id === activeConversation.model)) {
|
||||||
|
state.selectedModel = activeConversation.model;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = state.models.find((item) => item.id === state.selectedModel);
|
||||||
|
modelDropdownLabelEl.textContent = current?.label || current?.id || '选择模型';
|
||||||
|
modelDropdownMenuEl.innerHTML = '';
|
||||||
|
modelDropdownButtonEl.disabled = !state.models.length;
|
||||||
|
modelDropdownButtonEl.setAttribute('aria-expanded', modelDropdownEl.classList.contains('open') ? 'true' : 'false');
|
||||||
|
|
||||||
|
if (!state.models.length) {
|
||||||
|
modelDropdownEl.classList.remove('open');
|
||||||
|
modelDropdownButtonEl.setAttribute('aria-expanded', 'false');
|
||||||
|
const empty = document.createElement('div');
|
||||||
|
empty.className = 'model-option empty';
|
||||||
|
empty.textContent = '暂无模型';
|
||||||
|
modelDropdownMenuEl.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.models.forEach((model) => {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.className = `model-option${model.id === state.selectedModel ? ' active' : ''}`;
|
||||||
|
button.textContent = model.label || model.id;
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
state.selectedModel = model.id;
|
||||||
|
localStorage.setItem(MODEL_STORAGE, model.id);
|
||||||
|
const conversation = getActiveConversation();
|
||||||
|
if (conversation) {
|
||||||
|
conversation.model = model.id;
|
||||||
|
conversation.updatedAt = Date.now();
|
||||||
|
persistConversations();
|
||||||
|
renderConversationList();
|
||||||
|
}
|
||||||
|
modelDropdownEl.classList.remove('open');
|
||||||
|
modelDropdownButtonEl.setAttribute('aria-expanded', 'false');
|
||||||
|
renderModelDropdown();
|
||||||
|
});
|
||||||
|
modelDropdownMenuEl.appendChild(button);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addPendingFiles(files) {
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.size > MAX_ATTACHMENT_SIZE) {
|
||||||
|
alert(`${file.name} 超过 15MB,先跳过了。`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataUrl = await readFileAsDataURL(file);
|
||||||
|
const base64 = dataUrl.split(',')[1] || '';
|
||||||
|
state.pendingAttachments.push({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name: file.name,
|
||||||
|
type: file.type || 'application/octet-stream',
|
||||||
|
size: file.size,
|
||||||
|
kind: getAttachmentKind(file),
|
||||||
|
dataUrl,
|
||||||
|
base64,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renderAttachments();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAttachments() {
|
||||||
|
attachmentListEl.innerHTML = '';
|
||||||
|
if (!state.pendingAttachments.length) return;
|
||||||
|
|
||||||
|
state.pendingAttachments.forEach((file) => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'attachment-chip';
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="attachment-chip-main">
|
||||||
|
<div class="attachment-name">${escapeHtml(file.name)}</div>
|
||||||
|
<div class="attachment-meta">${formatBytes(file.size)} · ${escapeHtml(file.kind)}</div>
|
||||||
|
</div>
|
||||||
|
<button class="attachment-remove" type="button">×</button>
|
||||||
|
`;
|
||||||
|
item.querySelector('.attachment-remove').addEventListener('click', () => {
|
||||||
|
state.pendingAttachments = state.pendingAttachments.filter((entry) => entry.id !== file.id);
|
||||||
|
renderAttachments();
|
||||||
|
});
|
||||||
|
attachmentListEl.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendChat(accessKey, model, conversation, assistantMessage, attachments) {
|
||||||
|
const payload = buildChatPayload(model, conversation, assistantMessage, attachments);
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${accessKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type') || '';
|
||||||
|
if (!contentType.includes('text/event-stream')) {
|
||||||
|
const data = await response.json();
|
||||||
|
assistantMessage.content = extractAssistantText(data) || '上游返回为空';
|
||||||
|
updateThinkTiming(assistantMessage, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const parts = buffer.split('\n\n');
|
||||||
|
buffer = parts.pop() || '';
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
const lines = part.split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.startsWith('data:')) continue;
|
||||||
|
const data = line.slice(5).trim();
|
||||||
|
if (!data || data === '[DONE]') continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(data);
|
||||||
|
const delta = json.choices?.[0]?.delta?.content;
|
||||||
|
if (typeof delta === 'string' && delta) {
|
||||||
|
assistantMessage.content += delta;
|
||||||
|
updateThinkTiming(assistantMessage);
|
||||||
|
renderMessages();
|
||||||
|
} else if (Array.isArray(delta)) {
|
||||||
|
const text = delta.map((item) => item?.text || '').join('');
|
||||||
|
if (text) {
|
||||||
|
assistantMessage.content += text;
|
||||||
|
updateThinkTiming(assistantMessage);
|
||||||
|
renderMessages();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore invalid chunk
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildChatPayload(model, conversation, assistantMessage, attachments) {
|
||||||
|
const messages = conversation.messages
|
||||||
|
.filter((item) => item !== assistantMessage)
|
||||||
|
.map((item, index, arr) => {
|
||||||
|
if (item.role !== 'user') {
|
||||||
|
return { role: item.role, content: item.content };
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLastUserMessage = index === arr.length - 1;
|
||||||
|
const attachedFiles = isLastUserMessage ? attachments : [];
|
||||||
|
return buildUserMessage(item, attachedFiles);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
model,
|
||||||
|
stream: true,
|
||||||
|
messages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUserMessage(message, attachments) {
|
||||||
|
if (!attachments.length) {
|
||||||
|
return { role: 'user', content: message.content || '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachmentText = attachments
|
||||||
|
.map((file) => `- ${file.name} (${file.type || 'application/octet-stream'}, ${formatBytes(file.size)})`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const promptText = message.content || '请读取并处理这些附件。';
|
||||||
|
const content = [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: `${promptText}\n\n附件列表:\n${attachmentText}`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
attachments.forEach((file) => {
|
||||||
|
content.push({
|
||||||
|
type: 'image_url',
|
||||||
|
image_url: {
|
||||||
|
url: file.dataUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
role: 'user',
|
||||||
|
content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractAssistantText(data) {
|
||||||
|
const content = data?.choices?.[0]?.message?.content;
|
||||||
|
if (typeof content === 'string') return content;
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
return content
|
||||||
|
.map((item) => item?.text || item?.content || '')
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function createConversation() {
|
||||||
|
const conversation = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
title: '新会话',
|
||||||
|
model: localStorage.getItem(MODEL_STORAGE) || '',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
messages: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
state.conversations.unshift(conversation);
|
||||||
|
return conversation.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeConversation(rawConversation) {
|
||||||
|
if (!rawConversation || typeof rawConversation !== 'object') return null;
|
||||||
|
|
||||||
|
const messages = Array.isArray(rawConversation.messages)
|
||||||
|
? rawConversation.messages
|
||||||
|
.filter((message) => message && typeof message === 'object')
|
||||||
|
.map((message) => ({
|
||||||
|
role: message.role === 'assistant' ? 'assistant' : 'user',
|
||||||
|
content: typeof message.content === 'string' ? message.content : '',
|
||||||
|
createdAt: Number.isFinite(message.createdAt) ? message.createdAt : Date.now(),
|
||||||
|
thinkMs: Number.isFinite(message.thinkMs) ? message.thinkMs : undefined,
|
||||||
|
attachments: Array.isArray(message.attachments)
|
||||||
|
? message.attachments
|
||||||
|
.filter((file) => file && typeof file === 'object')
|
||||||
|
.map((file) => ({
|
||||||
|
name: typeof file.name === 'string' ? file.name : '未命名附件',
|
||||||
|
type: typeof file.type === 'string' ? file.type : 'application/octet-stream',
|
||||||
|
size: Number.isFinite(file.size) ? file.size : 0,
|
||||||
|
kind: typeof file.kind === 'string' ? file.kind : 'file',
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: typeof rawConversation.id === 'string' && rawConversation.id ? rawConversation.id : crypto.randomUUID(),
|
||||||
|
title: typeof rawConversation.title === 'string' && rawConversation.title.trim() ? rawConversation.title.trim() : '新会话',
|
||||||
|
model: typeof rawConversation.model === 'string' ? rawConversation.model : '',
|
||||||
|
createdAt: Number.isFinite(rawConversation.createdAt) ? rawConversation.createdAt : Date.now(),
|
||||||
|
updatedAt: Number.isFinite(rawConversation.updatedAt) ? rawConversation.updatedAt : Date.now(),
|
||||||
|
messages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteConversation(id) {
|
||||||
|
const target = state.conversations.find((item) => item.id === id);
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
const confirmed = window.confirm(`确认删除会话「${target.title || '新会话'}」吗?删除后无法恢复。`);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
state.conversations = state.conversations.filter((item) => item.id !== id);
|
||||||
|
|
||||||
|
if (!state.conversations.length) {
|
||||||
|
state.activeId = createConversation();
|
||||||
|
} else if (state.activeId === id) {
|
||||||
|
state.activeId = state.conversations[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
persistConversations();
|
||||||
|
renderConversationList();
|
||||||
|
renderMessages();
|
||||||
|
renderModelDropdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveConversation() {
|
||||||
|
return state.conversations.find((item) => item.id === state.activeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderConversationList() {
|
||||||
|
conversationListEl.innerHTML = '';
|
||||||
|
|
||||||
|
state.conversations
|
||||||
|
.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||||
|
.forEach((conversation) => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = `conversation-item${conversation.id === state.activeId ? ' active' : ''}`;
|
||||||
|
item.innerHTML = `
|
||||||
|
<button class="conversation-main" type="button">
|
||||||
|
<div class="conversation-title">${escapeHtml(conversation.title || '新会话')}</div>
|
||||||
|
<small class="conversation-time">${new Date(conversation.updatedAt).toLocaleString()}</small>
|
||||||
|
</button>
|
||||||
|
<button class="conversation-delete" type="button" aria-label="删除会话">×</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
item.querySelector('.conversation-main').addEventListener('click', () => {
|
||||||
|
state.activeId = conversation.id;
|
||||||
|
renderConversationList();
|
||||||
|
renderMessages();
|
||||||
|
renderModelDropdown();
|
||||||
|
closeMobileSidebar();
|
||||||
|
});
|
||||||
|
|
||||||
|
item.querySelector('.conversation-delete').addEventListener('click', (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
deleteConversation(conversation.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
conversationListEl.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 思考计时 ──────────────────────────────────────────────────────────────
|
||||||
|
// assistantMessage.thinkStart : <think> 出现时记录的 Date.now()
|
||||||
|
// assistantMessage.thinkMs : </think> 出现时写入的耗时毫秒,写入后删除 thinkStart
|
||||||
|
|
||||||
|
function updateThinkTiming(message, forceClose = false) {
|
||||||
|
const text = String(message.content || '');
|
||||||
|
const hasOpen = /<think>/i.test(text);
|
||||||
|
const hasClose = /<\/think>/i.test(text);
|
||||||
|
|
||||||
|
if (hasOpen && !message.thinkStart && !('thinkMs' in message)) {
|
||||||
|
message.thinkStart = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((hasClose || forceClose) && message.thinkStart && !('thinkMs' in message)) {
|
||||||
|
message.thinkMs = Date.now() - message.thinkStart;
|
||||||
|
delete message.thinkStart;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMessages() {
|
||||||
|
const conversation = getActiveConversation();
|
||||||
|
|
||||||
|
if (!conversation || !conversation.messages.length) {
|
||||||
|
stopLiveThinkTicker();
|
||||||
|
messagesEl.innerHTML = '';
|
||||||
|
const empty = document.createElement('div');
|
||||||
|
empty.className = 'empty-state';
|
||||||
|
empty.innerHTML = `
|
||||||
|
<div class="empty-state-box">
|
||||||
|
<h2>今天想聊点什么?</h2>
|
||||||
|
<p>输入访问密钥,选择模型,然后直接开始对话。支持上传图片、文档、压缩包等附件。</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
messagesEl.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有正在思考的消息,只更新计时器文字,不重绘整个列表
|
||||||
|
const thinkingMsg = conversation.messages.find((m) => m.thinkStart);
|
||||||
|
if (thinkingMsg && messagesEl.children.length > 0) {
|
||||||
|
const elapsed = Date.now() - thinkingMsg.thinkStart;
|
||||||
|
const timerEl = messagesEl.querySelector('.message-think.is-live .think-timer');
|
||||||
|
if (timerEl) {
|
||||||
|
timerEl.textContent = formatThinkDuration(elapsed);
|
||||||
|
startLiveThinkTicker();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全量重绘
|
||||||
|
const scrollNearBottom = Math.abs(messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight) < 80;
|
||||||
|
messagesEl.innerHTML = '';
|
||||||
|
|
||||||
|
conversation.messages.forEach((message) => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
const isAssistant = message.role === 'assistant';
|
||||||
|
row.className = `message-row ${isAssistant ? 'assistant-row' : 'user-row'}`;
|
||||||
|
row.innerHTML = isAssistant
|
||||||
|
? `<div class="avatar assistant">N</div><div class="message-bubble"><div class="message-role">NekoAI</div><div class="message-content"></div><div class="message-attachments"></div></div>`
|
||||||
|
: `<div class="message-bubble"><div class="message-role">用户</div><div class="message-content"></div><div class="message-attachments"></div></div>`;
|
||||||
|
|
||||||
|
renderMessageContent(row.querySelector('.message-content'), message);
|
||||||
|
|
||||||
|
const attachmentWrap = row.querySelector('.message-attachments');
|
||||||
|
if (message.attachments?.length) {
|
||||||
|
message.attachments.forEach((file) => {
|
||||||
|
const chip = document.createElement('div');
|
||||||
|
chip.className = 'message-attachment-chip';
|
||||||
|
chip.textContent = `${file.name} · ${formatBytes(file.size)}`;
|
||||||
|
attachmentWrap.appendChild(chip);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
messagesEl.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (thinkingMsg) startLiveThinkTicker();
|
||||||
|
else stopLiveThinkTicker();
|
||||||
|
|
||||||
|
if (scrollNearBottom) messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMessageContent(container, message) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
const raw = String(message.content || '');
|
||||||
|
const isThinking = Boolean(message.thinkStart);
|
||||||
|
|
||||||
|
// 剥离思考内容,主文本永远不含 <think> 块
|
||||||
|
const mainText = raw
|
||||||
|
.replace(/<think>[\s\S]*?<\/think>/gi, '')
|
||||||
|
.replace(/<think>[\s\S]*$/gi, '')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// 思考框:只对 assistant 消息,且确实含有 <think> 标记才渲染
|
||||||
|
const hasThink = message.role === 'assistant' && (
|
||||||
|
isThinking || Number.isFinite(message.thinkMs) || /<think>/i.test(raw)
|
||||||
|
);
|
||||||
|
if (hasThink) {
|
||||||
|
// 提取已收到的思考内容(无论是否完成)
|
||||||
|
let thinkContent = '';
|
||||||
|
if (isThinking) {
|
||||||
|
// 还在思考中:提取 <think> 之后的所有内容(不需要闭合标签)
|
||||||
|
const m = raw.match(/<think>([\s\S]*)$/i);
|
||||||
|
thinkContent = m ? m[1].trim() : '';
|
||||||
|
} else {
|
||||||
|
// 思考完成:提取 <think>...</think> 中间的内容
|
||||||
|
thinkContent = [...raw.matchAll(/<think>([\s\S]*?)<\/think>/gi)]
|
||||||
|
.map((m) => m[1].trim()).join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ms = isThinking ? (Date.now() - message.thinkStart) : (message.thinkMs || 0);
|
||||||
|
const isOpen = Boolean(message._thinkOpen);
|
||||||
|
|
||||||
|
const details = document.createElement('details');
|
||||||
|
details.className = `message-think${isThinking ? ' is-live' : ''}`;
|
||||||
|
if (isOpen) details.open = true;
|
||||||
|
details.innerHTML = `
|
||||||
|
<summary>
|
||||||
|
<span>思考过程</span>
|
||||||
|
<span class="think-timer">${formatThinkDuration(ms)}</span>
|
||||||
|
</summary>
|
||||||
|
${thinkContent ? `<div class="message-think-body">${renderMarkdown(thinkContent)}</div>` : ''}
|
||||||
|
`;
|
||||||
|
details.addEventListener('toggle', () => { message._thinkOpen = details.open; });
|
||||||
|
container.appendChild(details);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主文本
|
||||||
|
const images = extractImageDataUrls(mainText);
|
||||||
|
const textOnly = removeImageDataUrls(mainText).trim();
|
||||||
|
if (textOnly) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.innerHTML = renderMarkdown(textOnly);
|
||||||
|
container.appendChild(div);
|
||||||
|
}
|
||||||
|
if (images.length) {
|
||||||
|
const gallery = document.createElement('div');
|
||||||
|
gallery.className = 'message-image-gallery';
|
||||||
|
images.forEach((src) => {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = src;
|
||||||
|
img.className = 'message-inline-image';
|
||||||
|
gallery.appendChild(img);
|
||||||
|
});
|
||||||
|
container.appendChild(gallery);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMarkdown(input) {
|
||||||
|
return marked.parse(String(input || ''), {
|
||||||
|
renderer: (() => {
|
||||||
|
const r = new marked.Renderer();
|
||||||
|
r.code = ({ text, lang }) => {
|
||||||
|
const escaped = text.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
return `<pre class="md-pre"><code>${escaped}</code></pre>`;
|
||||||
|
};
|
||||||
|
return r;
|
||||||
|
})(),
|
||||||
|
breaks: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractImageDataUrls(text) {
|
||||||
|
const matches = String(text || '').match(/data:image\/[a-zA-Z0-9.+-]+;base64,[A-Za-z0-9+/=\n\r]+/g);
|
||||||
|
return matches ? matches.map((item) => item.replace(/[\n\r]/g, '')) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatThinkDuration(ms) {
|
||||||
|
if (!ms) return '0.0 秒';
|
||||||
|
if (ms < 60000) return `${(ms / 1000).toFixed(1)} 秒`;
|
||||||
|
const minutes = Math.floor(ms / 60000);
|
||||||
|
const seconds = ((ms % 60000) / 1000).toFixed(1);
|
||||||
|
return `${minutes} 分 ${seconds} 秒`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeImageDataUrls(text) {
|
||||||
|
return String(text || '').replace(/data:image\/[a-zA-Z0-9.+-]+;base64,[A-Za-z0-9+/=\n\r]+/g, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startLiveThinkTicker() {
|
||||||
|
if (state.liveThinkTimer) return;
|
||||||
|
state.liveThinkTimer = setInterval(() => {
|
||||||
|
renderMessages();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopLiveThinkTicker() {
|
||||||
|
if (!state.liveThinkTimer) return;
|
||||||
|
clearInterval(state.liveThinkTimer);
|
||||||
|
state.liveThinkTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSendingState() {
|
||||||
|
sendBtnEl.disabled = state.sending;
|
||||||
|
sendBtnEl.textContent = state.sending ? '发送中...' : '发送';
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoResizeTextarea() {
|
||||||
|
messageInputEl.style.height = '32px';
|
||||||
|
const maxHeight = 220;
|
||||||
|
const nextHeight = Math.min(Math.max(messageInputEl.scrollHeight, 32), maxHeight);
|
||||||
|
messageInputEl.style.height = `${nextHeight}px`;
|
||||||
|
messageInputEl.style.overflowY = messageInputEl.scrollHeight > maxHeight ? 'auto' : 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusComposer() {
|
||||||
|
messageInputEl.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyThemeByTime() {
|
||||||
|
const hour = new Date().getHours();
|
||||||
|
const isLight = hour >= 7 && hour < 19;
|
||||||
|
document.body.classList.toggle('theme-light', isLight);
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistConversations() {
|
||||||
|
const safeConversations = state.conversations.map((conversation) => sanitizeConversation(conversation)).filter(Boolean);
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(safeConversations));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadConversations() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
return parsed.map((conversation) => sanitizeConversation(conversation)).filter(Boolean);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAttachmentKind(file) {
|
||||||
|
if (file.type?.startsWith('image/')) return 'image';
|
||||||
|
return 'file';
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFileAsDataURL(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(String(reader.result || ''));
|
||||||
|
reader.onerror = () => reject(new Error(`读取文件失败:${file.name}`));
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (!bytes) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
let value = bytes;
|
||||||
|
let idx = 0;
|
||||||
|
while (value >= 1024 && idx < units.length - 1) {
|
||||||
|
value /= 1024;
|
||||||
|
idx += 1;
|
||||||
|
}
|
||||||
|
return `${value >= 10 || idx === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[idx]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
return String(text)
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
3
public/config.example.js
Normal file
3
public/config.example.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
window.NEKOAI_CONFIG = {
|
||||||
|
API_BASE_URL: "https://your-worker-name.your-subdomain.workers.dev"
|
||||||
|
};
|
||||||
91
public/index.html
Normal file
91
public/index.html
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>NekoAI</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="loginScreen" class="login-screen">
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-logo">N</div>
|
||||||
|
<h1>NekoAI</h1>
|
||||||
|
<p>先输入访问密钥,再进入聊天。</p>
|
||||||
|
<form id="loginForm" class="login-form">
|
||||||
|
<input id="loginAccessKeyInput" type="password" placeholder="输入访问密钥" autocomplete="off" />
|
||||||
|
<button type="submit" class="send-btn login-btn">进入聊天</button>
|
||||||
|
</form>
|
||||||
|
<div id="loginError" class="login-error"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="appShell" class="app-shell hidden">
|
||||||
|
<div id="mobileSidebarBackdrop" class="mobile-sidebar-backdrop hidden"></div>
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-top">
|
||||||
|
<div class="brand-wrap">
|
||||||
|
<div class="brand-logo">N</div>
|
||||||
|
<div>
|
||||||
|
<div class="brand">NekoAI</div>
|
||||||
|
<div class="brand-subtitle">Simple AI chat</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button id="newChatBtn" class="new-chat-btn">+ 新建聊天</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-section-header">
|
||||||
|
<div class="sidebar-section-label">最近会话</div>
|
||||||
|
<button id="clearAllBtn" class="sidebar-text-btn" type="button">清空</button>
|
||||||
|
</div>
|
||||||
|
<div id="conversationList" class="conversation-list"></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="topbar-mobile-row">
|
||||||
|
<button id="mobileSidebarToggle" class="mobile-sidebar-toggle" type="button" aria-label="打开侧栏">☰</button>
|
||||||
|
<div class="topbar-title compact-title">
|
||||||
|
<h1>NekoAI</h1>
|
||||||
|
</div>
|
||||||
|
<div class="control-panel compact-control-panel">
|
||||||
|
<div class="model-group model-group-plain">
|
||||||
|
<div id="modelDropdown" class="model-dropdown">
|
||||||
|
<button id="modelDropdownButton" type="button" class="model-dropdown-button" aria-expanded="false">
|
||||||
|
<span id="modelDropdownLabel">选择模型</span>
|
||||||
|
<span class="model-dropdown-caret"></span>
|
||||||
|
</button>
|
||||||
|
<div id="modelDropdownMenu" class="model-dropdown-menu"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section id="messages" class="messages"></section>
|
||||||
|
|
||||||
|
<footer class="composer-shell">
|
||||||
|
<form id="chatForm" class="composer-card">
|
||||||
|
<div id="attachmentList" class="attachment-list"></div>
|
||||||
|
<textarea id="messageInput" placeholder="给 NekoAI 发消息" rows="1"></textarea>
|
||||||
|
<div class="composer-bottom compact-composer-bottom">
|
||||||
|
<label for="fileInput" id="attachBtn" class="attach-btn" aria-label="添加文件">+</label>
|
||||||
|
<div class="composer-hint">支持图片、文档、压缩包等</div>
|
||||||
|
<div class="composer-actions single-line-actions">
|
||||||
|
<button type="submit" id="sendBtn" class="send-btn compact-send-btn">发送</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input id="fileInput" type="file" multiple hidden />
|
||||||
|
</form>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||||
|
<script src="/config.js"></script>
|
||||||
|
<script src="/app.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
702
public/styles.css
Normal file
702
public/styles.css
Normal file
@@ -0,0 +1,702 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #212121;
|
||||||
|
--sidebar: #171717;
|
||||||
|
--panel: #2f2f2f;
|
||||||
|
--panel-soft: #262626;
|
||||||
|
--panel-hover: #343434;
|
||||||
|
--border: rgba(255, 255, 255, 0.08);
|
||||||
|
--text: #ececec;
|
||||||
|
--muted: #a3a3a3;
|
||||||
|
--shadow: 0 12px 30px rgba(0, 0, 0, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.theme-light {
|
||||||
|
color-scheme: light;
|
||||||
|
--bg: #f7f7f8;
|
||||||
|
--sidebar: #efeff1;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--panel-soft: #f4f4f5;
|
||||||
|
--panel-hover: #ececef;
|
||||||
|
--border: rgba(15, 23, 42, 0.08);
|
||||||
|
--text: #1f2937;
|
||||||
|
--muted: #6b7280;
|
||||||
|
--shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
button, input, textarea, label { font: inherit; }
|
||||||
|
body { overflow: hidden; transition: background .2s ease, color .2s ease; }
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
|
||||||
|
.login-screen {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
background: radial-gradient(circle at top, rgba(255,255,255,.05), transparent 35%), var(--bg);
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
width: min(460px, 100%);
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 28px;
|
||||||
|
padding: 28px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.login-logo {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 18px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
background: #fff;
|
||||||
|
color: #111;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
body.theme-light .login-logo,
|
||||||
|
body.theme-light .send-btn {
|
||||||
|
background: #111;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.login-card h1 { margin: 0 0 8px; font-size: 28px; }
|
||||||
|
.login-card p { margin: 0 0 18px; color: var(--muted); }
|
||||||
|
.login-form { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.login-form input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--panel-soft);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.login-btn { width: 100%; justify-content: center; }
|
||||||
|
.login-error { min-height: 20px; color: #ff8a8a; margin-top: 10px; font-size: 13px; }
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 260px minmax(0, 1fr);
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
background: var(--sidebar);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
padding: 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
.sidebar-top { display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
.brand-wrap { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.brand-logo {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: #fff;
|
||||||
|
color: #111;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.brand { font-size: 16px; font-weight: 700; line-height: 1.2; }
|
||||||
|
.brand-subtitle { font-size: 12px; color: var(--muted); }
|
||||||
|
|
||||||
|
.new-chat-btn,
|
||||||
|
.ghost-btn,
|
||||||
|
.send-btn,
|
||||||
|
.conversation-main,
|
||||||
|
.conversation-delete,
|
||||||
|
.attach-btn,
|
||||||
|
.model-dropdown-button,
|
||||||
|
.mobile-sidebar-toggle {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--panel-soft);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 14px;
|
||||||
|
transition: .18s ease;
|
||||||
|
}
|
||||||
|
.new-chat-btn,
|
||||||
|
.ghost-btn,
|
||||||
|
.send-btn,
|
||||||
|
.conversation-main,
|
||||||
|
.conversation-delete,
|
||||||
|
.sidebar-text-btn,
|
||||||
|
.attach-btn,
|
||||||
|
.model-dropdown-button,
|
||||||
|
.mobile-sidebar-toggle {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.new-chat-btn { width: 100%; padding: 12px 14px; text-align: left; font-weight: 600; }
|
||||||
|
.new-chat-btn:hover,
|
||||||
|
.ghost-btn:hover,
|
||||||
|
.send-btn:hover,
|
||||||
|
.conversation-main:hover,
|
||||||
|
.conversation-delete:hover,
|
||||||
|
.attach-btn:hover,
|
||||||
|
.model-dropdown-button:hover,
|
||||||
|
.mobile-sidebar-toggle:hover { background: var(--panel-hover); }
|
||||||
|
|
||||||
|
.sidebar-section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 4px 0;
|
||||||
|
}
|
||||||
|
.sidebar-section-label { color: var(--muted); font-size: 12px; }
|
||||||
|
.sidebar-text-btn {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.sidebar-text-btn:hover { background: var(--panel-hover); color: var(--text); }
|
||||||
|
|
||||||
|
.conversation-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
overflow: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
.conversation-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.conversation-main {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.conversation-item.active .conversation-main {
|
||||||
|
background: var(--panel-hover);
|
||||||
|
border-color: rgba(255,255,255,.12);
|
||||||
|
}
|
||||||
|
.conversation-delete {
|
||||||
|
width: 0;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0;
|
||||||
|
border-color: transparent;
|
||||||
|
font-size: 18px;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(8px) scale(.92);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.conversation-item:hover .conversation-delete,
|
||||||
|
.conversation-item:focus-within .conversation-delete {
|
||||||
|
width: 34px;
|
||||||
|
min-width: 34px;
|
||||||
|
border-color: var(--border);
|
||||||
|
opacity: .78;
|
||||||
|
transform: translateX(0) scale(1);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.conversation-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.conversation-time { display: block; margin-top: 6px; color: var(--muted); font-size: 12px; }
|
||||||
|
|
||||||
|
.main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0,1fr) auto;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 20px 10px;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
.topbar-mobile-row { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||||
|
.compact-title { flex: 0 0 auto; min-width: 0; }
|
||||||
|
.topbar-title h1,
|
||||||
|
.compact-title h1 { margin: 0; font-size: 24px; font-weight: 700; line-height: 1.1; }
|
||||||
|
.topbar-title p { display: none; }
|
||||||
|
.mobile-sidebar-toggle {
|
||||||
|
display: none;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
place-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.mobile-sidebar-backdrop { display: none; }
|
||||||
|
|
||||||
|
.control-panel { display: flex; gap: 10px; justify-content: flex-end; }
|
||||||
|
.compact-control-panel { flex: 1 1 auto; min-width: 0; }
|
||||||
|
.control-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
.model-group { min-width: 160px; }
|
||||||
|
.model-group-plain {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.model-dropdown { position: relative; min-width: 0; width: 100%; }
|
||||||
|
.model-dropdown-button {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0 34px 0 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
text-align: left;
|
||||||
|
background: linear-gradient(180deg, rgba(255,255,255,.03), rgba(255,255,255,.015)), var(--panel);
|
||||||
|
position: relative;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
#modelDropdownLabel {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.model-dropdown-caret {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
top: 50%;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-right: 1.5px solid var(--muted);
|
||||||
|
border-bottom: 1.5px solid var(--muted);
|
||||||
|
transform: translateY(-65%) rotate(45deg);
|
||||||
|
transition: transform .18s ease;
|
||||||
|
}
|
||||||
|
.model-dropdown.open .model-dropdown-caret { transform: translateY(-35%) rotate(225deg); }
|
||||||
|
.model-dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 8px;
|
||||||
|
max-height: 280px;
|
||||||
|
overflow: auto;
|
||||||
|
z-index: 40;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.model-dropdown.open .model-dropdown-menu { display: block; }
|
||||||
|
.model-option {
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
text-align: left;
|
||||||
|
padding: 11px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.model-option:hover,
|
||||||
|
.model-option.active { background: var(--panel-hover); }
|
||||||
|
.model-option.empty { color: var(--muted); cursor: default; }
|
||||||
|
|
||||||
|
.messages {
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 8px 0 18px;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
.empty-state,
|
||||||
|
.message-row,
|
||||||
|
.composer-card { width: min(860px, calc(100% - 24px)); margin: 0 auto; }
|
||||||
|
.empty-state {
|
||||||
|
min-height: calc(100vh - 240px);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 32px 0;
|
||||||
|
}
|
||||||
|
.empty-state-box { max-width: 520px; }
|
||||||
|
.empty-state h2 { margin: 0 0 10px; font-size: 32px; color: var(--text); }
|
||||||
|
.empty-state p { margin: 0; line-height: 1.7; }
|
||||||
|
|
||||||
|
.message-row { display: flex; gap: 14px; padding: 12px 0; }
|
||||||
|
.user-row { justify-content: flex-end; }
|
||||||
|
.user-row .message-bubble { max-width: min(78%, 680px); }
|
||||||
|
.assistant-row .message-bubble { max-width: min(78%, 680px); }
|
||||||
|
.avatar {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
flex: 0 0 30px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.avatar.assistant { background: #fff; color: #111; }
|
||||||
|
.avatar.user { background: #3a3a3a; color: #fff; }
|
||||||
|
body.theme-light .avatar.assistant { background: #111; color: #fff; }
|
||||||
|
body.theme-light .avatar.user { background: #dfe3ea; color: #111; }
|
||||||
|
.message-bubble { flex: 0 1 auto; min-width: 0; overflow-wrap: break-word; word-break: break-word; }
|
||||||
|
.user-row .message-bubble .message-content {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 18px 18px 4px 18px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
display: inline-block;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.message-role { font-size: 13px; font-weight: 600; margin-bottom: 6px; }
|
||||||
|
.user-row .message-role { text-align: right; }
|
||||||
|
.assistant-row { background: none; }
|
||||||
|
body.theme-light .assistant-row { background: none; }
|
||||||
|
.message-content p { margin: 0 0 12px; }
|
||||||
|
.message-content p:last-child { margin-bottom: 0; }
|
||||||
|
.message-think {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--panel-soft);
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.message-think summary {
|
||||||
|
list-style: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 12px 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.message-think summary::-webkit-details-marker { display: none; }
|
||||||
|
.message-think summary::after { content: "+"; font-size: 18px; line-height: 1; color: var(--muted); }
|
||||||
|
.message-think[open] summary::after { content: "−"; }
|
||||||
|
.message-think.is-live summary { color: var(--text); }
|
||||||
|
.message-think.is-live summary span:last-child { color: #7fb3ff; font-variant-numeric: tabular-nums; }
|
||||||
|
.message-think-body { padding: 0 14px 14px; color: var(--text); border-top: 1px solid var(--border); }
|
||||||
|
.message-think-body p:first-child { margin-top: 12px; }
|
||||||
|
.message-content h1, .message-content h2, .message-content h3 { margin: 0 0 12px; line-height: 1.35; }
|
||||||
|
.message-content ul, .message-content ol { margin: 0 0 12px 20px; padding: 0; }
|
||||||
|
.message-content li + li { margin-top: 4px; }
|
||||||
|
.message-content a { color: #7fb3ff; text-decoration: none; }
|
||||||
|
.message-content a:hover { text-decoration: underline; }
|
||||||
|
.md-inline-code {
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: .92em;
|
||||||
|
}
|
||||||
|
.md-pre {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: auto;
|
||||||
|
background: #161616;
|
||||||
|
border: 1px solid rgba(255,255,255,.08);
|
||||||
|
}
|
||||||
|
body.theme-light .md-pre { background: #f3f4f6; border-color: rgba(15,23,42,.08); }
|
||||||
|
.md-pre code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; word-break: break-all; }
|
||||||
|
.message-image-gallery { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; }
|
||||||
|
.message-inline-image { max-width: min(100%, 420px); border-radius: 18px; display: block; border: 1px solid var(--border); }
|
||||||
|
.message-attachments { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
|
||||||
|
.message-attachment-chip {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop composer */
|
||||||
|
.composer-shell { padding: 0 16px 14px; background: var(--bg); }
|
||||||
|
.composer-card {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 24px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 14px 16px 12px;
|
||||||
|
}
|
||||||
|
.attachment-list { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; width: 100%; min-width: 0; max-width: 100%; }
|
||||||
|
.attachment-list:empty { display: none; }
|
||||||
|
.attachment-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 8px 8px 8px 12px;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.attachment-chip-main { min-width: 0; flex: 1 1 auto; overflow: hidden; }
|
||||||
|
.attachment-name { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
|
||||||
|
.attachment-meta { font-size: 12px; color: var(--muted); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.attachment-remove { border: none; background: transparent; color: var(--muted); font-size: 18px; cursor: pointer; flex: 0 0 auto; }
|
||||||
|
#messageInput {
|
||||||
|
width: 100%;
|
||||||
|
resize: none;
|
||||||
|
min-height: 28px;
|
||||||
|
max-height: 220px;
|
||||||
|
line-height: 1.65;
|
||||||
|
padding: 2px 0;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
overflow-y: hidden;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
#messageInput::placeholder, input::placeholder { color: #8f8f8f; }
|
||||||
|
.composer-bottom {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.attach-btn {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 14px;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
text-decoration: none;
|
||||||
|
user-select: none;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.composer-hint {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.single-line-actions { display: flex; justify-content: flex-end; flex: 0 0 auto; }
|
||||||
|
.send-btn {
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: #fff;
|
||||||
|
color: #111;
|
||||||
|
border: none;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 14px;
|
||||||
|
min-width: 72px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.send-btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
html, body { height: 100%; overflow: hidden; }
|
||||||
|
body { overflow: hidden; }
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
position: relative;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
height: 100dvh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: min(82vw, 320px);
|
||||||
|
max-width: 320px;
|
||||||
|
height: 100dvh;
|
||||||
|
z-index: 30;
|
||||||
|
transform: translate3d(-105%, 0, 0);
|
||||||
|
transition: transform .22s ease;
|
||||||
|
box-shadow: 0 20px 50px rgba(0,0,0,.28);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.app-shell.mobile-sidebar-open .sidebar { transform: translate3d(0, 0, 0); }
|
||||||
|
.mobile-sidebar-backdrop {
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,.35);
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 34px;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.conversation-delete {
|
||||||
|
width: 34px;
|
||||||
|
min-width: 34px;
|
||||||
|
opacity: .78;
|
||||||
|
transform: none;
|
||||||
|
pointer-events: auto;
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
height: 100dvh;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
.topbar {
|
||||||
|
gap: 8px;
|
||||||
|
padding: 2px 12px 6px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.topbar-mobile-row { gap: 8px; }
|
||||||
|
.mobile-sidebar-toggle {
|
||||||
|
display: inline-grid;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
place-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.compact-title h1,
|
||||||
|
.topbar-title h1 { font-size: 20px; }
|
||||||
|
.compact-control-panel { flex: 1 1 auto; width: auto; min-width: 0; justify-content: flex-end; }
|
||||||
|
.control-group { width: auto; padding: 4px; border-radius: 14px; }
|
||||||
|
.model-group { min-width: 138px; }
|
||||||
|
.model-group-plain {
|
||||||
|
width: auto;
|
||||||
|
min-width: 138px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.model-dropdown-button {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0 34px 0 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages {
|
||||||
|
height: 100%;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
.empty-state,
|
||||||
|
.message-row,
|
||||||
|
.composer-card { width: min(860px, calc(100% - 20px)); }
|
||||||
|
|
||||||
|
.composer-shell {
|
||||||
|
position: relative;
|
||||||
|
z-index: 5;
|
||||||
|
padding: 6px 10px calc(env(safe-area-inset-bottom, 0px) + 8px);
|
||||||
|
}
|
||||||
|
/* Mobile composer: fully independent from desktop */
|
||||||
|
.composer-card {
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 32px minmax(0, 1fr) auto;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
column-gap: 6px;
|
||||||
|
row-gap: 0;
|
||||||
|
}
|
||||||
|
.attachment-list {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-row: 1;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
/* textarea 放在第二行中间列 */
|
||||||
|
#messageInput {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 2;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 32px;
|
||||||
|
height: 32px;
|
||||||
|
max-height: 160px;
|
||||||
|
line-height: 20px;
|
||||||
|
padding: 6px 0;
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
resize: none;
|
||||||
|
overflow-y: hidden;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
box-sizing: border-box;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
/* composer-bottom 的内容拆开放入 grid */
|
||||||
|
.composer-bottom {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
.composer-hint {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.attach-btn {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 2;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 20px;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
.single-line-actions {
|
||||||
|
grid-column: 3;
|
||||||
|
grid-row: 2;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
.send-btn {
|
||||||
|
min-width: 48px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
132
src/index.js
Normal file
132
src/index.js
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
export default {
|
||||||
|
async fetch(request, env) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
if (request.method === 'OPTIONS') {
|
||||||
|
return new Response(null, { headers: corsHeaders(request, env) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/api/models' && request.method === 'GET') {
|
||||||
|
return handleModels(request, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/api/chat' && request.method === 'POST') {
|
||||||
|
return handleChat(request, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ error: 'Not found' }, 404, request, env);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handleModels(request, env) {
|
||||||
|
const authError = verifyAccessKey(request, env);
|
||||||
|
if (authError) return authError;
|
||||||
|
|
||||||
|
const upstream = await fetch(joinUrl(env.OPENAI_BASE_URL, '/v1/models'), {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${env.OPENAI_API_KEY}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await upstream.text();
|
||||||
|
let data;
|
||||||
|
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return json({ error: 'Invalid upstream /v1/models response', raw: text.slice(0, 500) }, 502, request, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!upstream.ok) {
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
error: data?.error?.message || data?.error || `Upstream /v1/models failed with HTTP ${upstream.status}`,
|
||||||
|
upstreamStatus: upstream.status,
|
||||||
|
},
|
||||||
|
upstream.status,
|
||||||
|
request,
|
||||||
|
env,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const models = Array.isArray(data?.data)
|
||||||
|
? data.data
|
||||||
|
.filter((item) => item && typeof item.id === 'string' && item.id.trim())
|
||||||
|
.map((item) => ({ id: item.id, label: item.id }))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return json({ models }, 200, request, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleChat(request, env) {
|
||||||
|
const authError = verifyAccessKey(request, env);
|
||||||
|
if (authError) return authError;
|
||||||
|
|
||||||
|
let payload;
|
||||||
|
try {
|
||||||
|
payload = await request.json();
|
||||||
|
} catch {
|
||||||
|
return json({ error: 'Invalid JSON body' }, 400, request, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
const upstream = await fetch(joinUrl(env.OPENAI_BASE_URL, '/v1/chat/completions'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${env.OPENAI_API_KEY}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers = new Headers(corsHeaders(request, env));
|
||||||
|
const contentType = upstream.headers.get('content-type');
|
||||||
|
if (contentType) headers.set('content-type', contentType);
|
||||||
|
|
||||||
|
return new Response(upstream.body, {
|
||||||
|
status: upstream.status,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyAccessKey(request, env) {
|
||||||
|
const auth = request.headers.get('authorization') || '';
|
||||||
|
const expected = `Bearer ${env.ACCESS_KEY}`;
|
||||||
|
|
||||||
|
if (!env.ACCESS_KEY) {
|
||||||
|
return json({ error: 'Server ACCESS_KEY is not configured' }, 500, request, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auth !== expected) {
|
||||||
|
return json({ error: 'Unauthorized' }, 401, request, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinUrl(base, path) {
|
||||||
|
return `${String(base || '').replace(/\/$/, '')}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function corsHeaders(request, env) {
|
||||||
|
const origin = request?.headers?.get('origin');
|
||||||
|
const allowedOrigin = env.ALLOWED_ORIGIN || '*';
|
||||||
|
const finalOrigin = allowedOrigin === '*' ? '*' : origin || allowedOrigin;
|
||||||
|
|
||||||
|
return {
|
||||||
|
'Access-Control-Allow-Origin': finalOrigin,
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||||
|
'Vary': 'Origin',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function json(data, status = 200, request, env) {
|
||||||
|
return new Response(JSON.stringify(data), {
|
||||||
|
status,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
...corsHeaders(request, env),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
4
wrangler.toml
Normal file
4
wrangler.toml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
name = "nekoai-api"
|
||||||
|
main = "src/index.js"
|
||||||
|
compatibility_date = "2026-03-12"
|
||||||
|
workers_dev = true
|
||||||
Reference in New Issue
Block a user