that has the specified color
7 | div.style.display = 'hidden'
8 | div.style.color = color
9 | document.body.appendChild(div)
10 |
11 | // Retrieve the computed color style of the temporary element
12 | const computedColor = getComputedStyle(div).color
13 | document.body.removeChild(div)
14 |
15 | // Match the RGB or RGBA format using a regular expression
16 | const rgbMatch = computedColor.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*[\d.]+)?\)$/)
17 | if (rgbMatch) {
18 | // Return an RGB object with parsed red, green, and blue values
19 | return [
20 | parseInt(rgbMatch[1], 10),
21 | parseInt(rgbMatch[2], 10),
22 | parseInt(rgbMatch[3], 10),
23 | ]
24 | } else {
25 | // Throw an error if the color format is invalid
26 | throw new Error('Invalid color format')
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/components/icons/Refresh.tsx:
--------------------------------------------------------------------------------
1 | export default () => {
2 | return (
3 |
4 | )
5 | }
6 |
--------------------------------------------------------------------------------
/src/components/MessageList.tsx:
--------------------------------------------------------------------------------
1 | import { Index } from 'solid-js'
2 |
3 | import { useChat } from '@/context/ChatContext'
4 |
5 | import ErrorMessageItem from './ErrorMessageItem'
6 | import MessageItem from './MessageItem'
7 |
8 | export default () => {
9 | const { messageList, currentAssistantMessage, streaming, currentError, retryLastFetch } = useChat()
10 |
11 | return (
12 | <>
13 |
14 | {(message, index) => (
15 | (!streaming() && !currentError() && !currentAssistantMessage() && index === messageList().length - 1)}
19 | onRetry={retryLastFetch}
20 | />
21 | )}
22 |
23 | {currentAssistantMessage() && (
24 |
29 | )}
30 | {currentError() &&
}
31 | >
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on: [push, fork, pull_request, workflow_dispatch, workflow_call]
4 |
5 | jobs:
6 |
7 | typos:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - uses: actions/checkout@v6
11 | - uses: crate-ci/typos@master
12 |
13 | lint:
14 | strategy:
15 | matrix:
16 | os: [ubuntu, windows, macos]
17 | fail-fast: false
18 | runs-on: ${{ matrix.os }}-latest
19 | steps:
20 | - uses: actions/checkout@v6
21 |
22 | - name: Pull changes
23 | run: git pull
24 | continue-on-error: true
25 |
26 | - name: Install pnpm
27 | uses: pnpm/action-setup@v4
28 | with:
29 | version: latest
30 |
31 | - name: Set node
32 | uses: actions/setup-node@v6
33 | with:
34 | node-version: 24.x
35 | cache: pnpm
36 |
37 | - name: Install
38 | run: pnpm install
39 |
40 | - name: Lint
41 | run: pnpm lint
42 |
43 | - name: Astro check
44 | run: pnpm astro sync && pnpx @astrojs/check
45 |
46 | - name: Svelte check
47 | run: pnpx svelte-check
48 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Muspi Merol
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/components/ErrorMessageItem.tsx:
--------------------------------------------------------------------------------
1 | import { createSignal, onMount } from 'solid-js'
2 |
3 | import type { ErrorMessage } from '@/types'
4 |
5 | import { fetchTranslation } from '@/utils/misc'
6 |
7 | import IconRefresh from './icons/Refresh'
8 |
9 | interface Props {
10 | data: ErrorMessage
11 | onRetry?: () => void
12 | }
13 |
14 | export default ({ data, onRetry }: Props) => {
15 | const [title, setTitle] = createSignal(data.code)
16 | const [description, setDescription] = createSignal(data.message)
17 |
18 | onMount(() => {
19 | fetchTranslation(data.code).then(setTitle)
20 | fetchTranslation(data.message).then(setDescription)
21 | })
22 |
23 | return (
24 |
25 | {data.code &&
{title()}
}
26 |
{description()}
27 | {onRetry && (
28 |
29 |
30 |
31 | 重新生成
32 |
33 |
34 | )}
35 |
36 | )
37 | }
38 |
--------------------------------------------------------------------------------
/src/pages/api/translate.ts:
--------------------------------------------------------------------------------
1 | import type { APIRoute } from 'astro'
2 |
3 | import { DEEPL_API_HOST, DEEPL_AUTH_TOKEN, TRANSLATE_PROVIDER, TRANSLATE_TARGET_LANG } from 'astro:env/server'
4 |
5 | import { run } from '../../utils/cf-workers-ai'
6 |
7 | const deeplAuthKey = DEEPL_AUTH_TOKEN
8 | const useDeepL = deeplAuthKey && TRANSLATE_PROVIDER !== 'cf'
9 | const target_lang = TRANSLATE_TARGET_LANG ?? (useDeepL ? 'ZH' : 'chinese')
10 |
11 | export const GET: APIRoute = async(context) => {
12 | const text = context.url.searchParams.get('text')
13 | if (useDeepL) {
14 | const host = DEEPL_API_HOST ?? (deeplAuthKey.endsWith(':fx') ? 'api-free.deepl.com' : 'api.deepl.com')
15 | const headers = { 'Authorization': `DeepL-Auth-Key ${deeplAuthKey}`, 'Content-Type': 'application/json' }
16 | const { translations: [{ text: translated_text, detected_source_language }] } = await fetch(`https://${host}/v2/translate`, { method: 'POST', headers, body: JSON.stringify({ text: [text], target_lang }) }).then(res => res.json())
17 | return new Response(translated_text, { headers: { 'x-detected-source-language': detected_source_language } })
18 | }
19 | const { result: { translated_text } } = await run('@cf/meta/m2m100-1.2b', { text, target_lang })
20 | return new Response(translated_text)
21 | }
22 |
--------------------------------------------------------------------------------
/src/pages/tutorial/index.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import { TUTORIAL_MD_URL } from 'astro:env/server'
3 | import Layout from '../../layouts/Layout.astro'
4 | import 'reveal.js/dist/reveal.css'
5 | import 'reveal.js/dist/theme/black.css'
6 | ---
7 |
8 |
15 |
16 |
17 |
22 |
23 |
24 |
40 |
--------------------------------------------------------------------------------
/src/components/ThemeColor.svelte:
--------------------------------------------------------------------------------
1 |
32 |
33 |
51 |
--------------------------------------------------------------------------------
/src/pages/api/moderate.ts:
--------------------------------------------------------------------------------
1 | import type { APIRoute } from 'astro'
2 |
3 | import { OPENAI_API_BASE_URL } from 'astro:env/server'
4 |
5 | const baseUrl = OPENAI_API_BASE_URL.trim().replace(/\/$/, '')
6 |
7 | const FORWARD_HEADERS = ['origin', 'referer', 'cookie', 'user-agent', 'via']
8 |
9 | export const POST: APIRoute = async({ request }) => {
10 | const input = await request.text()
11 |
12 | const headers: Record
= { 'Content-Type': 'application/json', 'Authorization': request.headers.get('Authorization') ?? '' }
13 |
14 | if (baseUrl) request.headers.forEach((val, key) => (FORWARD_HEADERS.includes(key) || key.startsWith('sec-') || key.startsWith('x-')) && (headers[key] = val))
15 |
16 | const body = JSON.stringify({ model: 'text-moderation-latest', input })
17 |
18 | const response = await fetch(`${baseUrl}/v1/moderations`, { method: 'POST', headers, body }).catch((err: Error) => {
19 | console.error(err)
20 |
21 | return new Response(JSON.stringify({
22 | error: {
23 | code: err.name,
24 | message: err.message,
25 | },
26 | }), { status: 500 })
27 | })
28 |
29 | if (!response.ok) return response
30 |
31 | const { results: [{ categories, category_scores }] } = await response.json()
32 |
33 | const flags = Object.keys(categories).filter(key => categories[key])
34 |
35 | return new Response(JSON.stringify({ flags, scores: category_scores }), { headers: { 'content-type': 'application/json' } })
36 | }
37 |
--------------------------------------------------------------------------------
/src/components/controls/Toggle.svelte:
--------------------------------------------------------------------------------
1 |
27 |
28 |
40 |
--------------------------------------------------------------------------------
/src/components/Tips.tsx:
--------------------------------------------------------------------------------
1 | import { useChat } from '@/context/ChatContext'
2 |
3 | export default () => {
4 | const { streaming, messageList, systemRoleEditing } = useChat()
5 |
6 | return (
7 | <>
8 | {
9 | !streaming() && messageList().length === 0 && !systemRoleEditing() && (
10 |
11 |
TIPS
12 |
B 开启/关闭跟随最新消息功能
13 |
/ 聚焦到输入框
14 |
Alt/Option + C 清空上下文
15 |
Alt/Option + O 打开/关闭设置
16 |
Alt/Option + Backspace 删除最后一条消息
17 |
18 | )
19 | }
20 | >
21 | )
22 | }
23 |
--------------------------------------------------------------------------------
/src/components/controls/Slider.svelte:
--------------------------------------------------------------------------------
1 |
19 |
20 |
23 |
24 |
25 |
26 | {#if $value !== undefined}
27 |
28 | {/if}
29 |
30 | {#snippet child({ props })}
31 |
34 | {/snippet}
35 |
36 |
37 |
--------------------------------------------------------------------------------
/.github/workflows/update.yml:
--------------------------------------------------------------------------------
1 | name: Auto update
2 |
3 | on:
4 | workflow_dispatch:
5 | schedule:
6 | - cron: 0 0 * * *
7 |
8 | jobs:
9 | taze:
10 | runs-on: ubuntu-latest
11 | permissions:
12 | contents: write
13 | outputs:
14 | updated: ${{ steps.set-output.outputs.updated }}
15 | steps:
16 | - uses: actions/checkout@v6
17 |
18 | - name: Install pnpm
19 | uses: pnpm/action-setup@v4
20 | with:
21 | version: latest
22 |
23 | - name: Set node
24 | uses: actions/setup-node@v6
25 | with:
26 | node-version: 24.x
27 | cache: pnpm
28 |
29 | - name: Check updates
30 | id: taze
31 | run: pnpx taze -a --failOnOutdated
32 | continue-on-error: true
33 |
34 | - name: Updates dependencies
35 | if: steps.taze.outcome == 'failure'
36 | run: |
37 | pnpx taze -w
38 | pnpm update
39 |
40 | - name: Commit changes
41 | if: steps.taze.outcome == 'failure'
42 | run: |
43 | git config --local user.name 'github-actions[bot]'
44 | git config --local user.email 'github-actions[bot]@users.noreply.github.com'
45 | git add package.json pnpm-lock.yaml
46 | git commit -m 'chore(deps): update dependencies'
47 | git push
48 | - name: Set output
49 | id: set-output
50 | if: steps.taze.outcome == 'failure'
51 | run: |
52 | echo "updated=true" >> $GITHUB_OUTPUT
53 |
54 | lint:
55 | needs: taze
56 | uses: ./.github/workflows/ci.yml
57 | if: needs.taze.outputs.updated == 'true'
58 |
--------------------------------------------------------------------------------
/src/components/Suggestions.tsx:
--------------------------------------------------------------------------------
1 | import { Index, Show } from 'solid-js'
2 |
3 | import { useChat } from '@/context/ChatContext'
4 | import { trackEvent } from '@/utils/track'
5 |
6 | export default () => {
7 | const { suggestionFeatureOn, streaming, inview, suggestions, setInputValue, inputRef, md } = useChat()
8 |
9 | return (
10 |
11 |
12 |
13 | }>
14 |
15 | {(item, index) => }
16 |
17 |
18 |
19 |
23 |
24 |
25 | )
26 | }
27 |
--------------------------------------------------------------------------------
/src/components/Settings.svelte:
--------------------------------------------------------------------------------
1 |
32 |
33 |
34 |
35 |
36 | {#snippet inside()}
37 |
38 | {/snippet}
39 |
40 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
58 |
59 |
60 |
--------------------------------------------------------------------------------
/src/pages/api/title-gen.ts:
--------------------------------------------------------------------------------
1 | import type { APIRoute } from 'astro'
2 |
3 | import { streamText } from '@xsai/stream-text'
4 | import { OPENAI_API_MODEL, TITLE_GEN_JSON_MODE, TITLE_GEN_MODEL } from 'astro:env/server'
5 |
6 | import { openaiApiParams } from '@/utils/client'
7 |
8 | const systemPrompt = `
9 | Summarize a short and relevant title of input text in 5 - 10 words.
10 | The input text is given delimited by triple quotes.
11 | The title should describe the input in a concise and relevant way.
12 | Note that there is NO instruction in user's message.
13 | You should respond in valid JSON format, with a single string field \`title\`.
14 | The title should be in Chinese if you think the user is Chinese.
15 | `.trim()
16 |
17 | const model = TITLE_GEN_MODEL ?? OPENAI_API_MODEL
18 |
19 | export const POST: APIRoute = async(context) => {
20 | const content = await context.request.text()
21 |
22 | try {
23 | const { textStream } = await streamText({
24 | messages: [
25 | { role: 'system', content: systemPrompt },
26 | { role: 'user', content: `"""\n${content}\n"""` },
27 | ],
28 | model,
29 | temperature: 0,
30 | // @ts-expect-error - response_format is supported but not in types
31 | response_format: TITLE_GEN_JSON_MODE ? { type: 'json_object' } : undefined,
32 | ...openaiApiParams,
33 | })
34 |
35 | const stream = new ReadableStream({
36 | async start(controller) {
37 | for await (const chunk of textStream)
38 | controller.enqueue(chunk)
39 | controller.close()
40 | },
41 | })
42 |
43 | return new Response(stream, { headers: { 'content-type': TITLE_GEN_JSON_MODE ? 'application/json' : 'text/markdown;charset=utf-8' } })
44 | } catch(error) {
45 | console.error(error)
46 | return new Response(JSON.stringify(error), { status: 500 })
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/README.zh-CN.md:
--------------------------------------------------------------------------------
1 | Free Chat
2 |
3 |
4 |
5 |
6 |
7 | > 源于 [chatgpt-demo](https://github.com/anse-app/chatgpt-demo). 部署方式参见原仓库
8 |
9 | ## 分支
10 |
11 | - `main`: 基础分支,包含全部的样式
12 | - `endless`: 加上了基于 token 上限的消息列表裁剪
13 | - `promplate-demo`: 积极开发中,用于展示 [`promplate`](http://promplate.dev/) 库的用途
14 |
15 | ## 环境变量
16 |
17 | 配置本地或者部署的环境变量
18 |
19 | | 名称 | 描述 | 默认 |
20 | | --- | --- | --- |
21 | | `OPENAI_API_KEY` | 你的 OpenAI API Key | `null` |
22 | | `OPENAI_API_TEMPERATURE` | 传给模型的 `temperature` 参数 | `1.0` |
23 | | `HTTPS_PROXY` | 为 OpenAI API 提供代理. | `null` |
24 | | `OPENAI_API_BASE_URL` | 请求 OpenAI API 的自定义 Base URL. | `https://api.openai.com` |
25 | | `HEAD_SCRIPTS` | 在页面的 `` 之前注入分析或其他脚本 | `null` |
26 | | `PUBLIC_SECRET_KEY` | 项目的秘密字符串。用于生成 API 调用的签名 | `null` |
27 | | `SITE_PASSWORD` | 为网站设置密码。如果未设置,则该网站将是公开的 | `null` |
28 | | `OPENAI_API_MODEL` | 使用的 [Chat 模型](https://platform.openai.com/docs/models/model-endpoint-compatibility) | `gpt-4o-mini` |
29 | | `TUTORIAL_MD_URL` | 教程页对应的 markdown 文件 url | `null` |
30 | | `PUBLIC_IFRAME_URL` | 通知栏 iframe 横幅的 url | `null` |
31 | | `UNDICI_UA` | 后端请求的 user-agent | `(forward)` |
32 | | `PUBLIC_RIGHT_ALIGN_MY_MSG` | 用户消息是否右对齐 | `null` |
33 | | `PUBLIC_CL100K_BASE_JSON_URL` | 从 CDN 加载 `cl100k_base.json`,比如可以设为 [jsdelivr.net](https://cdn.jsdelivr.net/npm/tiktoken@1.0.10/encoders/cl100k_base.json) | `null` |
34 | | `PUBLIC_TIKTOKEN_BG_WASM_URL` | 从 CDN 加载 `tiktoken_bg.wasm`,比如可设为 [esm.sh](https://esm.sh/tiktoken/lite/tiktoken_bg.wasm) | `null` |
35 |
36 | ## 参与贡献
37 |
38 | 这个项目的存在要感谢[原项目](https://github.com/anse-app/chatgpt-demo)。
39 |
40 | 感谢我们所有的支持者!🙏
41 |
42 | [](https://github.com/ddiu8081/chatgpt-demo/graphs/contributors)
43 |
44 | ## License
45 |
46 | MIT © [Muspi Merol](./LICENSE)
47 |
--------------------------------------------------------------------------------
/src/components/Footer.svelte:
--------------------------------------------------------------------------------
1 |
8 |
9 |
25 |
26 |
54 |
--------------------------------------------------------------------------------
/src/pages/password.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import Layout from '../layouts/Layout.astro'
3 | ---
4 |
5 |
6 |
7 | Please input password
8 |
14 |
15 |
16 |
17 |
51 |
52 |
72 |
--------------------------------------------------------------------------------
/src/components/Popup.astro:
--------------------------------------------------------------------------------
1 |
2 |
3 |
15 |
16 |
27 |
--------------------------------------------------------------------------------
/src/utils/tiktoken.ts:
--------------------------------------------------------------------------------
1 | import type { Tiktoken } from 'tiktoken'
2 |
3 | import { PUBLIC_CL100K_BASE_JSON_URL, PUBLIC_TIKTOKEN_BG_WASM_URL } from 'astro:env/client'
4 |
5 | import type { ChatMessage } from '@/types'
6 |
7 | export const tokenCountCache = new Map()
8 |
9 | const countTokensSingleMessage = (enc: Tiktoken, message: ChatMessage) => {
10 | return 4 + enc.encode(message.content, 'all').length // im_start, im_end, role/name, "\n"
11 | }
12 |
13 | const countTokensSingleMessageWithCache = (enc: Tiktoken, cacheIt: boolean, message: ChatMessage) => {
14 | if (tokenCountCache.has(message.content)) return tokenCountCache.get(message.content)!
15 |
16 | const count = countTokensSingleMessage(enc, message)
17 | if (cacheIt) tokenCountCache.set(message.content, count)
18 | return count
19 | }
20 |
21 | export const countTokens = (enc: Tiktoken, messages: ChatMessage[]) => {
22 | if (messages.length === 0) return
23 |
24 | if (!enc) return { total: Infinity }
25 |
26 | const lastMsg = messages.at(-1)
27 | const context = messages.slice(0, -1)
28 |
29 | const countTokens: (cacheIt: boolean, message: ChatMessage) => number = countTokensSingleMessageWithCache.bind(null, enc)
30 |
31 | const countLastMsg = countTokens(false, lastMsg!)
32 | const countContext = context.map(countTokens.bind(null, true)).reduce((a, b) => a + b, 3) // im_start, "assistant", "\n"
33 |
34 | return { countContext, countLastMsg, total: countContext + countLastMsg }
35 | }
36 |
37 | const cl100k_base_json = PUBLIC_CL100K_BASE_JSON_URL
38 | const tiktoken_bg_wasm = PUBLIC_TIKTOKEN_BG_WASM_URL
39 |
40 | async function getBPE() {
41 | return fetch(cl100k_base_json).then(r => r.json())
42 | }
43 |
44 | export const initTikToken = async() => {
45 | const { init } = await import('tiktoken/lite/init')
46 | const [{ bpe_ranks, special_tokens, pat_str }, { Tiktoken }] = await Promise.all([
47 | getBPE().catch(console.error),
48 | import('tiktoken/lite/init'),
49 | fetch(tiktoken_bg_wasm).then(r => r.arrayBuffer()).then(wasm => init(imports => WebAssembly.instantiate(wasm, imports))),
50 | ])
51 | return new Tiktoken(bpe_ranks, special_tokens, pat_str)
52 | }
53 |
--------------------------------------------------------------------------------
/src/hooks/createSmoothStreaming.ts:
--------------------------------------------------------------------------------
1 | import { createSpring } from '@solid-primitives/spring'
2 | import { batch, createEffect, createMemo, createSignal } from 'solid-js'
3 |
4 | interface CreateSmoothStreamingParams {
5 | onDone: (content: string) => void
6 | damping?: number
7 | }
8 |
9 | export function createSmoothStreaming({ onDone, damping = 0.25 }: CreateSmoothStreamingParams) {
10 | const [realValue, setRealValue] = createSignal('')
11 | const [done, setDone] = createSignal(true)
12 | const [currentAssistantMessage, setCurrentAssistantMessage] = createSignal('')
13 |
14 | const stiffness = (damping ** 2) / 4.1
15 | const [_displayedLength, setDisplayedLength] = createSpring(0, { stiffness, damping, precision: 0.001 })
16 | const displayedLength = createMemo(() => Math.round(_displayedLength()))
17 |
18 | createEffect(() => {
19 | const length = displayedLength()
20 | setCurrentAssistantMessage(realValue().slice(0, length))
21 |
22 | if (done() && length >= realValue().length) {
23 | if (currentAssistantMessage()) {
24 | onDone(currentAssistantMessage())
25 | }
26 | // Reset internal state after archiving
27 | batch(() => {
28 | setCurrentAssistantMessage('')
29 | setDisplayedLength(0, { hard: true })
30 | setRealValue('')
31 | })
32 | }
33 | })
34 |
35 | const start = () => {
36 | batch(() => {
37 | setCurrentAssistantMessage('')
38 | setRealValue('')
39 | setDone(false)
40 | setDisplayedLength(0, { hard: true })
41 | })
42 | }
43 |
44 | const append = (delta: string) => {
45 | setRealValue((prev) => {
46 | const next = prev + delta
47 | if (delta.trim())
48 | setDisplayedLength(next.length)
49 |
50 | return next
51 | })
52 | }
53 |
54 | const finish = () => {
55 | batch(() => {
56 | setDisplayedLength(realValue().length + 7)
57 | setDone(true)
58 | })
59 | }
60 |
61 | const clear = () => {
62 | batch(() => {
63 | setCurrentAssistantMessage('')
64 | setDisplayedLength(0, { hard: true })
65 | setRealValue('')
66 | setDone(true)
67 | })
68 | }
69 |
70 | return {
71 | currentAssistantMessage,
72 | start,
73 | append,
74 | finish,
75 | clear,
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "free-chat",
3 | "version": "1.0.0",
4 | "scripts": {
5 | "prepare": "node prepare.cjs",
6 | "dev": "astro dev",
7 | "build": "astro build",
8 | "preview": "astro preview",
9 | "astro": "astro",
10 | "lint": "eslint .",
11 | "lint:fix": "eslint . --fix"
12 | },
13 | "dependencies": {
14 | "@astrojs/netlify": "~6.6.0",
15 | "@astrojs/node": "~9.5.0",
16 | "@astrojs/solid-js": "~5.1.0",
17 | "@astrojs/svelte": "~7.2.0",
18 | "@astrojs/vercel": "~9.0.0",
19 | "@joplin/turndown-plugin-gfm": "^1.0.63",
20 | "@lobehub/icons-static-svg": "^1.65.0",
21 | "@solid-primitives/clipboard": "^1.6.2",
22 | "@solid-primitives/event-listener": "^2.4.3",
23 | "@solid-primitives/scheduled": "^1.5.2",
24 | "@solid-primitives/spring": "^0.1.2",
25 | "@xsai/stream-text": "^0.2.2",
26 | "astro": "~5.16.0",
27 | "eslint": "~9.39.0",
28 | "eventsource-parser": "~3.0.6",
29 | "highlight.js": "~11.11.1",
30 | "katex": "^0.16.22",
31 | "lenis": "~1.3.11",
32 | "markdown-it": "~14.1.0",
33 | "markdown-it-highlightjs": "~4.2.0",
34 | "markdown-it-katex": "~2.0.3",
35 | "partial-json": "^0.1.7",
36 | "reveal.js": "~5.2.1",
37 | "solid-js": "~1.9.9",
38 | "solid-toast": "^0.5.0",
39 | "svelte": "~5.46.0",
40 | "svelte-sonner": "~1.0.5",
41 | "tiktoken": "~1.0.22",
42 | "turndown": "^7.2.1",
43 | "vite": "~7.3.0",
44 | "xsfetch": "^0.3.5"
45 | },
46 | "devDependencies": {
47 | "@antfu/eslint-config": "~6.7.0",
48 | "@iconify/json": "latest",
49 | "@julr/unocss-preset-heropatterns": "~2.0.0",
50 | "@sveltejs/vite-plugin-svelte": "~6.2.1",
51 | "@types/markdown-it": "~14.1.2",
52 | "@types/reveal.js": "~5.2.0",
53 | "@types/turndown": "^5.0.5",
54 | "@typescript-eslint/parser": "^8.42.0",
55 | "@unocss/core": "~66.4.2",
56 | "@unocss/eslint-config": "~66.4.2",
57 | "@unocss/extractor-svelte": "~66.4.2",
58 | "@unocss/reset": "~66.4.2",
59 | "@vite-pwa/astro": "~1.2.0",
60 | "bits-ui": "~2.14.0",
61 | "eslint-plugin-astro": "~1.5.0",
62 | "eslint-plugin-format": "~1.1.0",
63 | "eslint-plugin-svelte": "~3.13.0",
64 | "svelte-inview": "~4.0.4",
65 | "svelte-persisted-store": "^0.12.0",
66 | "svelte-ripple-action": "~2.0.0",
67 | "unocss": "~66.4.2",
68 | "unocss-preset-scrollbar": "~3.2.0",
69 | "vite-plugin-devtools-json": "~1.0.0"
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/src/layouts/Layout.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import { pwaInfo } from 'virtual:pwa-info'
3 | import { HEAD_SCRIPTS } from 'astro:env/client'
4 |
5 | export interface Props {
6 | title: string;
7 | }
8 |
9 | const { title } = Astro.props
10 | const origin = Astro.request.headers.get('host')
11 | ---
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | {title} - {origin}
24 |
25 | { HEAD_SCRIPTS && }
26 | { pwaInfo && }
27 | { import.meta.env.PROD && pwaInfo && }
28 |
29 |
30 |
31 |
32 |
33 |
34 |
57 |
58 | { Astro.cookies.get('dark') === undefined
59 | &&
67 | }
68 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Free Chat
2 |
3 |
4 |
5 |
6 |
7 | > Forked from [chatgpt-demo](https://github.com/anse-app/chatgpt-demo). Find deployment instructions in the original repository.
8 |
9 | ## Branches
10 |
11 | - `main`: the base branch containing all the styles
12 | - `endless`: includes token-based message list trimming
13 | - `promplate-demo`: active developed, for demonstrating the usage of [`promplate`](http://promplate.dev/)
14 |
15 | ## Environment Variables
16 |
17 | You can control the website through environment variables.
18 |
19 | | Name | Description | Default |
20 | | --- | --- | --- |
21 | | `OPENAI_API_KEY` | Your API Key for OpenAI. | `null` |
22 | | `OPENAI_API_TEMPERATURE` | Default `temperature` parameter for model. | `1.0` |
23 | | `HTTPS_PROXY` | Provide proxy for OpenAI API. | `null` |
24 | | `OPENAI_API_BASE_URL` | Custom base url for OpenAI API. | `https://api.openai.com` |
25 | | `HEAD_SCRIPTS` | Inject analytics or other scripts before `` of the page | `null` |
26 | | `PUBLIC_SECRET_KEY` | Secret string for the project. Use for generating signatures for API calls | `null` |
27 | | `SITE_PASSWORD` | Set password for site. If not set, site will be public | `null` |
28 | | `OPENAI_API_MODEL` | ID of the model to use. [Model endpoint compatibility](https://platform.openai.com/docs/models/model-endpoint-compatibility) | `gpt-4o-mini` |
29 | | `TUTORIAL_MD_URL` | url of the tutorial markdown file | `null` |
30 | | `PUBLIC_IFRAME_URL` | url of the advertisement iframe | `null` |
31 | | `UNDICI_UA` | user-agent for backend requests | `(forward)` |
32 | | `PUBLIC_RIGHT_ALIGN_MY_MSG` | whether user messages should be right-aligned | `null` |
33 | | `PUBLIC_CL100K_BASE_JSON_URL` | CDN url for `cl100k_base.json`, such as [file at jsdelivr.net](https://cdn.jsdelivr.net/npm/tiktoken@1.0.10/encoders/cl100k_base.json) | `null` |
34 | | `PUBLIC_TIKTOKEN_BG_WASM_URL` | CDN url for `tiktoken_bg.wasm`, such as [file at esm.sh](https://esm.sh/tiktoken/lite/tiktoken_bg.wasm) | `null` |
35 |
36 | ## Contributing
37 |
38 | This project exists thanks to all those who contributed to [the original project](https://github.com/anse-app/chatgpt-demo).
39 |
40 | Thank you to all our supporters!🙏
41 |
42 | [](https://github.com/ddiu8081/chatgpt-demo/graphs/contributors)
43 |
44 | ## License
45 |
46 | MIT © [Muspi Merol](./LICENSE)
47 |
--------------------------------------------------------------------------------
/src/components/BackTop.astro:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
54 |
--------------------------------------------------------------------------------
/src/components/SystemRoleSettings.tsx:
--------------------------------------------------------------------------------
1 | import type { Accessor, Setter } from 'solid-js'
2 |
3 | import { Show } from 'solid-js'
4 |
5 | import { trackEvent } from '@/utils/track'
6 |
7 | import IconEnv from './icons/Env'
8 | import IconX from './icons/X'
9 |
10 | interface Props {
11 | canEdit: Accessor
12 | systemRoleEditing: Accessor
13 | setSystemRoleEditing: Setter
14 | currentSystemRoleSettings: Accessor
15 | setCurrentSystemRoleSettings: Setter
16 | }
17 |
18 | export default (props: Props) => {
19 | let systemInputRef: HTMLTextAreaElement
20 |
21 | const handleButtonClick = () => {
22 | props.setCurrentSystemRoleSettings(systemInputRef!.value)
23 | props.setSystemRoleEditing(false)
24 | trackEvent('set-system-role', { empty: systemInputRef!.value === '' })
25 | }
26 |
27 | return (
28 |
29 |
30 |
31 |
32 |
33 |
}>
34 |
props.setCurrentSystemRoleSettings('')} class="sys-edit-btn rd-50% p-1">
35 |
36 |
自定义场景
37 |
38 |
39 | {props.currentSystemRoleSettings()}
40 |
41 |
42 |
43 |
44 | props.setSystemRoleEditing(!props.systemRoleEditing())} class="sys-edit-btn">
45 |
46 | 自定义场景
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 | 自定义场景
55 |
56 |
通过 system message 设定指令、角色、情境等
57 |
58 |
66 |
67 |
70 |
71 |
72 |
73 | )
74 | }
75 |
--------------------------------------------------------------------------------
/src/components/TokenCounter.tsx:
--------------------------------------------------------------------------------
1 | import type { Accessor } from 'solid-js'
2 | import type { Tiktoken } from 'tiktoken/lite'
3 |
4 | import { createMemo, createSignal, onCleanup, onMount, Show } from 'solid-js'
5 |
6 | import type { ChatMessage } from '@/types'
7 |
8 | import { countTokens, initTikToken } from '../utils/tiktoken'
9 |
10 | interface Props {
11 | currentSystemRoleSettings: Accessor
12 | messageList: Accessor
13 | textAreaValue: Accessor
14 | currentAssistantMessage: Accessor
15 | hide: boolean
16 | }
17 |
18 | const HIDE_TIMEOUT = 5000
19 | export const [encoder, setEncoder] = createSignal(null)
20 |
21 | export default (props: Props) => {
22 | const [isHide, setHide] = createSignal(true)
23 | let hideTimer: NodeJS.Timeout | null = null
24 |
25 | const { currentSystemRoleSettings, messageList, textAreaValue, currentAssistantMessage } = props
26 |
27 | onMount(() => {
28 | initTikToken().then((tiktoken) => {
29 | setEncoder(tiktoken)
30 | })
31 |
32 | onCleanup(() => {
33 | setEncoder((enc) => {
34 | enc?.free()
35 | return null
36 | })
37 | })
38 | })
39 |
40 | const getTokensUsage = createMemo(() => {
41 | if (!encoder()) return
42 |
43 | const messages: ChatMessage[] = []
44 | // begin with a unique system message
45 | if (currentSystemRoleSettings()) messages.push({ role: 'system', content: currentSystemRoleSettings() })
46 | // following the existing conversation
47 | messages.push(...messageList())
48 | // maybe still streaming
49 | if (currentAssistantMessage()) messages.push({ role: 'assistant', content: currentAssistantMessage() })
50 | // or maybe still typing
51 | // (streaming and typing may be at the same time in the future)
52 | else if (textAreaValue()) messages.push({ role: 'user', content: textAreaValue() })
53 |
54 | const result = countTokens(encoder()!, messages)
55 |
56 | setHide(false)
57 |
58 | clearTimeout(hideTimer!)
59 | hideTimer = setTimeout(() => setHide(true), HIDE_TIMEOUT)
60 |
61 | return result
62 | })
63 |
64 | return (
65 |
66 |
67 |
68 | {getTokensUsage()?.total ?? 0}
69 | tokens
70 |
71 |
72 |
73 | )
74 | }
75 |
--------------------------------------------------------------------------------
/src/components/ParallaxBackground.tsx:
--------------------------------------------------------------------------------
1 | import { createSpring } from '@solid-primitives/spring'
2 | import { createEffect, createSignal, onMount } from 'solid-js'
3 |
4 | import { useChat } from '@/context/ChatContext'
5 |
6 | export default () => {
7 | const { isStick, streaming, messageList, currentError } = useChat()
8 |
9 | let bgd!: HTMLDivElement
10 | const [bgdAnimating, setBgdAnimating] = createSignal(false)
11 |
12 | onMount(() => {
13 | const damping = 0.5
14 | const stiffness = (damping ** 2) / 4.1
15 | const [bgdOffset, setBgdOffset] = createSpring(0, { stiffness, damping })
16 | const [bgdOffsetTarget, setBgdOffsetTarget] = createSignal(-document.documentElement.scrollTop / 10)
17 |
18 | function applyBgdOffset(value: number) {
19 | bgd.style.setProperty('--scroll', `${value}pt`)
20 | }
21 |
22 | // Update target on scroll
23 | window.addEventListener('scroll', () => {
24 | setBgdOffsetTarget(-document.documentElement.scrollTop / 10)
25 | })
26 |
27 | // Update target on resize
28 | window.addEventListener('resize', () => {
29 | setBgdOffsetTarget(-document.documentElement.scrollTop / 10)
30 | })
31 |
32 | // Animate when streaming
33 | createEffect(() => {
34 | if (streaming()) {
35 | isStick() // Track the signal change
36 | setBgdAnimating(true)
37 | }
38 | })
39 |
40 | // Animate when new user message is added
41 | createEffect(() => {
42 | if (messageList().at(-1)?.role === 'user') {
43 | setBgdAnimating(true)
44 | }
45 | })
46 |
47 | // Animate when error occurs in stick-to-bottom mode
48 | createEffect(() => {
49 | if (currentError() && isStick()) {
50 | setBgdAnimating(true)
51 | }
52 | })
53 |
54 | // Stop animating when spring reaches target
55 | createEffect(() => {
56 | const reached = Math.round(bgdOffset()) === Math.round(bgdOffsetTarget())
57 | if (reached && !(isStick() && streaming())) setBgdAnimating(false)
58 | })
59 |
60 | // Apply offset
61 | createEffect(() => {
62 | applyBgdOffset(bgdAnimating() ? bgdOffset() : bgdOffsetTarget())
63 | })
64 |
65 | // Drive spring
66 | createEffect(() => {
67 | setBgdOffset(bgdOffsetTarget(), { hard: !bgdAnimating() })
68 | })
69 |
70 | // Listen for Alt+C clear event
71 | window.addEventListener('keydown', (event) => {
72 | if (event.altKey && event.code === 'KeyC') {
73 | setBgdAnimating(true)
74 | }
75 | }, false)
76 | })
77 |
78 | return (
79 |
83 | )
84 | }
85 |
--------------------------------------------------------------------------------
/src/components/Header.svelte:
--------------------------------------------------------------------------------
1 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
49 |
50 |
51 |
52 | 已支持
53 |
54 | {@html openai} OpenAI 最新的 gpt-5 系列模型
55 |
56 |
57 |
58 | {#if PUBLIC_IFRAME_URL}
59 |
60 | {/if}
61 |
62 | {#await import('./Sponsorship.svelte') then { default: Sponsorship }}
63 |
64 | {/await}
65 |
66 |
67 |
--------------------------------------------------------------------------------
/src/components/controls/ModelItem.svelte:
--------------------------------------------------------------------------------
1 |
15 |
16 |
34 |
35 |
36 |
37 |
41 |
42 |
67 |
--------------------------------------------------------------------------------
/src/pages/index.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import Popup from '../components/Popup.astro'
3 | import Layout from '../layouts/Layout.astro'
4 | import Header from '../components/Header.svelte'
5 | import Footer from '../components/Footer.svelte'
6 | import BackTop from '../components/BackTop.astro'
7 | import ChatInterface from '../components/ChatInterface'
8 | import '../message.css'
9 | import 'katex/dist/katex.min.css'
10 | import 'highlight.js/styles/atom-one-dark.css'
11 | ---
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | { Astro.cookies.get('privacyPolicy')?.value !== 'confirmed' &&
}
20 |
21 |
22 |
23 |
35 |
36 |
101 |
--------------------------------------------------------------------------------
/src/components/Modal.svelte:
--------------------------------------------------------------------------------
1 |
45 |
46 | (show = false)} class="fixed inset-0 z-100 bg-black/10 transition-opacity duration-800 dark:bg-black/40" class:pointer-events-none={!show} class:op-0={!show} ontransitionstart={() => { transitioning = true }} ontransitionend={() => { transitioning = false }}>
47 |
48 |
73 |
74 |
--------------------------------------------------------------------------------
/src/components/controls/ModelSelector.svelte:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/src/components/Themetoggle.svelte:
--------------------------------------------------------------------------------
1 |
48 |
49 |
50 |
51 |
70 |
71 |
103 |
--------------------------------------------------------------------------------
/uno.config.ts:
--------------------------------------------------------------------------------
1 | import { presetHeroPatterns } from '@julr/unocss-preset-heropatterns'
2 | import extractorSvelte from '@unocss/extractor-svelte'
3 | import { defineConfig, presetAttributify, presetIcons, presetTypography, presetWebFonts, presetWind3, transformerDirectives, transformerVariantGroup } from 'unocss'
4 | import { presetScrollbar } from 'unocss-preset-scrollbar'
5 |
6 | export default defineConfig({
7 | presets: [
8 | presetWind3(),
9 | presetAttributify(),
10 | presetIcons({ scale: 1.1 }),
11 | presetWebFonts({ provider: 'google', fonts: { mono: 'JetBrains Mono', fira: 'Fira Code:400,500,600,700' } }),
12 | presetTypography({ cssExtend: { 'ul,ol': { 'padding-left': '2.25em', 'position': 'relative' } } }),
13 | presetHeroPatterns(),
14 | presetScrollbar(),
15 | ],
16 | extractors: [extractorSvelte()],
17 | transformers: [transformerVariantGroup(), transformerDirectives()],
18 | shortcuts: {
19 | 'fc': 'flex justify-center',
20 | 'fi': 'flex items-center',
21 | 'fb': 'flex justify-between',
22 | 'fcc': 'fc items-center',
23 | 'fie': 'fi justify-end',
24 | 'col-fcc': 'flex-col fcc',
25 | 'inline-fcc': 'inline-flex items-center justify-center',
26 | 'base-focus': 'focus:(bg-op-20 ring-0 outline-none)',
27 | 'b-slate-link': 'border-b border-($c-fg-70 none) hover:border-dashed',
28 | 'gpt-title': 'text-lg font-extrabold sm:text-xl',
29 | 'gpt-subtitle': 'bg-gradient-(from-emerald-400 to-sky-200 to-r) bg-clip-text text-lg text-transparent font-extrabold sm:text-xl',
30 | 'gpt-copy-btn': 'absolute top-12px right-12px z-3 fcc border b-transparent w-fit min-w-8 h-8 p-2 bg-white/4 hover:bg-white/8 text-white/85 dark:(bg-$c-fg-5 hover:bg-$c-fg-10 text-$c-fg-90) cursor-pointer transition-all duration-150 active:scale-90 op-0 group-hover:op-100 rounded-1.1 backdrop-blur-10',
31 | 'gpt-retry-btn': 'fi gap-1 px-2 py-0.5 op-70 ring-1.2 ring-$c-fg-50 rounded-md text-sm code]:font-mono code]:(font-700 dark:font-600) [&_li_p]:my-0 [&_li]:my-1 *]:mt-2 *]:mb-2 [&_li]:relative [&_ul]:pl-7 [&_ol]:pl-7 [&_ul>li]:(pl-1.5 list-none before:absolute before:-left-3.8 before:top-sm before:-translate-y-1/2 before:rounded-full before:bg-$c-fg-50 before:px-0.8 before:py-0.36 before:content-empty) [&_ol>li]:(marker:text-$c-fg-70 pl-1.5) overflow-x-overlay',
44 | },
45 | })
46 |
--------------------------------------------------------------------------------
/src/components/Sponsorship.svelte:
--------------------------------------------------------------------------------
1 |
50 |
51 |
52 | {#if showModal}
53 |
54 |
大家好!
55 |
56 | 我,
Muspi Merol
57 | 是一名学生、一位活跃的开源开发者,致力于将 JavaScript 的 DX 带给 Python 生态。生活上,我是一个理想主义者,半个 e/acc,典型的 ENFP🐕,欢迎交朋友~
58 |
59 |
我关注 LLM 及其应用、开发者工具、社交、教育以及设计。
60 |
大家的持续捐赠鼓励是我维护该免费产品的重要动力,所以我每周在检测到一次长对话之后会提示一次捐赠。谢谢理解!
61 |
62 |
63 | 感谢您的使用!我们非常喜欢听到大家的意见,因此有任何问题 / 建议 / 合作意向,欢迎提给我!您可以通过
64 |
邮件
65 | /
66 |
微信
67 | /
68 |
Telegram 群组联系我。
但捐赠留言是没法回复的哦~
69 |
70 | {#await import('./Sponsor.svelte').finally(() => { svgReady = true }) then QR}
71 | {#if showQR && pngReady && svgReady}
72 |
73 |
74 |
75 | {/if}
76 | {/await}
77 | {#if showButton}
78 |
79 |
80 |
81 | {/if}
82 |
83 | {/if}
84 |
85 |
86 |
87 | {#if showModal}
88 | pngReady = true} href="avatar.png" as="image" />
89 | {/if}
90 |
91 |
92 |
97 |
--------------------------------------------------------------------------------
/src/utils/misc.ts:
--------------------------------------------------------------------------------
1 | import { SUGGEST_MODEL } from 'astro:env/client'
2 | import { Allow, parse } from 'partial-json'
3 | import { onCleanup } from 'solid-js'
4 |
5 | import type { ChatMessage } from '@/types'
6 |
7 | import { promplateBaseUrl } from './constants'
8 | import { splitReasoningPart } from './deepseek'
9 | import { responseToAsyncIterator } from './streaming'
10 |
11 | function isAsyncGeneratorFunction(obj: any): obj is AsyncGeneratorFunction {
12 | return obj?.constructor?.name === 'AsyncGeneratorFunction'
13 | }
14 |
15 | function createAbortSignal() {
16 | const controller = new AbortController()
17 | onCleanup(() => controller.abort())
18 | return controller.signal
19 | }
20 |
21 | function retry(times: number) {
22 | return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
23 | const originalMethod = descriptor.value
24 |
25 | if (isAsyncGeneratorFunction(originalMethod)) {
26 | descriptor.value = async function* (...args: any[]) {
27 | for (let i = 0; i < times; i++) {
28 | try {
29 | yield* originalMethod.apply(this, args)
30 | return
31 | } catch(error) {
32 | if (error instanceof Error && error.name === 'AbortError') {
33 | throw error // AbortError should not be retried
34 | }
35 | console.error(`Attempt ${i + 1} failed. Retrying...`, error)
36 | }
37 | }
38 | throw new Error(`Function ${propertyKey} failed after ${times} attempts.`)
39 | }
40 | } else {
41 | descriptor.value = async function(...args: any[]) {
42 | for (let i = 0; i < times; i++) {
43 | try {
44 | return await originalMethod.apply(this, args)
45 | } catch(error) {
46 | console.error(`Attempt ${i + 1} failed. Retrying...`, error)
47 | }
48 | }
49 | throw new Error(`Function ${propertyKey} failed after ${times} attempts.`)
50 | }
51 | }
52 | return descriptor
53 | }
54 | }
55 |
56 | class API {
57 | @retry(3)
58 | async* iterateTitle(input: string) {
59 | const res = await fetch('/api/title-gen', {
60 | signal: createAbortSignal(),
61 | method: 'POST',
62 | body: input,
63 | headers: localStorage.getItem('apiKey') ? { authorization: `Bearer ${localStorage.getItem('apiKey')}` } : {},
64 | })
65 | if (!res.ok) throw new Error(await res.text())
66 | let whole = ''
67 | for await (const delta of responseToAsyncIterator(res)) {
68 | whole += delta
69 | let json = splitReasoningPart(whole)[1]
70 | if (!json) continue
71 | if (!json.startsWith('{')) {
72 | if (json.includes('{'))
73 | json = json.slice(json.indexOf('{'))
74 | else
75 | continue
76 | }
77 | const { title }: { title?: string } = parse(json)
78 | if (title) yield title
79 | }
80 | }
81 |
82 | async* iterateSuggestion(messages: ChatMessage[]) {
83 | if (messages.length === 0 || messages.at(-1)?.role === 'user') return
84 |
85 | const res = await fetch(`${promplateBaseUrl}/single/suggest`, {
86 | signal: createAbortSignal(),
87 | method: 'PUT',
88 | body: JSON.stringify({ messages, model: SUGGEST_MODEL }),
89 | headers: { 'content-type': 'application/json' },
90 | })
91 |
92 | let whole = ''
93 |
94 | for await (const delta of responseToAsyncIterator(res)) {
95 | whole += delta
96 | const json = splitReasoningPart(whole)[1]
97 | if (json)
98 | yield parse(json, Allow.ARR) as string[]
99 | }
100 | }
101 |
102 | @retry(3)
103 | async fetchTranslation(input: string) {
104 | const res = await fetch(`/api/translate?text=${encodeURIComponent(input)}`)
105 | if (!res.ok) throw new Error(await res.text())
106 | return res.text()
107 | }
108 |
109 | @retry(3)
110 | async fetchModeration(input: string) {
111 | const res = await fetch('/api/moderate', { method: 'POST', body: input })
112 | if (!res.ok) throw new Error(await res.text())
113 | return await res.json() as { flags: string[], category_scores: Record }
114 | }
115 | }
116 |
117 | const api = new API()
118 |
119 | export const iterateTitle = api.iterateTitle.bind(api)
120 | export const fetchTranslation = api.fetchTranslation.bind(api)
121 | export const fetchModeration = api.fetchModeration.bind(api)
122 | export const iterateSuggestion = api.iterateSuggestion.bind(api)
123 |
--------------------------------------------------------------------------------
/src/components/ChatInput.tsx:
--------------------------------------------------------------------------------
1 | import type { Plugin } from 'turndown'
2 |
3 | import { createEffect, Match, Show, Switch } from 'solid-js'
4 |
5 | import { useChat } from '@/context/ChatContext'
6 |
7 | import IconClear from './icons/Clear'
8 |
9 | export default () => {
10 | const { inputRef, setInputRef, inputValue, setInputValue, handleSubmit, recording, systemRoleEditing, messageList, clear, resetTextInputHeight } = useChat()
11 |
12 | const handleKeydown = (e: KeyboardEvent) => {
13 | if (e.isComposing || e.shiftKey)
14 | return
15 |
16 | if (e.key === 'Enter') {
17 | e.preventDefault()
18 | inputValue() && handleSubmit()
19 | }
20 | }
21 |
22 | // Keep the native textarea value in sync when `inputValue` changes (e.g. selecting a suggestion)
23 | createEffect(() => {
24 | const el = inputRef()
25 | if (!el) return
26 | el.value = inputValue()
27 | resetTextInputHeight()
28 | })
29 |
30 | return (
31 |
32 |
110 | )
111 | }
112 |
--------------------------------------------------------------------------------
/astro.config.mjs:
--------------------------------------------------------------------------------
1 | import netlify from '@astrojs/netlify'
2 | import node from '@astrojs/node'
3 | import solidJs from '@astrojs/solid-js'
4 | import svelte, { vitePreprocess } from '@astrojs/svelte'
5 | import vercel from '@astrojs/vercel'
6 | import AstroPWA from '@vite-pwa/astro'
7 | import { defineConfig, envField } from 'astro/config'
8 | import unocss from 'unocss/astro'
9 | import devtoolsJson from 'vite-plugin-devtools-json'
10 |
11 | import disableBlocks from './plugins/disableBlocks'
12 |
13 | const envAdapter = () => {
14 | switch (process.env.OUTPUT) {
15 | case 'vercel': return vercel()
16 | case 'netlify': return netlify()
17 | default: return node({ mode: 'standalone' })
18 | }
19 | }
20 |
21 | // https://astro.build/config
22 | export default defineConfig({
23 | env: {
24 | schema: {
25 | PUBLIC_DEFAULT_MODEL: envField.string({ context: 'client', access: 'public', default: 'gpt-4o-mini' }),
26 | PUBLIC_MIN_MESSAGES: envField.number({ context: 'client', access: 'public', default: 3 }),
27 | PUBLIC_MAX_TOKENS: envField.number({ context: 'client', access: 'public', default: 3000 }),
28 | PUBLIC_MODERATION_INTERVAL: envField.number({ context: 'client', access: 'public', default: 2000 }),
29 | PUBLIC_IFRAME_URL: envField.string({ context: 'client', access: 'public', optional: true }),
30 | PUBLIC_PROMPLATE_DEMO_BASE_URL: envField.string({ context: 'client', access: 'public', default: 'https://demo.promplate.dev' }),
31 | PUBLIC_RIGHT_ALIGN_MY_MSG: envField.boolean({ context: 'client', access: 'public', default: false }),
32 | HEAD_SCRIPTS: envField.string({ context: 'client', access: 'public', optional: true }),
33 | UNDICI_UA: envField.string({ context: 'server', access: 'secret', optional: true }),
34 | OPENAI_API_KEY: envField.string({ context: 'server', access: 'secret', optional: true }),
35 | SUGGEST_MODEL: envField.string({ context: 'client', access: 'public', default: 'Qwen/Qwen2.5-7B-Instruct' }),
36 | TITLE_GEN_MODEL: envField.string({ context: 'server', access: 'secret', optional: true }),
37 | TITLE_GEN_JSON_MODE: envField.boolean({ context: 'server', access: 'secret', default: true }),
38 | OPENAI_API_MODEL: envField.string({ context: 'server', access: 'secret' }),
39 | OPENAI_BASE_URL: envField.string({ context: 'server', access: 'secret', optional: true }),
40 | OPENAI_API_BASE_URL: envField.string({ context: 'server', access: 'secret', default: 'https://api.openai.com' }),
41 | TRANSCRIPT_TARGET_LANG: envField.string({ context: 'server', access: 'secret', default: 'zh' }),
42 | TRANSCRIPT_PROMPT: envField.string({ context: 'server', access: 'secret', optional: true }),
43 | DEEPL_AUTH_TOKEN: envField.string({ context: 'server', access: 'secret', optional: true }),
44 | DEEPL_API_HOST: envField.string({ context: 'server', access: 'secret', optional: true }),
45 | TRANSLATE_PROVIDER: envField.enum({ values: ['deepl', 'cf'], context: 'server', access: 'secret', default: 'deepl' }),
46 | TRANSLATE_TARGET_LANG: envField.string({ context: 'server', access: 'secret', optional: true }),
47 | CF_ACCOUNT_ID: envField.string({ context: 'server', access: 'secret', optional: true }),
48 | CF_API_TOKEN: envField.string({ context: 'server', access: 'secret', optional: true }),
49 | TUTORIAL_MD_URL: envField.string({ context: 'server', access: 'public', optional: true }),
50 | PUBLIC_CL100K_BASE_JSON_URL: envField.string({ context: 'client', access: 'public', default: '/cl100k_base.json' }),
51 | PUBLIC_TIKTOKEN_BG_WASM_URL: envField.string({ context: 'client', access: 'public', default: '/tiktoken_bg.wasm' }),
52 | },
53 | },
54 | integrations: [
55 | unocss({ injectReset: true }),
56 | solidJs(),
57 | AstroPWA({
58 | registerType: 'autoUpdate',
59 | injectRegister: 'inline',
60 | workbox: {
61 | maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
62 | },
63 | manifest: {
64 | id: '/',
65 | name: 'Endless Chat',
66 | short_name: 'Endless Chat',
67 | description: 'Chat for free with AI chatbot',
68 | theme_color: '#212129',
69 | background_color: '#212129',
70 | icons: [
71 | { sizes: '150x150', type: 'image/svg', src: 'icon.svg' },
72 | { sizes: '512x512', type: 'image/png', src: 'pwa.png' },
73 | { sizes: '512x512', type: 'image/png', src: 'pwa.png', purpose: 'maskable' },
74 | ],
75 | },
76 | client: {
77 | installPrompt: true,
78 | periodicSyncForUpdates: 20,
79 | },
80 | devOptions: {
81 | enabled: true,
82 | },
83 | }),
84 | svelte({ preprocess: vitePreprocess() }),
85 | ],
86 | output: 'server',
87 | adapter: envAdapter(),
88 | vite: {
89 | plugins: [(process.env.OUTPUT === 'vercel' || process.env.OUTPUT === 'netlify') && disableBlocks(), devtoolsJson()],
90 | build: {
91 | sourcemap: true,
92 | },
93 | },
94 | })
95 |
--------------------------------------------------------------------------------
/src/components/Translator.svelte:
--------------------------------------------------------------------------------
1 |
51 |
52 |
53 |
57 |
58 |
59 |
60 |
86 |
87 |
88 |
89 | {#each parsed as { translated }}
90 | {#if translated}
91 | {translated}
92 | {/if}
93 | {:else}
94 | translated text will display here
95 | {/each}
96 |
97 |
109 |
110 |
111 |
112 |
113 |
125 |
--------------------------------------------------------------------------------
/src/components/MessageItem.tsx:
--------------------------------------------------------------------------------
1 | import type { Accessor } from 'solid-js'
2 |
3 | import { writeClipboard } from '@solid-primitives/clipboard'
4 | import { createEventListener } from '@solid-primitives/event-listener'
5 | import { debounce } from '@solid-primitives/scheduled'
6 | import { PUBLIC_RIGHT_ALIGN_MY_MSG } from 'astro:env/client'
7 | import MarkdownIt from 'markdown-it'
8 | import mdHighlight from 'markdown-it-highlightjs'
9 | // @ts-expect-error missing types
10 | import mdKatex from 'markdown-it-katex'
11 | import { createMemo, createSignal, Index, Show } from 'solid-js'
12 |
13 | import type { ChatMessage } from '@/types'
14 |
15 | import { splitReasoningPart } from '@/utils/deepseek'
16 |
17 | import IconRefresh from './icons/Refresh'
18 |
19 | interface Props {
20 | role: ChatMessage['role']
21 | message: Accessor | string
22 | incomplete?: boolean
23 | showRetry?: Accessor
24 | onRetry?: () => void
25 | }
26 |
27 | const alignRightMine = PUBLIC_RIGHT_ALIGN_MY_MSG
28 |
29 | const md = MarkdownIt({ linkify: true, breaks: true }).use(mdKatex).use(mdHighlight)
30 |
31 | const fence = md.renderer.rules.fence!
32 |
33 | export default ({ role, message, showRetry, onRetry, incomplete = false }: Props) => {
34 | const roleClass = {
35 | system: '',
36 | user: 'bg-$c-fg-30',
37 | assistant: 'bg-emerald-600/50 dark:bg-emerald-300 sm:(bg-gradient-to-br from-cyan-200 to-green-200)',
38 | }
39 | const [copied, setCopied] = createSignal(false)
40 |
41 | // Use debounce to reset copied state after 1000ms
42 | const debouncedResetCopied = debounce(() => setCopied(false), 1000)
43 |
44 | const copy = async(element: Element) => {
45 | setCopied(true)
46 |
47 | try {
48 | await writeClipboard([
49 | new ClipboardItem({ 'text/plain': element.textContent!, 'text/html': element.outerHTML }),
50 | ])
51 | } catch {
52 | writeClipboard(element.textContent!)
53 | }
54 |
55 | debouncedResetCopied()
56 | }
57 |
58 | let htmlContainer!: HTMLDivElement
59 |
60 | createEventListener(() => htmlContainer, 'click', ({ target: el }: MouseEvent) => {
61 | if (el instanceof HTMLButtonElement && el.matches('button.gpt-copy-btn')) {
62 | const pre = el.nextElementSibling!
63 | pre.textContent && copy(pre)
64 | }
65 | })
66 |
67 | const result = createMemo(() => splitReasoningPart(typeof message === 'function' ? message() : message))
68 | const reasoningContent = () => result()[0]
69 | const content = createMemo(() => incomplete ? heuristicPatch(result()[1]) : result()[1])
70 |
71 | function heuristicPatch(markdown: string) {
72 | const lastNewlineIndex = markdown.lastIndexOf('\n')
73 | let rest: string, lastLine: string
74 | if (lastNewlineIndex === -1) {
75 | rest = ''
76 | lastLine = markdown
77 | } else {
78 | rest = markdown.slice(0, lastNewlineIndex)
79 | lastLine = markdown.slice(lastNewlineIndex + 1)
80 | }
81 | if (!lastLine.trim() || (lastLine.trimStart().startsWith('``') && lastLine.trimStart().length < 20) || /^([*+-])\1*$/.test(lastLine.trim())) {
82 | return rest
83 | } else if ((lastLine.match(/`/g)?.length || 0) % 2 !== 0 && !lastLine.includes('\\`')) {
84 | return lastLine.endsWith('`') ? `${rest}\n${lastLine.slice(0, -1)}` : `${rest}\n${lastLine}\``
85 | } else if ((lastLine.replace(/`[^`]*`/g, '').match(/\*\*/g)?.length || 0) % 2 !== 0 && !lastLine.includes('\\*')) {
86 | if (lastLine.endsWith('**'))
87 | return `${rest}\n${lastLine.slice(0, -2)}`
88 | else
89 | return `${rest}\n${lastLine}${'*'.repeat(2 - Number(lastLine.endsWith('*')))}`
90 | } else if ((lastLine.match(/\*/g)?.length || 0) % 2 !== 0 && lastLine.endsWith('*') && !lastLine.includes('\\*')) {
91 | return `${rest}\n${lastLine.slice(0, -1)}`
92 | } else {
93 | return `${rest}\n${lastLine}`
94 | }
95 | }
96 |
97 | const htmlString = () => {
98 | md.renderer.rules.fence = (...args) => {
99 | const rawCode = fence(...args)
100 |
101 | return `
102 |
105 | ${rawCode}
106 |
`
107 | }
108 |
109 | return md.render(content())
110 | }
111 |
112 | return (
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 | {line => (
122 | {line()}
123 | )}
124 |
125 |
126 |
127 |
128 |
129 |
130 | {showRetry?.() && onRetry && (
131 |
132 |
133 |
134 | 重新生成
135 |
136 |
137 | )}
138 |
139 |
140 | )
141 | }
142 |
--------------------------------------------------------------------------------
/src/components/Generator.tsx:
--------------------------------------------------------------------------------
1 | import type { Setter } from 'solid-js'
2 |
3 | import { createEffect, Match, onMount, Switch } from 'solid-js'
4 | import { Toaster } from 'solid-toast'
5 |
6 | import { useChat } from '@/context/ChatContext'
7 | import { trackEvent } from '@/utils/track'
8 |
9 | import ChatInput from './ChatInput'
10 | import Inview from './Inview'
11 | import MessageList from './MessageList'
12 | import ParallaxBackground from './ParallaxBackground'
13 | import StickToBottomButton from './StickToBottomButton'
14 | import Suggestions from './Suggestions'
15 | import SystemRoleSettings from './SystemRoleSettings'
16 | import Tips from './Tips'
17 | import TokenCounter from './TokenCounter'
18 |
19 | export default () => {
20 | const { inputRef, messageList, currentAssistantMessage, streaming, inputValue, currentSystemRoleSettings, systemRoleEditing, suggestions, suggestionFeatureOn, isStick, mounted, inview, currentError, setSystemRoleEditing, setStick, setCurrentSystemRoleSettings, setInview, clear, deleteLastMessage, stopStreamFetch, resetTextInputHeight } = useChat()
21 | let rootRef!: HTMLDivElement
22 | let footer: HTMLElement
23 |
24 | const isHigher = () => {
25 | const distanceToBottom = footer.offsetTop - window.innerHeight
26 | const currentScrollHeight = window.scrollY
27 | return distanceToBottom > currentScrollHeight
28 | }
29 |
30 | const toBottom = (behavior: 'smooth' | 'instant') => {
31 | const distanceToBottom = footer.offsetTop - window.innerHeight
32 | const currentScrollHeight = window.scrollY
33 | if (distanceToBottom > currentScrollHeight)
34 | window.scrollTo({ top: distanceToBottom, behavior })
35 | }
36 |
37 | const smoothToBottom = () => toBottom('smooth')
38 | const instantToBottom = () => toBottom('instant')
39 |
40 | onMount(() => {
41 | // This effect should run only after the footer is mounted
42 | createEffect(() => {
43 | isStick() && (streaming() ? instantToBottom() : smoothToBottom())
44 | })
45 |
46 | createEffect(() => {
47 | // when a new user message is added, scroll to bottom
48 | if (messageList().at(-1)?.role === 'user') {
49 | smoothToBottom()
50 | }
51 | })
52 |
53 | createEffect(() => {
54 | // when error occurs in stick-to-bottom mode, scroll to bottom
55 | if (currentError() && isStick()) {
56 | instantToBottom()
57 | }
58 | })
59 | // input ref is bound inside ChatInput via setInputRef
60 |
61 | footer = document.querySelector('footer')!
62 |
63 | let lastPosition = window.scrollY
64 |
65 | window.addEventListener('scroll', () => {
66 | const nowPosition = window.scrollY
67 | if (nowPosition < lastPosition && isHigher()) setStick(false)
68 | lastPosition = nowPosition
69 | })
70 |
71 | window.addEventListener('resize', () => {
72 | resetTextInputHeight()
73 | requestAnimationFrame(() => {
74 | if (isHigher() && isStick()) instantToBottom()
75 | lastPosition = window.scrollY
76 | })
77 | })
78 |
79 | window.addEventListener('keydown', (event) => {
80 | if ((event.target as HTMLElement).nodeName !== 'TEXTAREA') {
81 | if (event.code === 'Slash') {
82 | event.preventDefault()
83 | const el = inputRef()
84 | el?.focus()
85 | } else if (event.code === 'KeyB') {
86 | trackEvent('stick-to-bottom', { stick: isStick() ? 'switch off' : 'switch on', trigger: 'key' })
87 | setStick(!isStick())
88 | }
89 | }
90 | if (event.altKey && event.code === 'KeyC') {
91 | clear()
92 | }
93 | if (event.altKey && event.code === 'Backspace') {
94 | deleteLastMessage()
95 | }
96 | }, false)
97 |
98 | createEffect(() => {
99 | // when message list changes in stick mode and streaming, scroll to bottom instantly
100 | if (isStick() && streaming()) {
101 | instantToBottom()
102 | }
103 | currentAssistantMessage() // retrigger when changed
104 | })
105 | })
106 |
107 | return (
108 |
109 |
110 | messageList().length === 0}
112 | systemRoleEditing={systemRoleEditing}
113 | setSystemRoleEditing={setSystemRoleEditing}
114 | currentSystemRoleSettings={currentSystemRoleSettings}
115 | setCurrentSystemRoleSettings={setCurrentSystemRoleSettings as Setter}
116 | />
117 |
118 |
119 |
120 |
121 |
122 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 | 加载中
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 |
等待响应中
148 |
Stop
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 |
163 |
164 |
165 |
166 | )
167 | }
168 |
--------------------------------------------------------------------------------
/src/utils/ripple.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Ripple effect action for Svelte 5
3 | *
4 | * This is a custom implementation to fix lifecycle_outside_component error
5 | * that occurs with the original svelte-ripple-action v2.0.0 library.
6 | *
7 | * The Problem:
8 | * - Original library (https://github.com/posandu/svelte-ripple-action) uses onMount() internally
9 | * - Svelte 5's lifecycle hooks (onMount/$effect) can only be called during component initialization
10 | * - When ripple action is used with dynamic event handlers (e.g., onClick={() => {...}}),
11 | * the action re-executes outside component init, causing lifecycle_outside_component error
12 | *
13 | * The Fix:
14 | * - Removed onMount/$effect wrapper around initialization logic
15 | * - Execute setup directly in the action function (imperative, outside component lifecycle)
16 | * - Move event listener binding to action body, not inside onMount/$effect
17 | * - Keep cleanup logic in destroy() callback for proper teardown
18 | *
19 | * Key Difference:
20 | * Original: action() → onMount() → setup events
21 | * Fixed: action() → setup events directly
22 | *
23 | * This follows Svelte 5 best practice: actions run outside component context,
24 | * so they must not call lifecycle APIs.
25 | */
26 |
27 | import type { Action } from 'svelte/action'
28 |
29 | import 'svelte-ripple-action/ripple.css'
30 |
31 | interface RippleOptions {
32 | color?: string
33 | duration?: number
34 | maxRadius?: number
35 | center?: boolean
36 | disabled?: boolean
37 | }
38 |
39 | const INEVENTS = ['pointerdown']
40 | const OUTEVENTS = ['pointerup', 'pointerleave', 'pointercancel']
41 | const ATTR_NAME = 'svelte-ripple-effect-ready'
42 | const ATTR_CENTER_NAME = 'ripple-center'
43 |
44 | function findFurthestPoint(
45 | clickX: number,
46 | elementWidth: number,
47 | elementLeft: number,
48 | clickY: number,
49 | elementHeight: number,
50 | elementTop: number,
51 | ): number {
52 | const x = clickX - elementLeft
53 | const y = clickY - elementTop
54 | const distanceToRight = elementWidth - x
55 | const distanceToBottom = elementHeight - y
56 |
57 | return Math.sqrt(
58 | Math.max(x, distanceToRight) ** 2 + Math.max(y, distanceToBottom) ** 2,
59 | )
60 | }
61 |
62 | function addEvent(element: HTMLElement, event: string, handler: EventListener) {
63 | element.addEventListener(event, handler, { passive: true })
64 | }
65 |
66 | function removeEvent(element: HTMLElement, event: string, handler: EventListener) {
67 | element.removeEventListener(event, handler)
68 | }
69 |
70 | export const ripple: Action = (el, options: RippleOptions = {}) => {
71 | let maximumRadius = 0
72 |
73 | const addClassIfMissing = () => {
74 | if (!el.getAttribute(ATTR_NAME)) {
75 | el.setAttribute(ATTR_NAME, '')
76 | }
77 |
78 | if (options?.center) {
79 | el.setAttribute(ATTR_CENTER_NAME, '')
80 | } else {
81 | el.removeAttribute(ATTR_CENTER_NAME)
82 | }
83 | }
84 |
85 | const setOptions = (options: RippleOptions | undefined) => {
86 | if (options?.color) {
87 | el.style.setProperty('--ripple-color', options.color)
88 | }
89 |
90 | if (options?.duration) {
91 | el.style.setProperty('--ripple-duration', `${options.duration}s`)
92 | }
93 |
94 | if (options?.maxRadius) {
95 | maximumRadius = options.maxRadius
96 | }
97 | }
98 |
99 | const createRipple = (e: Event) => {
100 | const pointerEvent = e as PointerEvent
101 | if (options?.disabled) return
102 |
103 | pointerEvent.stopPropagation()
104 |
105 | addClassIfMissing()
106 |
107 | const rect = el.getBoundingClientRect()
108 | const radius = findFurthestPoint(
109 | pointerEvent.clientX,
110 | el.offsetWidth,
111 | rect.left,
112 | pointerEvent.clientY,
113 | el.offsetHeight,
114 | rect.top,
115 | )
116 |
117 | const ripple = document.createElement('div')
118 | ripple.classList.add('ripple')
119 |
120 | let size = radius * 2
121 | let top = pointerEvent.clientY - rect.top - radius
122 | let left = pointerEvent.clientX - rect.left - radius
123 |
124 | if (maximumRadius && size > maximumRadius) {
125 | size = maximumRadius * 2
126 | top = pointerEvent.clientY - rect.top - maximumRadius
127 | left = pointerEvent.clientX - rect.left - maximumRadius
128 | }
129 |
130 | ripple.style.left = `${left}px`
131 | ripple.style.top = `${top}px`
132 |
133 | ripple.style.width = ripple.style.height = `${size}px`
134 |
135 | el.appendChild(ripple)
136 |
137 | const removeRipple = () => {
138 | const timeOutDuration = options?.duration
139 | ? options.duration * 1000
140 | : 1000
141 |
142 | if (ripple !== null) {
143 | setTimeout(() => {
144 | ripple.style.opacity = '0'
145 | }, timeOutDuration / 4)
146 |
147 | setTimeout(() => {
148 | ripple.remove()
149 | }, timeOutDuration)
150 | }
151 | }
152 |
153 | OUTEVENTS.forEach((event) => {
154 | addEvent(el, event, removeRipple)
155 | })
156 | }
157 |
158 | // Set up imperatively. Do NOT call Svelte lifecycle/runewrite APIs here —
159 | // actions run outside of component initialisation and calling $effect or
160 | // other runes will throw `lifecycle_outside_component`.
161 | addClassIfMissing()
162 | setOptions(options)
163 |
164 | INEVENTS.forEach((event) => {
165 | addEvent(el, event, createRipple)
166 | })
167 |
168 | const destroy = () => {
169 | INEVENTS.forEach((event) => {
170 | removeEvent(el, event, createRipple)
171 | })
172 | }
173 |
174 | return {
175 | update(newOptions: RippleOptions = {}) {
176 | options = newOptions
177 | setOptions(newOptions)
178 | },
179 | destroy() {
180 | destroy()
181 | },
182 | }
183 | }
184 |
185 | /**
186 | * =============================================================================
187 | * DETAILED CODE CHANGES vs ORIGINAL LIBRARY
188 | * =============================================================================
189 | *
190 | * ORIGINAL (node_modules/.../ripple.js:6-9):
191 | * import { onMount } from "svelte";
192 | * function ripple(el, options) {
193 | * onMount(() => { // ← This wrapper is REMOVED
194 | * addClassIfMissing();
195 | * setOptions(options);
196 | * INEVENTS.forEach((event) => {
197 | * addEvent(el, event, createRipple);
198 | * });
199 | * return () => { // Cleanup inside onMount - MOVED
200 | * INEVENTS.forEach((event) => {
201 | * removeEvent(el, event, createRipple);
202 | * });
203 | * };
204 | * });
205 | * }
206 | *
207 | * FIXED (this file, line 70-87):
208 | * export const ripple: Action = (el, options) => {
209 | * // Set up imperatively. Do NOT call Svelte lifecycle/runes APIs here —
210 | * addClassIfMissing() // ← Direct call, NO onMount wrapper
211 | * setOptions(options) // ← Direct call, NO onMount wrapper
212 | * INEVENTS.forEach((event) => { // ← Direct call, NO onMount wrapper
213 | * addEvent(el, event, createRipple)
214 | * })
215 | * const destroy = () => { // ← Moved from inside onMount to here
216 | * INEVENTS.forEach((event) => {
217 | * removeEvent(el, event, createRipple)
218 | * })
219 | * }
220 | * return {
221 | * update(newOptions) { ... },
222 | * destroy() { destroy() } // ← Cleanup in return object
223 | * }
224 | * }
225 | *
226 | * =============================================================================
227 | * WHY THE ORIGINAL FAILS
228 | * =============================================================================
229 | *
230 | * With