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