diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0f2de5a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +*.md +Dockerfile +docker-compose.yml +LICENSE +netlify.toml +vercel.json +node_modules +.vscode diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2573a0f --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Your API Key for GEMINI_API +GEMINI_API_KEY= +# Custom base url for OpenAI API. default: https://generativelanguage.googleapis.com +API_BASE_URL= +# Inject analytics or other scripts before of the page +HEAD_SCRIPTS= +# Secret string for the project. Use for generating signatures for API calls +PUBLIC_SECRET_KEY= +# Set password for site, support multiple password separated by comma. If not set, site will be public +SITE_PASSWORD= +# Set the maximum number of historical messages used for contextual contact +PUBLIC_MAX_HISTORY_MESSAGES= diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..59a29c9 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,7 @@ +dist +public +node_modules +.netlify +.vercel +.github +.changeset diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..242f49b --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,31 @@ +module.exports = { + extends: ['@evan-yang', 'plugin:astro/recommended'], + rules: { + 'no-console': ['error', { allow: ['error'] }], + 'react/display-name': 'off', + 'react-hooks/rules-of-hooks': 'off', + '@typescript-eslint/no-use-before-define': 'off', + }, + overrides: [ + { + files: ['*.astro'], + parser: 'astro-eslint-parser', + parserOptions: { + parser: '@typescript-eslint/parser', + extraFileExtensions: ['.astro'], + }, + rules: { + 'no-mixed-spaces-and-tabs': ['error', 'smart-tabs'], + }, + }, + { + // Define the configuration for ` diff --git a/src/components/icons/Clear.tsx b/src/components/icons/Clear.tsx new file mode 100644 index 0000000..9741b6b --- /dev/null +++ b/src/components/icons/Clear.tsx @@ -0,0 +1,5 @@ +export default () => { + return ( + + ) +} diff --git a/src/components/icons/Env.tsx b/src/components/icons/Env.tsx new file mode 100644 index 0000000..8dc4dd2 --- /dev/null +++ b/src/components/icons/Env.tsx @@ -0,0 +1,5 @@ +export default () => { + return ( + + ) +} diff --git a/src/components/icons/Refresh.tsx b/src/components/icons/Refresh.tsx new file mode 100644 index 0000000..c7cca61 --- /dev/null +++ b/src/components/icons/Refresh.tsx @@ -0,0 +1,5 @@ +export default () => { + return ( + + ) +} diff --git a/src/components/icons/X.tsx b/src/components/icons/X.tsx new file mode 100644 index 0000000..e1f7854 --- /dev/null +++ b/src/components/icons/X.tsx @@ -0,0 +1,5 @@ +export default () => { + return ( + + ) +} diff --git a/src/env.d.ts b/src/env.d.ts new file mode 100644 index 0000000..13faad1 --- /dev/null +++ b/src/env.d.ts @@ -0,0 +1,16 @@ +/// + +interface ImportMetaEnv { + readonly GEMINI_API_KEY: string + readonly HTTPS_PROXY: string + readonly API_BASE_URL: string + readonly HEAD_SCRIPTS: string + readonly PUBLIC_SECRET_KEY: string + readonly SITE_PASSWORD: string + readonly OPENAI_API_MODEL: string + readonly PUBLIC_MAX_HISTORY_MESSAGES: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro new file mode 100644 index 0000000..4d4f8c0 --- /dev/null +++ b/src/layouts/Layout.astro @@ -0,0 +1,93 @@ +--- +import { pwaInfo } from 'virtual:pwa-info' + +export interface Props { + title: string; +} + +const { title } = Astro.props; +--- + + + + + + + + + + + + {title} + + { import.meta.env.HEAD_SCRIPTS && } + { pwaInfo && } + { import.meta.env.PROD && pwaInfo && } + + + + + + + + + diff --git a/src/message.css b/src/message.css new file mode 100644 index 0000000..2605d94 --- /dev/null +++ b/src/message.css @@ -0,0 +1,26 @@ +.message pre { + background-color: #64748b10; + font-size: 0.8rem; + padding: 0.4rem 1rem; +} + +.message .hljs { + background-color: transparent; +} + +.message table { + font-size: 0.8em; +} + +.message table thead tr { + background-color: #64748b40; + text-align: left; +} + +.message table th, .message table td { + padding: 0.6rem 1rem; +} + +.message table tbody tr:last-of-type { + border-bottom: 2px solid #64748b40; +} \ No newline at end of file diff --git a/src/pages/api/auth.ts b/src/pages/api/auth.ts new file mode 100644 index 0000000..fb9ba03 --- /dev/null +++ b/src/pages/api/auth.ts @@ -0,0 +1,13 @@ +import type { APIRoute } from 'astro' + +const realPassword = import.meta.env.SITE_PASSWORD || '' +const passList = realPassword.split(',') || [] + +export const post: APIRoute = async(context) => { + const body = await context.request.json() + + const { pass } = body + return new Response(JSON.stringify({ + code: (!realPassword || pass === realPassword || passList.includes(pass)) ? 0 : -1, + })) +} diff --git a/src/pages/api/generate.ts b/src/pages/api/generate.ts new file mode 100644 index 0000000..356aea6 --- /dev/null +++ b/src/pages/api/generate.ts @@ -0,0 +1,59 @@ +import { startChatAndSendMessageStream } from '@/utils/openAI' +import { verifySignature } from '@/utils/auth' +import type { APIRoute } from 'astro' + +const sitePassword = import.meta.env.SITE_PASSWORD || '' +const passList = sitePassword.split(',') || [] + +export const post: APIRoute = async(context) => { + const body = await context.request.json() + const { sign, time, messages, pass } = body + + if (!messages || messages.length === 0 || messages[messages.length - 1].role !== 'user') { + return new Response(JSON.stringify({ + error: { + message: 'Invalid message history: The last message must be from user role.', + }, + }), { status: 400 }) + } + + if (sitePassword && !(sitePassword === pass || passList.includes(pass))) { + return new Response(JSON.stringify({ + error: { + message: 'Invalid password.', + }, + }), { status: 401 }) + } + + if (import.meta.env.PROD && !await verifySignature({ t: time, m: messages[messages.length - 1].parts.map(part => part.text).join('') }, sign)) { + return new Response(JSON.stringify({ + error: { + message: 'Invalid signature.', + }, + }), { status: 401 }) + } + + try { + const history = messages.slice(0, -1) // All messages except the last one + const newMessage = messages[messages.length - 1].parts.map(part => part.text).join('') + + // Start chat and send message with streaming + const responseStream = await startChatAndSendMessageStream(history, newMessage) + + return new Response(responseStream, { status: 200, headers: { 'Content-Type': 'text/plain; charset=utf-8' } }) + } catch (error) { + console.error(error) + const errorMessage = error.message + const regex = /https?:\/\/[^\s]+/g + const filteredMessage = errorMessage.replace(regex, '').trim() + const messageParts = filteredMessage.split('[400 Bad Request]') + const cleanMessage = messageParts.length > 1 ? messageParts[1].trim() : filteredMessage + + return new Response(JSON.stringify({ + error: { + code: error.name, + message: cleanMessage, + }, + }), { status: 500 }) + } +} diff --git a/src/pages/index.astro b/src/pages/index.astro new file mode 100644 index 0000000..174a1dc --- /dev/null +++ b/src/pages/index.astro @@ -0,0 +1,36 @@ +--- +import Layout from '../layouts/Layout.astro' +import Header from '../components/Header.astro' +import Footer from '../components/Footer.astro' +import Generator from '../components/Generator' +import '../message.css' +import 'katex/dist/katex.min.css' +import 'highlight.js/styles/atom-one-dark.css' +--- + + +
+
+ +
+
+
+ + diff --git a/src/pages/password.astro b/src/pages/password.astro new file mode 100644 index 0000000..a120479 --- /dev/null +++ b/src/pages/password.astro @@ -0,0 +1,71 @@ +--- +import Layout from '../layouts/Layout.astro' +--- + + +
+
Please input password
+
+ +
+
+
+
+
+
+ + + + diff --git a/src/slider.css b/src/slider.css new file mode 100644 index 0000000..81cbc39 --- /dev/null +++ b/src/slider.css @@ -0,0 +1,82 @@ +/* ----------------------------------------------------------------------------- +* Slider +* -----------------------------------------------------------------------------*/ + +[data-scope='slider'][data-part='root'] { + @apply w-full flex flex-col +} +[data-scope='slider'][data-part='root'][data-orientation='vertical'] { + @apply h-60 +} + +[data-scope='slider'][data-part='control'] { + --slider-thumb-size: 14px; + --slider-track-height: 4px; + @apply relative fcc cursor-pointer +} +[data-scope='slider'][data-part='control'][data-orientation='horizontal'] { + @apply h-[var(--slider-thumb-size)]; +} +[data-scope='slider'][data-part='control'][data-orientation='vertical'] { + @apply w-[var(--slider-thumb-size)]; +} +[data-scope='slider'][data-part='control']:hover [data-part='range'] { + @apply bg-gray-400 dark:bg-gray-600 +} +[data-scope='slider'][data-part='control']:hover [data-part='thumb'] { + @apply bg-gray-300 dark:bg-gray-400 +} + + +[data-scope='slider'][data-part='thumb'] { + all: unset; + @apply bg-gray-200 dark:bg-gray-500 w-[var(--slider-thumb-size)] h-[var(--slider-thumb-size)] rounded-full b-#c5c5d2 b-2 +} +[data-scope='slider'][data-part='thumb'][data-disabled] { + @apply w-0 +} + +[data-scope='slider'] .control-area { + @apply flex mt-12px +} + +.slider [data-orientation='horizontal'] .control-area { + flex-direction: column; + width: 100%; +} + +.slider [data-orientation='vertical'] .control-area { + flex-direction: row; + height: 100%; +} + +[data-scope='slider'][data-part='track'] { + @apply rounded-full bg-gray-200 dark:bg-neutral-700 +} +[data-scope='slider'][data-part='track'][data-orientation='horizontal'] { + @apply h-[var(--slider-track-height)] w-full; +} +[data-scope='slider'][data-part='track'][data-orientation='vertical'] { + @apply h-full w-[var(--slider-track-height)]; +} + +[data-scope='slider'][data-part='range'] { + @apply bg-neutral-300 dark:bg-gray-700 +} +[data-scope='slider'][data-part='range'][data-disabled] { + @apply bg-neutral-300 dark:bg-gray-600 +} +[data-scope='slider'][data-part='range'][data-orientation='horizontal'] { + @apply h-full; +} +[data-scope='slider'][data-part='range'][data-orientation='vertical'] { + @apply w-full; +} + +[data-scope='slider'][data-part='output'] { + margin-inline-start: 12px; +} + +[data-scope='slider'][data-part='marker'] { + color: lightgray; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..dbea4ce --- /dev/null +++ b/src/types.ts @@ -0,0 +1,13 @@ +export interface ChatPart { + text: string +} + +export interface ChatMessage { + role: 'model' | 'user' + parts: ChatPart[] +} + +export interface ErrorMessage { + code: string + message: string +} diff --git a/src/utils/auth.ts b/src/utils/auth.ts new file mode 100644 index 0000000..21197e5 --- /dev/null +++ b/src/utils/auth.ts @@ -0,0 +1,32 @@ +import { sha256 } from 'js-sha256' +interface AuthPayload { + t: number + m: string +} + +async function digestMessage(message: string) { + if (typeof crypto !== 'undefined' && crypto?.subtle?.digest) { + const msgUint8 = new TextEncoder().encode(message) + const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + return hashArray.map(b => b.toString(16).padStart(2, '0')).join('') + } else { + return sha256(message).toString() + } +} + +export const generateSignature = async(payload: AuthPayload) => { + const { t: timestamp, m: lastMessage } = payload + const secretKey = import.meta.env.PUBLIC_SECRET_KEY as string || '' + const signText = `${timestamp}:${lastMessage}:${secretKey}` + // eslint-disable-next-line no-return-await + return await digestMessage(signText) +} + +export const verifySignature = async(payload: AuthPayload, sign: string) => { + // if (Math.abs(payload.t - Date.now()) > 1000 * 60 * 5) { + // return false + // } + const payloadSign = await generateSignature(payload) + return payloadSign === sign +} diff --git a/src/utils/openAI.ts b/src/utils/openAI.ts new file mode 100644 index 0000000..b5afc7f --- /dev/null +++ b/src/utils/openAI.ts @@ -0,0 +1,39 @@ +import { GoogleGenerativeAI } from '@fuyun/generative-ai' + +const apiKey = (import.meta.env.GEMINI_API_KEY) +const apiBaseUrl = (import.meta.env.API_BASE_URL)?.trim().replace(/\/$/, '') + +const genAI = apiBaseUrl + ? new GoogleGenerativeAI(apiKey, apiBaseUrl) + : new GoogleGenerativeAI(apiKey) + +export const startChatAndSendMessageStream = async(history: ChatMessage[], newMessage: string) => { + const model = genAI.getGenerativeModel({ model: 'gemini-pro' }) + + const chat = model.startChat({ + history: history.map(msg => ({ + role: msg.role, + parts: msg.parts.map(part => part.text).join(''), // Join parts into a single string + })), + generationConfig: { + maxOutputTokens: 8000, + }, + }) + + // Use sendMessageStream for streaming responses + const result = await chat.sendMessageStream(newMessage) + + const encodedStream = new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder() + for await (const chunk of result.stream) { + const text = await chunk.text() + const encoded = encoder.encode(text) + controller.enqueue(encoded) + } + controller.close() + }, + }) + + return encodedStream +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..31c7eff --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "astro/tsconfigs/base", + "compilerOptions": { + "baseUrl": ".", + "jsx": "preserve", + "jsxImportSource": "solid-js", + "types": ["vite-plugin-pwa/info"], + "paths": { + "@/*": ["src/*"], + }, + } +} diff --git a/unocss.config.ts b/unocss.config.ts new file mode 100644 index 0000000..be741bc --- /dev/null +++ b/unocss.config.ts @@ -0,0 +1,56 @@ +import { + defineConfig, + presetAttributify, + presetIcons, + presetTypography, + presetUno, + transformerDirectives, + transformerVariantGroup, +} from 'unocss' + +export default defineConfig({ + presets: [ + presetUno(), + presetAttributify(), + presetIcons({ + scale: 1.1, + cdn: 'https://esm.sh/', + }), + presetTypography({ + cssExtend: { + 'ul,ol': { + 'padding-left': '2.25em', + 'position': 'relative', + }, + }, + }), + ], + transformers: [transformerVariantGroup(), transformerDirectives()], + shortcuts: [{ + 'fc': 'flex justify-center', + 'fi': 'flex items-center', + 'fb': 'flex justify-between', + 'fcc': 'fc items-center', + 'fie': 'fi justify-end', + 'col-fcc': 'flex-col fcc', + 'inline-fcc': 'inline-flex items-center justify-center', + 'base-focus': 'focus:(bg-op-20 ring-0 outline-none)', + 'b-slate-link': 'border-b border-(slate none) hover:border-dashed', + 'gpt-title': 'text-2xl font-extrabold mr-1', + 'gpt-subtitle': 'text-(2xl transparent) font-extrabold bg-(clip-text gradient-to-r) from-sky-400 to-emerald-600', + 'gpt-copy-btn': 'absolute top-12px right-12px z-3 fcc border b-transparent w-8 h-8 p-2 bg-light-300 dark:bg-dark-300 op-90 cursor-pointer', + 'gpt-copy-tips': 'op-0 h-7 bg-black px-2.5 py-1 box-border text-xs c-white fcc rounded absolute z-1 transition duration-600 whitespace-nowrap -top-8', + 'gpt-retry-btn': 'fi gap-1 px-2 py-0.5 op-70 border border-slate rounded-md text-sm cursor-pointer hover:bg-slate/10', + 'gpt-back-top-btn': 'fcc p-2.5 text-base rounded-md hover:bg-slate/10 fixed bottom-60px right-20px z-10 cursor-pointer transition-colors', + 'gpt-back-bottom-btn': 'gpt-back-top-btn bottom-20px transform-rotate-180deg', + 'gpt-password-input': 'px-4 py-3 h-12 rounded-sm bg-(slate op-15) base-focus', + 'gpt-password-submit': 'fcc h-12 w-12 bg-slate cursor-pointer bg-op-20 hover:bg-op-50', + 'gen-slate-btn': 'h-12 px-4 py-2 bg-(slate op-15) hover:bg-op-20 rounded-sm', + 'gen-cb-wrapper': 'h-12 my-4 fcc gap-4 bg-(slate op-15) rounded-sm', + 'gen-cb-stop': 'px-2 py-0.5 border border-slate rounded-md text-sm op-70 cursor-pointer hover:bg-slate/10', + 'gen-text-wrapper': 'my-4 fc gap-2 transition-opacity', + 'gen-textarea': 'w-full px-3 py-3 min-h-12 max-h-36 rounded-sm bg-(slate op-15) resize-none base-focus placeholder:op-50 dark:(placeholder:op-30) scroll-pa-8px', + 'sys-edit-btn': 'inline-fcc gap-1 text-sm bg-slate/20 px-2 py-1 rounded-md transition-colors cursor-pointer hover:bg-slate/50', + 'stick-btn-on': '!bg-$c-fg text-$c-bg hover:op-80', + }], +}) diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..f70eaa4 --- /dev/null +++ b/vercel.json @@ -0,0 +1,3 @@ +{ + "buildCommand": "OUTPUT=vercel astro build" +} \ No newline at end of file