commit ef6e362205dcd37cad19a30af7fc6dbe25e4b580 Author: zimk Date: Fri Mar 13 17:00:59 2026 +0800 Initial commit: NekoAI Cloudflare chat app diff --git a/.dev.vars.example b/.dev.vars.example new file mode 100644 index 0000000..3851278 --- /dev/null +++ b/.dev.vars.example @@ -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" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a490f0d --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.wrangler/ +.env +public/config.js diff --git a/README.md b/README.md new file mode 100644 index 0000000..ef2f76e --- /dev/null +++ b/README.md @@ -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..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 ` +- Worker 代理上游 `/v1/models` +- 不做 fallback + +### `POST /api/chat` +- 需要 `Authorization: Bearer ` +- 代理上游 `/v1/chat/completions` +- 支持流式返回 + +## 当前实现说明 + +这是一个最简可跑版本,特点是: + +- 不接数据库 +- 不做用户系统 +- 不做聊天记录云端同步 +- 模型列表不写死 +- 上游失败就直接报错 +- 前端和 API 分离部署 + +## 后续可以继续加的东西 + +- Markdown 渲染 +- 代码高亮 +- 删除会话 +- 导出会话 +- IP 限流 +- 模型过滤 +- 自定义系统提示词 +- 自定义域名 diff --git a/package.json b/package.json new file mode 100644 index 0000000..8a9b8f8 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..bb7112b --- /dev/null +++ b/public/app.js @@ -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 = ` +
+
${escapeHtml(file.name)}
+
${formatBytes(file.size)} · ${escapeHtml(file.kind)}
+
+ + `; + 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 = ` + + + `; + + 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 : 出现时记录的 Date.now() +// assistantMessage.thinkMs : 出现时写入的耗时毫秒,写入后删除 thinkStart + +function updateThinkTiming(message, forceClose = false) { + const text = String(message.content || ''); + const hasOpen = //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 = ` +
+

今天想聊点什么?

+

输入访问密钥,选择模型,然后直接开始对话。支持上传图片、文档、压缩包等附件。

+
+ `; + 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 + ? `
N
NekoAI
` + : `
用户
`; + + 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); + + // 剥离思考内容,主文本永远不含 块 + const mainText = raw + .replace(/[\s\S]*?<\/think>/gi, '') + .replace(/[\s\S]*$/gi, '') + .trim(); + + // 思考框:只对 assistant 消息,且确实含有 标记才渲染 + const hasThink = message.role === 'assistant' && ( + isThinking || Number.isFinite(message.thinkMs) || //i.test(raw) + ); + if (hasThink) { + // 提取已收到的思考内容(无论是否完成) + let thinkContent = ''; + if (isThinking) { + // 还在思考中:提取 之后的所有内容(不需要闭合标签) + const m = raw.match(/([\s\S]*)$/i); + thinkContent = m ? m[1].trim() : ''; + } else { + // 思考完成:提取 ... 中间的内容 + thinkContent = [...raw.matchAll(/([\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 = ` + + 思考过程 + ${formatThinkDuration(ms)} + + ${thinkContent ? `
${renderMarkdown(thinkContent)}
` : ''} + `; + 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,'>'); + return `
${escaped}
`; + }; + 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("'", '''); +} diff --git a/public/config.example.js b/public/config.example.js new file mode 100644 index 0000000..064b076 --- /dev/null +++ b/public/config.example.js @@ -0,0 +1,3 @@ +window.NEKOAI_CONFIG = { + API_BASE_URL: "https://your-worker-name.your-subdomain.workers.dev" +}; diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..1774b25 --- /dev/null +++ b/public/index.html @@ -0,0 +1,91 @@ + + + + + + NekoAI + + + + + + + + + + + + + + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..2ccb702 --- /dev/null +++ b/public/styles.css @@ -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; + } +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..7cc6430 --- /dev/null +++ b/src/index.js @@ -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), + }, + }); +} diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..82f4752 --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,4 @@ +name = "nekoai-api" +main = "src/index.js" +compatibility_date = "2026-03-12" +workers_dev = true