├── .env.development
├── .eslintrc.cjs
├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
├── dependabot.yml
└── workflows
│ ├── build.yml
│ └── release.yml
├── .gitignore
├── LICENSE
├── Makefile
├── README.md
├── index.html
├── package.json
├── postcss.config.js
├── src
├── App.css
├── App.tsx
├── api
│ ├── restful
│ │ ├── api.ts
│ │ ├── model.ts
│ │ └── util.ts
│ └── sse
│ │ ├── event.ts
│ │ ├── server-ability.ts
│ │ └── sse.tsx
├── assets
│ ├── bg
│ │ ├── clement
│ │ │ └── December 21 Night.jpg
│ │ ├── no-copyright
│ │ │ ├── 84974784818869.5d6bfdf4e8260.png
│ │ │ └── 959e3384818869.5d6bfdf2b5e1b.png
│ │ ├── noise-lg.svg
│ │ ├── noise.svg
│ │ └── wikiart-public-domain
│ │ │ ├── Composition VIII 1923.jpg
│ │ │ └── simultaneous-counter-composition-1930.jpg
│ ├── font
│ │ └── Borel
│ │ │ ├── Borel-Regular.ttf
│ │ │ └── OFL.txt
│ └── svg
│ │ ├── Google_2015_logo.svg
│ │ ├── gemini.ico
│ │ ├── ios-spinner.svg
│ │ └── openai-icon.svg
├── auth
│ └── auth.tsx
├── config.ts
├── data-structure
│ ├── client-option.tsx
│ ├── message.tsx
│ └── provider-api-refrence
│ │ ├── chat-gpt.ts
│ │ ├── elevenlabs-tts.ts
│ │ ├── gemini.ts
│ │ ├── google-stt.ts
│ │ ├── google-tts.ts
│ │ ├── llm.ts
│ │ ├── types.ts
│ │ └── whisper.ts
├── data
│ ├── chat.ts
│ ├── google-sst-language.ts
│ ├── google-tts-language.ts
│ └── prompt.ts
├── error.tsx
├── experiment
│ ├── experiment.tsx
│ ├── subscribe-test.tsx
│ └── valtio.tsx
├── favicon.png
├── home
│ ├── chat-window
│ │ ├── attached
│ │ │ ├── attached-item.tsx
│ │ │ └── attached-preview.tsx
│ │ ├── chat-window.tsx
│ │ ├── compnent
│ │ │ ├── audio.tsx
│ │ │ ├── drop-down-menu.tsx
│ │ │ ├── error-boundary.tsx
│ │ │ ├── my-error.tsx
│ │ │ ├── my-text.tsx
│ │ │ ├── prompt-attached-button.tsx
│ │ │ ├── theme.ts
│ │ │ └── widget
│ │ │ │ ├── highlightjs-plugins
│ │ │ │ ├── copy-button-plugin.css
│ │ │ │ ├── copy-button-plugin.tsx
│ │ │ │ └── language-label-plugin.tsx
│ │ │ │ └── icon.tsx
│ │ ├── message-list
│ │ │ ├── menu.tsx
│ │ │ ├── message-list.tsx
│ │ │ └── row.tsx
│ │ ├── prompt-attached.tsx
│ │ ├── prompt
│ │ │ ├── prompt-editor-item.tsx
│ │ │ ├── prompt-editor.tsx
│ │ │ ├── prompt-item.tsx
│ │ │ └── prompt-list.tsx
│ │ ├── recorder.tsx
│ │ └── text-area.tsx
│ ├── home.tsx
│ └── panel
│ │ ├── chat-list
│ │ ├── avatar.tsx
│ │ ├── chat-component.tsx
│ │ ├── chat-list.tsx
│ │ ├── draggable-chat.tsx
│ │ └── preview.tsx
│ │ ├── current
│ │ ├── current.tsx
│ │ └── other-setting.tsx
│ │ ├── global
│ │ ├── global.tsx
│ │ ├── other-setting.tsx
│ │ └── shortcuts-setting.tsx
│ │ ├── panel.tsx
│ │ └── shared
│ │ ├── llm
│ │ ├── chat-gpt.tsx
│ │ ├── gemini.tsx
│ │ └── llm.tsx
│ │ ├── select-box-or-not-available.tsx
│ │ ├── stt
│ │ ├── google-stt.tsx
│ │ ├── stt.tsx
│ │ └── whisper.tsx
│ │ ├── tts
│ │ ├── elevenlabs.tsx
│ │ ├── google-tts.tsx
│ │ └── tts.tsx
│ │ └── widget
│ │ ├── button.tsx
│ │ ├── discrete-range.tsx
│ │ ├── logo.tsx
│ │ ├── select-box.tsx
│ │ ├── separator.tsx
│ │ ├── slider-range.tsx
│ │ └── switch.tsx
├── index.css
├── main.tsx
├── other.ts
├── prose.css
├── shared-types.ts
├── state
│ ├── app-state.ts
│ ├── control-state.ts
│ ├── dangerous.ts
│ ├── db.ts
│ ├── layout-state.ts
│ ├── message-state.ts
│ ├── migration.ts
│ ├── network-state.ts
│ ├── promt-state.ts
│ └── shortcuts.ts
├── util
│ ├── enhanced-recorder.ts
│ └── util.tsx
├── vite-env.d.ts
├── wallpaper
│ ├── art.tsx
│ ├── granim-wallpaper.tsx
│ └── wallpaper.tsx
├── window-listeners.tsx
└── worker
│ ├── subscribe-audio-duration-update.tsx
│ ├── subscribe-sending-message.tsx
│ ├── timeout-content-detection.tsx
│ └── workers.tsx
├── tailwind.config.js
├── tsconfig.json
├── tsconfig.node.json
├── vite.config.ts
└── yarn.lock
/.env.development:
--------------------------------------------------------------------------------
1 | VITE_REACT_APP_ENDPOINT=http://localhost:8000/api
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: {browser: true, es2020: true},
4 | extends: [
5 | 'eslint:recommended',
6 | 'plugin:@typescript-eslint/recommended',
7 | 'plugin:react-hooks/recommended',
8 | "plugin:valtio/recommended"
9 | ],
10 | ignorePatterns: ['dist', '.eslintrc.cjs'],
11 | parser: '@typescript-eslint/parser',
12 | plugins: ['react-refresh'],
13 | rules: {
14 | 'react-refresh/only-export-components': [
15 | 'warn',
16 | {allowConstantExport: true},
17 | ],
18 | },
19 | }
20 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Desktop (please complete the following information):**
27 | - OS: [e.g. iOS]
28 | - Browser [e.g. chrome, safari]
29 | - Version [e.g. 22]
30 |
31 | **Smartphone (please complete the following information):**
32 | - Device: [e.g. iPhone6]
33 | - OS: [e.g. iOS8.1]
34 | - Browser [e.g. stock browser, safari]
35 | - Version [e.g. 22]
36 |
37 | **Additional context**
38 | Add any other context about the problem here.
39 |
40 | **Error message and stack trace**
41 | ```
42 | Version
43 |
44 | Not Available
45 |
46 | Status
47 |
48 | None
49 |
50 | Error Message
51 |
52 | Cannot read properties of undefined (reading 'llm')
53 |
54 | Stack trace
55 |
56 | TypeError: Cannot read properties of undefined (reading 'llm')
57 | at http://localhost:5173/src/home/chat-window/message-list.tsx:43:31
58 | at commitHookEffectListMount (http://localhost:5173/node_modules/.vite/deps/chunk-GYWC62UC.js?v=78cad7ae:16904:34)
59 | at invokePassiveEffectMountInDEV (http://localhost:5173/node_modules/.vite/deps/chunk-GYWC62UC.js?v=78cad7ae:18320:19)
60 | at invokeEffectsInDev (http://localhost:5173/node_modules/.vite/deps/chunk-GYWC62UC.js?v=78cad7ae:19697:19)
61 | at commitDoubleInvokeEffectsInDEV (http://localhost:5173/node_modules/.vite/deps/chunk-GYWC62UC.js?v=78cad7ae:19682:15)
62 | at flushPassiveEffectsImpl (http://localhost:5173/node_modules/.vite/deps/chunk-GYWC62UC.js?v=78cad7ae:19499:13)
63 | at flushPassiveEffects (http://localhost:5173/node_modules/.vite/deps/chunk-GYWC62UC.js?v=78cad7ae:19443:22)
64 | at http://localhost:5173/node_modules/.vite/deps/chunk-GYWC62UC.js?v=78cad7ae:19324:17
65 | at workLoop (http://localhost:5173/node_modules/.vite/deps/chunk-GYWC62UC.js?v=78cad7ae:197:42)
66 | at flushWork (http://localhost:5173/node_modules/.vite/deps/chunk-GYWC62UC.js?v=78cad7ae:176:22)
67 | ```
68 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
5 |
6 | version: 2
7 | updates:
8 | - package-ecosystem: "yarn" # See documentation for possible values
9 | directory: "/" # Location of package manifests
10 | schedule:
11 | interval: "weekly"
12 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | # This workflow will build a golang project
2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go
3 |
4 | name: build
5 |
6 | on:
7 | push:
8 | branches: [ "main" ]
9 | pull_request:
10 | branches: [ "main" ]
11 | workflow_dispatch:
12 |
13 | jobs:
14 | build:
15 | name: Build
16 | runs-on: ubuntu-latest
17 | steps:
18 | - uses: actions/checkout@v3
19 |
20 | - name: Set Node.js
21 | uses: actions/setup-node@v3
22 | with:
23 | node-version: 20.x
24 |
25 | - name: Run install
26 | uses: Borales/actions-yarn@v4.2.0
27 | with:
28 | cmd: install # will run `yarn install` command
29 | - name: Build production bundle
30 | uses: Borales/actions-yarn@v4.2.0
31 | with:
32 | cmd: build
33 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | # This workflow will build a golang project
2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go
3 |
4 | name: release
5 |
6 | on:
7 | release:
8 | types: [ published ]
9 | workflow_dispatch:
10 |
11 | permissions:
12 | contents: write
13 |
14 | jobs:
15 | build:
16 | name: binaries
17 | runs-on: ubuntu-latest
18 | steps:
19 | - uses: actions/checkout@v3
20 |
21 | - name: Set Node.js
22 | uses: actions/setup-node@v3
23 | with:
24 | node-version: 20.x
25 |
26 | - name: release
27 | run: make release
28 |
29 | - name: Upload the artifacts
30 | uses: skx/github-action-publish-binaries@release-2.0
31 | env:
32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
33 | with:
34 | args: 'build/*.tar.gz build/*.zip'
35 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | dist-ssr
4 | *.local
5 |
6 | # Editor directories and files
7 | .vscode/*
8 | !.vscode/extensions.json
9 | .idea
10 | .DS_Store
11 | *.suo
12 | *.ntvs*
13 | *.njsproj
14 | *.sln
15 | *.sw?
16 |
17 | node_modules/
18 | npm-debug.log
19 | yarn-error.log
20 |
21 | build/
22 | dist/
23 | chunks-report.html
24 |
25 | .env.local
26 | .env.development.local
27 | .env.test.local
28 | .env.production.local
29 |
30 | .vscode/
31 | .idea/
32 | *.code-workspace
33 |
34 | *.swp
35 | *.tmp
36 |
37 | # Logs
38 | logs
39 | *.log
40 | npm-debug.log*
41 | yarn-debug.log*
42 | yarn-error.log*
43 | pnpm-debug.log*
44 | lerna-debug.log*
45 |
46 |
47 | coverage/
48 |
49 | .npmrc
50 | .yarnrc
51 |
52 | *.generated.*
53 |
54 | .cache/
55 |
56 | *.bak
57 |
58 | Thumbs.db
59 |
60 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 proxoar
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 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: build
2 | build:
3 | yarn install && yarn build
4 |
5 | # build and copy dist to proxoar/talk
6 | .PHONY: copy
7 | copy:
8 | make build
9 | rm -rf ../talk/web/html && cp -r ./build/dist ../talk/web/html
10 |
11 | .PHONY: run
12 | run:
13 | yarn dev
14 |
15 | .PHONY: release
16 | release:
17 | make build
18 | cd build; \
19 | tar -czf dist.tar.gz dist; \
20 | zip -r dist.zip dist;
21 |
22 | .PHONY: clean
23 | clean:
24 | echo "Cleaning up..."
25 | rm -rf build
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # talk-web
2 |
3 | Talk-web, the web-based interface for [proxoar/talk](https://github.com/proxoar/talk), is a single-page application designed to
4 | emulate the user experience of a native app.
5 |
6 | For more info, refer to [proxoar/talk](https://github.com/proxoar/talk)
7 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Talk
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "talk-vite",
3 | "private": true,
4 | "version": "2.0.3",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc && vite build",
9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
10 | "preview": "vite preview"
11 | },
12 | "dependencies": {
13 | "@headlessui/react": "^1.7.17",
14 | "@microsoft/fetch-event-source": "^2.0.1",
15 | "@tanstack/react-virtual": "^3.0.0-beta.60",
16 | "@types/highlight.js": "^10.1.0",
17 | "@types/markdown-it": "^13.0.2",
18 | "@types/markdown-it-emoji": "^2.0.2",
19 | "axios": "1.6.0",
20 | "crypto-js": "4.2.0",
21 | "date-fns": "^2.30.0",
22 | "downshift": "^8.1.0",
23 | "framer-motion": "^10.16.1",
24 | "granim": "^2.0.0",
25 | "highlight.js": "^11.8.0",
26 | "highlightjs-copy": "^1.0.5",
27 | "localforage": "^1.10.0",
28 | "lodash": "^4.17.21",
29 | "markdown-it": "^13.0.2",
30 | "markdown-it-emoji": "^2.0.2",
31 | "markdown-it-footnote": "^3.0.3",
32 | "markdown-it-link-attributes": "^4.0.1",
33 | "markdown-it-multimd-table": "^4.2.3",
34 | "markdown-it-sub": "^1.0.0",
35 | "markdown-it-sup": "^1.0.0",
36 | "markdown-it-task-lists": "^2.1.1",
37 | "react": "^18.2.0",
38 | "react-copy-to-clipboard": "^5.1.0",
39 | "react-countdown": "^2.3.5",
40 | "react-dnd": "^16.0.1",
41 | "react-dnd-html5-backend": "^16.0.1",
42 | "react-dom": "^18.2.0",
43 | "react-helmet-async": "^1.3.0",
44 | "react-icons": "^4.10.1",
45 | "react-nice-avatar": "^1.4.1",
46 | "react-router": "^6.15.0",
47 | "react-router-dom": "^6.15.0",
48 | "semver": "^7.5.4",
49 | "valtio": "^1.11.2",
50 | "wavesurfer.js": "^7.3.0"
51 | },
52 | "devDependencies": {
53 | "@tailwindcss/typography": "^0.5.9",
54 | "@types/crypto-js": "^4.1.1",
55 | "@types/gradient-string": "^1.1.2",
56 | "@types/granim": "^2.0.1",
57 | "@types/lodash": "^4.14.198",
58 | "@types/markdown-it-footnote": "^3.0.1",
59 | "@types/markdown-it-link-attributes": "^3.0.2",
60 | "@types/node": "^20.5.0",
61 | "@types/react": "^18.2.15",
62 | "@types/react-copy-to-clipboard": "^5.0.4",
63 | "@types/react-dom": "^18.2.7",
64 | "@types/react-helmet": "^6.1.6",
65 | "@types/react-virtualized": "^9.21.22",
66 | "@types/react-window": "^1.8.5",
67 | "@types/uuid": "^9.0.2",
68 | "@types/wavesurfer.js": "^6.0.6",
69 | "@typescript-eslint/eslint-plugin": "^6.0.0",
70 | "@typescript-eslint/parser": "^6.0.0",
71 | "@vitejs/plugin-react": "^4.0.4",
72 | "autoprefixer": "^10.4.15",
73 | "eslint": "^8.45.0",
74 | "eslint-plugin-react-hooks": "^4.6.0",
75 | "eslint-plugin-react-refresh": "^0.4.3",
76 | "eslint-plugin-valtio": "^0.6.2",
77 | "postcss": "8.4.32",
78 | "postcss-import": "^15.1.0",
79 | "rollup-plugin-visualizer": "^5.12.0",
80 | "tailwindcss": "^3.3.3",
81 | "typescript": "^5.2.2",
82 | "vite": "5.0.12",
83 | "vite-plugin-svgr": "^4.0.0"
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | 'postcss-import': {},
4 | tailwindcss: {},
5 | autoprefixer: {},
6 | },
7 | }
8 |
--------------------------------------------------------------------------------
/src/App.css:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codegenius2/Talk-web/17eaef99bc62a8259c47b81627a8da2629f9dbb8/src/App.css
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | export default function App() {
2 | return (
3 |
4 |
5 | )
6 | }
7 |
--------------------------------------------------------------------------------
/src/api/restful/api.ts:
--------------------------------------------------------------------------------
1 | import axios, {AxiosError} from "axios"
2 | import {ChatReq} from "./model.ts"
3 | import {generateHash} from "../../util/util.tsx"
4 | import {networkState} from "../../state/network-state.ts"
5 | import {appState, setLoggedIn} from "../../state/app-state.ts"
6 | import {APIEndpoint} from "../../config.ts"
7 |
8 | const axiosInstance = axios.create({
9 | baseURL: APIEndpoint(),
10 | timeout: 5000,
11 | })
12 |
13 | axiosInstance.interceptors.request.use((config) => {
14 | config.headers['Stream-ID'] = networkState.streamId
15 | if (!config.headers['Authorization']) {
16 | // do not override Authorization headerZ
17 | config.headers['Authorization'] = 'Bearer ' + appState.auth.passwordHash
18 | }
19 | return config
20 | })
21 |
22 | axiosInstance.interceptors.response.use(response => {
23 | return response
24 | },
25 | (error: AxiosError) => {
26 | if (error.response && error.response.status === 401) {
27 | setLoggedIn(false)
28 | console.info('Unauthorized', error.response)
29 | }
30 | return Promise.reject(error)
31 | }
32 | )
33 |
34 | export const postChat = (chat: ChatReq) => {
35 | return axiosInstance.post("chat", chat)
36 | }
37 |
38 | export const postAudioChat = (audio: Blob, fileName: string, chat: ChatReq) => {
39 | const formData = new FormData()
40 | formData.append('audio', audio, fileName)
41 | formData.append('chat', JSON.stringify(chat))
42 | return axiosInstance.postForm("audio-chat", formData)
43 | }
44 |
45 | export const login = (password?: string) => {
46 | return axiosInstance.get("health", {
47 | headers: password ? {
48 | 'Authorization': 'Bearer ' + generateHash(password)
49 | } : {}
50 | })
51 | }
52 |
--------------------------------------------------------------------------------
/src/api/restful/model.ts:
--------------------------------------------------------------------------------
1 | import {LLMMessage} from "../../shared-types.ts"
2 |
3 | export type ChatReq = {
4 | chatId: string // unique ID for every chat
5 | ticketId: string // A distinctive ID for each request, utilised by the client to associate messages.
6 | ms: LLMMessage[]
7 | talkOption: TalkOption
8 | }
9 |
10 | /**
11 | * When make a request to the Talk server, append a TalkOption to the request.
12 | * Each LLMOption, TTSOption, and SSTOption can only engage one provider at a time.
13 | * For instance, either `talkOption.llm.chatGPT` or `talkOption.llm.claude` should be set to `undefined`.
14 | */
15 | export type TalkOption = {
16 | toText: boolean, // transcribe user's speech to text, requires STTOption option
17 | toSpeech: boolean, // synthesize user's text to speech, requires TTSOption
18 | completion: boolean, // completion, requires messages or result of transcription, require LLMOption
19 | completionToSpeech: boolean, // synthesize result of completion to speech, requires TTSOption
20 | sttOption?: STTOption,
21 | ttsOption?: TTSOption,
22 | llmOption?: LLMOption
23 | }
24 |
25 | export type LLMOption = {
26 | chatGPT?: ChatGPTOption
27 | gemini?: GeminiOption
28 | }
29 |
30 | export type ChatGPTOption = {
31 | model: string
32 | maxTokens: number
33 | temperature: number
34 | topP: number
35 | presencePenalty: number
36 | frequencyPenalty: number
37 | }
38 |
39 | export type GeminiOption = {
40 | model: string
41 | // stopSequences: string[]
42 | maxOutputTokens: number
43 | temperature: number
44 | topP: number
45 | topK: number
46 | }
47 |
48 | export type STTOption = {
49 | whisper?: WhisperOption
50 | google?: GoogleSTTOption
51 | }
52 |
53 | export type WhisperOption = {
54 | model: string
55 | }
56 |
57 | export type GoogleSTTOption = {
58 | recognizer: string
59 | model?: string
60 | language?: string
61 | }
62 |
63 | export type TTSOption = {
64 | google?: GoogleTTSOption
65 | elevenlabs?: ElevenlabsTTSOption
66 | }
67 |
68 | export type GoogleTTSOption = {
69 | // if VoiceId is provided, LanguageCode and Gender will not be used
70 | voiceId?: string
71 | // if languageCode is undefined, server should return an error, it's users' responsibility to choose a language
72 | languageCode?: string
73 | /**
74 | * An unspecified gender.
75 | * In VoiceSelectionParams, this means that the client doesn't care which
76 | * gender the selected voice will have. In the Voice field of
77 | * ListVoicesResponse, this may mean that the voice doesn't fit any of the
78 | * other categories in this enum, or that the gender of the voice isn't known.
79 | * SsmlVoiceGender_SSML_VOICE_GENDER_UNSPECIFIED SsmlVoiceGender = 0
80 | * A male voice.
81 | * SsmlVoiceGender_MALE SsmlVoiceGender = 1
82 | * A female voice.
83 | * SsmlVoiceGender_FEMALE SsmlVoiceGender = 2
84 | * A gender-neutral voice. This voice is not yet supported.
85 | * SsmlVoiceGender_NEUTRAL SsmlVoiceGender = 3
86 | */
87 | gender?: GoogleTTSGender
88 | speakingRate: number
89 | pitch: number
90 | volumeGainDb: number
91 | }
92 |
93 | export type ElevenlabsTTSOption = {
94 | voiceId: string
95 | stability: number
96 | clarity: number
97 | }
98 |
99 | export enum GoogleTTSGender {
100 | unspecified = 0,
101 | male = 1,
102 | female = 2,
103 | neutral = 3
104 | }
105 |
--------------------------------------------------------------------------------
/src/api/restful/util.ts:
--------------------------------------------------------------------------------
1 | import {isAttached, Message} from "../../data-structure/message.tsx"
2 | import {LLMMessage} from "../../shared-types.ts"
3 |
4 | export const attachedMessages = (messages:Message[], maxAttached: number): LLMMessage[] => {
5 | if (maxAttached <= 0) {
6 | return []
7 | }
8 | const hist: LLMMessage[] = []
9 | for (let i = messages.length - 1; i >= 0; i--) {
10 | if (hist.length === maxAttached) {
11 | break
12 | }
13 | const m = messages[i]
14 | if (isAttached(m)) {
15 | hist.push({role: m.role, content: m.text})
16 | }
17 | }
18 | return hist.reverse()
19 | }
--------------------------------------------------------------------------------
/src/api/sse/event.ts:
--------------------------------------------------------------------------------
1 | import {Role} from "../../shared-types.ts"
2 |
3 | export const EventMessageThinking = "message/thinking"
4 | export const EventMessageTextTyping = "message/text/typing"
5 | export const EventMessageTextEOF = "message/text/EOF"
6 | export const EventMessageAudio = "message/audio"
7 | export const EventMessageError = "message/error"
8 | export const EventSystemAbility = "system/ability"
9 | export const EventSystemNotification = "system/notification"
10 | export const EventKeepAlive = ""
11 |
12 | export type SSEMsgMeta = {
13 | // unique ID for the whole chat(contains maybe hundreds of messages)
14 | chatId: string
15 | // unique ID for each request
16 | ticketId: string
17 | // unique ID for each message
18 | messageID: string
19 | role: Role
20 | }
21 |
22 | export type SSEMsgText = SSEMsgMeta & {
23 | text: string
24 | }
25 |
26 | export type SSEMsgAudio = SSEMsgMeta & {
27 | audio: string; // base64 of byte array
28 | durationMs?: number
29 | }
30 |
31 | export type SSEMsgError = SSEMsgMeta & {
32 | errMsg: string
33 | }
34 |
35 |
--------------------------------------------------------------------------------
/src/api/sse/server-ability.ts:
--------------------------------------------------------------------------------
1 | // ServerAbility guide clients in adjusting all parameters.
2 | export type ServerAbility = {
3 | demo: boolean
4 | llm: ServerLLM
5 | tts: ServerTTS
6 | stt: ServerSTT
7 | }
8 |
9 | // TTS
10 | export type ServerTTS = {
11 | available: boolean
12 | google: ServerGoogleTTS
13 | elevenlabs: ServerElevenlabs
14 | }
15 |
16 | export type ServerGoogleTTS = {
17 | available: boolean
18 | voices?: TaggedItem[]
19 | }
20 |
21 | export type ServerElevenlabs = {
22 | available: boolean
23 | voices?: TaggedItem[]
24 | }
25 |
26 | // STT
27 | export type ServerSTT = {
28 | available: boolean
29 | whisper: ServerWhisper
30 | google: ServerGoogleSTT
31 | }
32 |
33 | export type ServerWhisper = {
34 | available: boolean
35 | models?: string[]
36 | }
37 |
38 | export type ServerGoogleSTT = {
39 | available: boolean
40 | recognizers?: TaggedItem[]
41 | }
42 |
43 | // LLM
44 | export type ServerLLM = {
45 | available: boolean
46 | chatGPT: ServerChatGPT
47 | gemini: ServerGemini
48 | }
49 |
50 | export type ServerChatGPT = {
51 | available: boolean
52 | models?: Model[]
53 | }
54 |
55 | export type ServerGemini = {
56 | available: boolean
57 | models?: Model[]
58 | }
59 |
60 | export type Model = {
61 | name: string
62 | displayName: string
63 | }
64 |
65 | // other
66 | export type TaggedItem = {
67 | id: string
68 | name: string
69 | tags?: string [] // gender, accent, age, etc
70 | }
71 |
72 | export const defaultServerAbility = (): ServerAbility => {
73 | return {
74 | demo: false,
75 | llm: {
76 | available: false,
77 | chatGPT: {
78 | available: false,
79 | },
80 | gemini: {
81 | available: false,
82 | }
83 | },
84 | tts: {
85 | available: false,
86 | google: {
87 | available: false
88 | },
89 | elevenlabs: {
90 | available: false
91 | }
92 | },
93 | stt: {
94 | available: false,
95 | whisper: {
96 | available: false
97 | },
98 | google: {
99 | available: false
100 | }
101 | },
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/src/api/sse/sse.tsx:
--------------------------------------------------------------------------------
1 | import {useEffect} from 'react'
2 | import {useSnapshot} from "valtio/react"
3 | import {networkState} from "../../state/network-state.ts"
4 | import {appState, findChatProxy, findMessage, findMessage2} from "../../state/app-state.ts"
5 | import {ServerAbility} from "./server-ability.ts"
6 | import {newError, newThinking, onAudio, onEOF, onError, onThinking, onTyping} from "../../data-structure/message.tsx"
7 | import {
8 | EventKeepAlive,
9 | EventMessageAudio,
10 | EventMessageError,
11 | EventMessageTextEOF,
12 | EventMessageTextTyping,
13 | EventMessageThinking,
14 | EventSystemAbility,
15 | SSEMsgAudio,
16 | SSEMsgError,
17 | SSEMsgMeta,
18 | SSEMsgText
19 | } from "./event.ts"
20 | import {base64ToBlob, generateAudioId, randomHash32Char} from "../../util/util.tsx"
21 | import {audioDb} from "../../state/db.ts"
22 | import {audioPlayerMimeType, SSEEndpoint} from "../../config.ts"
23 | import {adjustOption} from "../../data-structure/client-option.tsx"
24 | import {createDemoChatIfNecessary} from "../../data/chat.ts";
25 |
26 |
27 | export const SSE = () => {
28 | const {passwordHash} = useSnapshot(appState.auth)
29 | // console.info("SSE rendered", new Date().toLocaleString())
30 |
31 |
32 | useEffect(() => {
33 | const ep = SSEEndpoint()
34 | const streamId = randomHash32Char()
35 | networkState.streamId = streamId
36 | const url = `${ep}?stream=${streamId}&passwordHash=${passwordHash}`
37 | const eventSource = new EventSource(url);
38 | console.info("connecting to [SSE]: ", url)
39 |
40 | eventSource.onopen = (event) => {
41 | console.info("[SSE] connected to server, response: ", event, new Date().toISOString())
42 | eventSource.withCredentials
43 | }
44 |
45 | eventSource.onerror = (event) => {
46 | console.error("[SSE] error: ", event, new Date().toISOString());
47 | }
48 |
49 | eventSource.addEventListener(EventSystemAbility, (event: MessageEvent) => {
50 | console.debug("received ability from SSE server", event.type, event.data)
51 | const sa: ServerAbility = JSON.parse(event.data)
52 | adjustOption(appState.option, sa)
53 | for (const chat of appState.chats) {
54 | adjustOption(chat.option, sa)
55 | }
56 | // eslint-disable-next-line valtio/state-snapshot-rule
57 | appState.ability = sa
58 | createDemoChatIfNecessary()
59 | })
60 |
61 | eventSource.addEventListener(EventMessageThinking, (event: MessageEvent) => {
62 | console.debug("received EventMessageThinking")
63 | const meta: SSEMsgMeta = JSON.parse(event.data)
64 | const found = findChatProxy(meta.chatId, true)
65 | if (!found) {
66 | return
67 | }
68 | const chat = found[0]
69 | const msg = findMessage(chat, meta.messageID)
70 | if (msg) {
71 | onThinking(msg)
72 | } else {
73 | const message = newThinking(meta.messageID, meta.ticketId, meta.role)
74 | chat.messages.push(message)
75 | }
76 | })
77 |
78 | eventSource.addEventListener(EventMessageTextTyping, (event: MessageEvent) => {
79 | const text: SSEMsgText = JSON.parse(event.data)
80 | const msg = findMessage2(text.chatId, text.messageID, true)
81 | if (msg) {
82 | onTyping(msg, text.text)
83 | }
84 | })
85 |
86 | eventSource.addEventListener(EventMessageTextEOF, (event: MessageEvent) => {
87 | const text: SSEMsgText = JSON.parse(event.data)
88 | const msg = findMessage2(text.chatId, text.messageID, true)
89 | if (msg) {
90 | onEOF(msg, text.text ?? "")
91 | }
92 | })
93 |
94 | eventSource.addEventListener(EventMessageAudio, (event: MessageEvent) => {
95 | const audio: SSEMsgAudio = JSON.parse(event.data)
96 | const msg = findMessage2(audio.chatId, audio.messageID, true)
97 | if (msg) {
98 | const blob = base64ToBlob(audio.audio, audioPlayerMimeType)
99 | const audioId = generateAudioId("synthesis")
100 | audioDb.setItem(audioId, blob, () => {
101 | onAudio(msg, {id: audioId, durationMs: audio.durationMs})
102 | }).then(() => true)
103 | }
104 | })
105 |
106 | eventSource.addEventListener(EventMessageError, (event: MessageEvent) => {
107 | const error: SSEMsgError = JSON.parse(event.data)
108 | const found = findChatProxy(error.chatId, true)
109 | if (!found) {
110 | return
111 | }
112 | const chat = found[0]
113 | const msg = findMessage(chat, error.messageID)
114 | if (msg) {
115 | onError(msg, error.errMsg)
116 | } else {
117 | const m = newError(error.messageID, error.ticketId, error.role, error.errMsg)
118 | chat.messages.push(m)
119 | }
120 | })
121 |
122 | eventSource.addEventListener(EventKeepAlive, () => {
123 | // nothing to do
124 | })
125 |
126 | return () => {
127 | console.info("[SSE] trying to abort")
128 | eventSource.close()
129 | }
130 | },
131 | [passwordHash]
132 | )
133 | return null
134 | }
135 |
--------------------------------------------------------------------------------
/src/assets/bg/clement/December 21 Night.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codegenius2/Talk-web/17eaef99bc62a8259c47b81627a8da2629f9dbb8/src/assets/bg/clement/December 21 Night.jpg
--------------------------------------------------------------------------------
/src/assets/bg/no-copyright/84974784818869.5d6bfdf4e8260.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codegenius2/Talk-web/17eaef99bc62a8259c47b81627a8da2629f9dbb8/src/assets/bg/no-copyright/84974784818869.5d6bfdf4e8260.png
--------------------------------------------------------------------------------
/src/assets/bg/no-copyright/959e3384818869.5d6bfdf2b5e1b.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codegenius2/Talk-web/17eaef99bc62a8259c47b81627a8da2629f9dbb8/src/assets/bg/no-copyright/959e3384818869.5d6bfdf2b5e1b.png
--------------------------------------------------------------------------------
/src/assets/bg/noise-lg.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/assets/bg/noise.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/assets/bg/wikiart-public-domain/Composition VIII 1923.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codegenius2/Talk-web/17eaef99bc62a8259c47b81627a8da2629f9dbb8/src/assets/bg/wikiart-public-domain/Composition VIII 1923.jpg
--------------------------------------------------------------------------------
/src/assets/bg/wikiart-public-domain/simultaneous-counter-composition-1930.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codegenius2/Talk-web/17eaef99bc62a8259c47b81627a8da2629f9dbb8/src/assets/bg/wikiart-public-domain/simultaneous-counter-composition-1930.jpg
--------------------------------------------------------------------------------
/src/assets/font/Borel/Borel-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codegenius2/Talk-web/17eaef99bc62a8259c47b81627a8da2629f9dbb8/src/assets/font/Borel/Borel-Regular.ttf
--------------------------------------------------------------------------------
/src/assets/font/Borel/OFL.txt:
--------------------------------------------------------------------------------
1 | Copyright 2023 The Borel Project Authors (https://github.com/RosaWagner/Borel)
2 |
3 | This Font Software is licensed under the SIL Open Font License, Version 1.1.
4 | This license is copied below, and is also available with a FAQ at:
5 | http://scripts.sil.org/OFL
6 |
7 |
8 | -----------------------------------------------------------
9 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10 | -----------------------------------------------------------
11 |
12 | PREAMBLE
13 | The goals of the Open Font License (OFL) are to stimulate worldwide
14 | development of collaborative font projects, to support the font creation
15 | efforts of academic and linguistic communities, and to provide a free and
16 | open framework in which fonts may be shared and improved in partnership
17 | with others.
18 |
19 | The OFL allows the licensed fonts to be used, studied, modified and
20 | redistributed freely as long as they are not sold by themselves. The
21 | fonts, including any derivative works, can be bundled, embedded,
22 | redistributed and/or sold with any software provided that any reserved
23 | names are not used by derivative works. The fonts and derivatives,
24 | however, cannot be released under any other type of license. The
25 | requirement for fonts to remain under this license does not apply
26 | to any document created using the fonts or their derivatives.
27 |
28 | DEFINITIONS
29 | "Font Software" refers to the set of files released by the Copyright
30 | Holder(s) under this license and clearly marked as such. This may
31 | include source files, build scripts and documentation.
32 |
33 | "Reserved Font Name" refers to any names specified as such after the
34 | copyright statement(s).
35 |
36 | "Original Version" refers to the collection of Font Software components as
37 | distributed by the Copyright Holder(s).
38 |
39 | "Modified Version" refers to any derivative made by adding to, deleting,
40 | or substituting -- in part or in whole -- any of the components of the
41 | Original Version, by changing formats or by porting the Font Software to a
42 | new environment.
43 |
44 | "Author" refers to any designer, engineer, programmer, technical
45 | writer or other person who contributed to the Font Software.
46 |
47 | PERMISSION & CONDITIONS
48 | Permission is hereby granted, free of charge, to any person obtaining
49 | a copy of the Font Software, to use, study, copy, merge, embed, modify,
50 | redistribute, and sell modified and unmodified copies of the Font
51 | Software, subject to the following conditions:
52 |
53 | 1) Neither the Font Software nor any of its individual components,
54 | in Original or Modified Versions, may be sold by itself.
55 |
56 | 2) Original or Modified Versions of the Font Software may be bundled,
57 | redistributed and/or sold with any software, provided that each copy
58 | contains the above copyright notice and this license. These can be
59 | included either as stand-alone text files, human-readable headers or
60 | in the appropriate machine-readable metadata fields within text or
61 | binary files as long as those fields can be easily viewed by the user.
62 |
63 | 3) No Modified Version of the Font Software may use the Reserved Font
64 | Name(s) unless explicit written permission is granted by the corresponding
65 | Copyright Holder. This restriction only applies to the primary font name as
66 | presented to the users.
67 |
68 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69 | Software shall not be used to promote, endorse or advertise any
70 | Modified Version, except to acknowledge the contribution(s) of the
71 | Copyright Holder(s) and the Author(s) or with their explicit written
72 | permission.
73 |
74 | 5) The Font Software, modified or unmodified, in part or in whole,
75 | must be distributed entirely under this license, and must not be
76 | distributed under any other license. The requirement for fonts to
77 | remain under this license does not apply to any document created
78 | using the Font Software.
79 |
80 | TERMINATION
81 | This license becomes null and void if any of the above conditions are
82 | not met.
83 |
84 | DISCLAIMER
85 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93 | OTHER DEALINGS IN THE FONT SOFTWARE.
94 |
--------------------------------------------------------------------------------
/src/assets/svg/Google_2015_logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/assets/svg/gemini.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codegenius2/Talk-web/17eaef99bc62a8259c47b81627a8da2629f9dbb8/src/assets/svg/gemini.ico
--------------------------------------------------------------------------------
/src/assets/svg/ios-spinner.svg:
--------------------------------------------------------------------------------
1 |
2 |
28 |
29 |
--------------------------------------------------------------------------------
/src/assets/svg/openai-icon.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/src/auth/auth.tsx:
--------------------------------------------------------------------------------
1 | import {motion} from 'framer-motion'
2 | import React, {useCallback, useEffect, useState} from 'react'
3 | import {AxiosError, AxiosResponse} from "axios"
4 | import {useNavigate} from "react-router-dom"
5 | import {login} from "../api/restful/api.ts"
6 | import {savePassAsHash, setLoggedIn} from "../state/app-state.ts"
7 | import {cx} from "../util/util.tsx"
8 | import {Helmet} from 'react-helmet-async'
9 | import {GranimWallpaper} from "../wallpaper/granim-wallpaper.tsx"
10 |
11 | const detectDelay = 1000
12 | const fadeOutDuration = 1500
13 |
14 | // (0,0) -> (-500,0) -> (500,0) -> ...
15 | const shakeAnimation = {
16 | x: [0, -30, 30, -30, 30, 0],
17 | y: [0, 0, 0, 0, 0, 0],
18 | }
19 |
20 | export default function Auth() {
21 | const navigate = useNavigate()
22 |
23 | const [textLight, setTextLight] = useState(false)
24 |
25 | const [inputValue, setInputValue] = useState('')
26 | const [shake, setShake] = useState(false)
27 | const [startFadeOut, setStartFadeOut] = useState(false)
28 | const [startFadeIn, setStartFadeIn] = useState(false)
29 |
30 | // use this function to detect where password is required by Talk server
31 | const handleLogin = useCallback((detect: boolean, password?: string) => {
32 | setShake(false)
33 | login(password).then((r: AxiosResponse) => {
34 | console.info("login is successful", r.status, r.data)
35 | if (password) {
36 | savePassAsHash(password)
37 | }
38 | setLoggedIn(true)
39 | setStartFadeOut(true)
40 | setTimeout(() => navigate("/chat"), fadeOutDuration)
41 | }).catch((e: AxiosError) => {
42 | console.info("failed to login", e)
43 | if (!detect) {
44 | setInputValue('')
45 | setShake(true)
46 | }
47 | })
48 | }, [navigate])
49 |
50 | // detect if login is required
51 | useEffect(() => {
52 | const t = setTimeout(() => handleLogin(true)
53 | , detectDelay)
54 | return () => clearTimeout(t)
55 | }, [handleLogin])
56 |
57 | const handleSubmit = useCallback((event: React.FormEvent) => {
58 | event.preventDefault()
59 | handleLogin(false, inputValue)
60 | }, [handleLogin, inputValue])
61 |
62 |
63 | const onDark = useCallback((isDark: boolean) => {
64 | setTextLight(isDark)
65 | }, [])
66 |
67 | useEffect(() => {
68 | setStartFadeIn(true)
69 | }, [])
70 |
71 | return (
72 | // fadeOutDuration is shorter than duration-2000 to avoid staying in a white page
73 |
74 |
75 | Talk - login
76 |
77 | {/* Add more meta tags as needed */}
78 |
79 |
80 | {
}
81 |
83 |
86 | Let's talk
87 |
88 |
93 | {/**/}
94 | {
101 | setInputValue(e.target.value)
102 | setShake(false)
103 | }}
104 | className={cx("appearance-none w-full h-16 rounded-lg outline-none caret-transparent",
105 | "text-6xl text-center tracking-widest bg-white backdrop-blur bg-opacity-10 transition duration-5000",
106 | textLight ? "text-neutral-200" : "text-neutral-800")}
107 | />
108 |
109 |
110 |
111 |
112 | )
113 | }
114 |
--------------------------------------------------------------------------------
/src/config.ts:
--------------------------------------------------------------------------------
1 | import {currentProtocolHostPortPath, joinUrl} from "./util/util.tsx"
2 |
3 | export const audioPlayerMimeType = 'audio/mpeg'
4 | export const maxLoadedVoice = 6
5 | export const messageTimeoutSeconds = 60
6 | // a recoding which is less than minSpeakTimeMillis should be discarded
7 | export const minSpeakTimeMillis = 500
8 |
9 | export function SSEEndpoint(): string {
10 | const ep = joinUrl(APIEndpoint(), "events")
11 | console.debug("SSEEndpoint:", ep)
12 | return ep
13 | }
14 |
15 | export function APIEndpoint(): string {
16 | let ep = import.meta.env.VITE_REACT_APP_ENDPOINT
17 | if (ep) {
18 | return ep
19 | }
20 | ep = joinUrl(currentProtocolHostPortPath(), "api")
21 | console.debug("RestfulEndpoint:", ep)
22 | return ep
23 | }
24 |
25 | export type RecordingMimeType = {
26 | mimeType: string
27 | fileName: string
28 | }
29 |
30 | export const popularMimeTypes: RecordingMimeType[] = [
31 | {mimeType: 'audio/webm; codecs=vp9', fileName: "audio.webm"},
32 | {mimeType: 'audio/webm; codecs=opus', fileName: "audio.webm"},
33 | {mimeType: 'audio/webm', fileName: "audio.webm"},
34 | {mimeType: 'audio/mp4', fileName: "audio.mp4"},
35 | ]
36 |
37 |
--------------------------------------------------------------------------------
/src/data-structure/provider-api-refrence/chat-gpt.ts:
--------------------------------------------------------------------------------
1 | import {Choice, FloatRange, IntRange, k} from "./types.ts"
2 |
3 | export type ChatGPTAPIReference = {
4 | readonly maxTokens: IntRange
5 | readonly temperature: FloatRange
6 | readonly topP: FloatRange
7 | readonly presencePenalty: FloatRange
8 | readonly frequencyPenalty: FloatRange
9 | }
10 |
11 | // see https://platform.openai.com/docs/api-reference/chat/create
12 | export const chatGPTAPIReference: ChatGPTAPIReference = {
13 | maxTokens: {
14 | rangeStart: 0,
15 | rangeEnd: Number.MAX_SAFE_INTEGER,
16 | default: k,
17 | },
18 | temperature: {
19 | rangeStart: 0,
20 | rangeEnd: 2,
21 | default: 1,
22 | },
23 | topP: {
24 | rangeStart: 0,
25 | rangeEnd: 1,
26 | default: 1,
27 | },
28 | presencePenalty: {
29 | rangeStart: -2,
30 | rangeEnd: 2,
31 | default: 0,
32 | },
33 | frequencyPenalty: {
34 | rangeStart: -2,
35 | rangeEnd: 2,
36 | default: 0,
37 | },
38 | }
39 |
40 | export const attachNumberChoices: Choice[] = [
41 | {value: 1, name: "1", tags: []},
42 | {value: 2, name: "2", tags: []},
43 | {value: 3, name: "3", tags: []},
44 | {value: 4, name: "4", tags: []},
45 | {value: 5, name: "5", tags: []},
46 | {value: 6, name: "6", tags: []},
47 | {value: 7, name: "7", tags: []},
48 | {value: 8, name: "8", tags: []},
49 | {value: 9, name: "9", tags: []},
50 | {value: 10, name: "10", tags: []},
51 | {value: 20, name: "20", tags: []},
52 | {value: 30, name: "30", tags: []},
53 | {value: 40, name: "40", tags: []},
54 | {value: 50, name: "50", tags: []},
55 | {value: 100, name: "100", tags: []},
56 | {value: Number.MAX_SAFE_INTEGER, name: "∞", tags: []}
57 | ]
58 |
59 | export const tokenChoices: Choice[] = [
60 | {value: 50, name: "50", tags: []},
61 | {value: 100, name: "100", tags: []},
62 | {value: 200, name: "200", tags: []},
63 | {value: 500, name: "500", tags: []},
64 | {value: k, name: "1k", tags: []},
65 | {value: 2 * k, name: "2k", tags: []},
66 | {value: 4 * k, name: "4k", tags: []},
67 | {value: 8 * k, name: "8k", tags: []},
68 | {value: 16 * k, name: "16k", tags: []},
69 | {value: 32 * k, name: "32k", tags: []},
70 | {value: 64 * k, name: "64k", tags: []},
71 | {value: Number.MAX_SAFE_INTEGER, name: "∞", tags: []},
72 | ]
--------------------------------------------------------------------------------
/src/data-structure/provider-api-refrence/elevenlabs-tts.ts:
--------------------------------------------------------------------------------
1 | import {FloatRange} from "./types.ts"
2 |
3 | export type ElevenlabsAPIReference = {
4 | stability: FloatRange
5 | clarity: FloatRange
6 | }
7 |
8 | // see https://platform.openai.com/docs/models/whisper
9 | export const elevenlabsAPIReference: ElevenlabsAPIReference = {
10 | stability: {
11 | rangeStart: 0,
12 | rangeEnd: 1,
13 | default: 0.5,
14 | },
15 | clarity: {
16 | rangeStart: 0,
17 | rangeEnd: 1,
18 | default: 0.75,
19 | },
20 | }
21 |
--------------------------------------------------------------------------------
/src/data-structure/provider-api-refrence/gemini.ts:
--------------------------------------------------------------------------------
1 | import {Choice, FloatRange, IntRange, k} from "./types.ts"
2 |
3 | export type GeminiAPIReference = {
4 | readonly maxOutputTokens: IntRange
5 | readonly temperature: FloatRange
6 | readonly topP: FloatRange
7 | readonly topK: IntRange
8 | }
9 |
10 | // https://cloud.google.com/vertex-ai/docs/generative-ai/model-reference/gemini
11 | export const geminiAPIReference: GeminiAPIReference = {
12 | maxOutputTokens: {
13 | rangeStart: 1,
14 | rangeEnd: 8 * k,
15 | default: 8 * k,
16 | },
17 | temperature: {
18 | rangeStart: 0,
19 | rangeEnd: 1,
20 | default: 0.9,
21 | },
22 | topP: {
23 | rangeStart: 0,
24 | rangeEnd: 1,
25 | default: 1,
26 | },
27 | topK: {
28 | rangeStart: 1,
29 | rangeEnd: 40,
30 | default: 32,
31 | },
32 | }
33 |
34 | export const maxOutputTokens: Choice[] = [
35 | {value: 50, name: "50", tags: []},
36 | {value: 100, name: "100", tags: []},
37 | {value: 200, name: "200", tags: []},
38 | {value: 500, name: "500", tags: []},
39 | {value: k, name: "1k", tags: []},
40 | {value: 2 * k, name: "2k", tags: []},
41 | {value: 4 * k, name: "4k", tags: []},
42 | {value: 8 * k, name: "8k", tags: []},
43 | ]
--------------------------------------------------------------------------------
/src/data-structure/provider-api-refrence/google-stt.ts:
--------------------------------------------------------------------------------
1 | import {ChooseOne} from "./types.ts"
2 | import {googleTTSLanguageStrings} from "../../data/google-tts-language.ts"
3 |
4 | export type GoogleSTTAPIReference = {
5 | language: ChooseOne
6 | models: ChooseOne
7 | }
8 |
9 | // see https://cloud.google.com/text-to-speech/docs/reference/rest/v1/AudioConfig
10 | export const googleSTTAPIReference: GoogleSTTAPIReference = {
11 | language: {
12 | choices: googleTTSLanguageStrings,
13 | default: {
14 | value: "en-GB",
15 | name: "English (Great Britain)",
16 | tags: []
17 | },
18 | },
19 | models: {
20 | choices: [
21 | {
22 | name: "short",
23 | value: "short",
24 | tags: []
25 | },
26 | {
27 | name: "long",
28 | value: "long",
29 | tags: []
30 | },
31 | {
32 | name: "chirp",
33 | value: "chirp",
34 | tags: []
35 | },
36 | {
37 | name: "telephony",
38 | value: "telephony",
39 | tags: []
40 | },
41 | {
42 | name: "medical_dictation",
43 | value: "medical_dictation",
44 | tags: []
45 | },
46 | {
47 | name: "medical_conversation",
48 | value: "medical_conversation",
49 | tags: []
50 | },
51 | ],
52 | default: {
53 | name: "short",
54 | value: "short",
55 | tags: []
56 | },
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/src/data-structure/provider-api-refrence/google-tts.ts:
--------------------------------------------------------------------------------
1 | import {ChooseOne, FloatRange} from "./types.ts"
2 | import {GoogleTTSGender} from "../../api/restful/model.ts"
3 | import {googleTTSLanguageStrings} from "../../data/google-tts-language.ts"
4 |
5 | export type GoogleTTSAPIReference = {
6 | language: ChooseOne
7 | gender: ChooseOne
8 | speakingRate: FloatRange
9 | pitch: FloatRange
10 | volumeGainDb: FloatRange
11 | }
12 |
13 | // see https://cloud.google.com/text-to-speech/docs/reference/rest/v1/AudioConfig
14 | export const googleTTSAPIReference: GoogleTTSAPIReference = {
15 | language: {
16 | choices: googleTTSLanguageStrings,
17 | default: {
18 | value: "en-GB",
19 | name: "English (Great Britain)",
20 | tags: []
21 | },
22 | },
23 | gender: {
24 | choices: [
25 | {
26 | name: "unspecified",
27 | value: GoogleTTSGender.unspecified,
28 | tags: []
29 | },
30 | {
31 | name: "male",
32 | value: GoogleTTSGender.male,
33 | tags: []
34 | },
35 | {
36 | name: "female",
37 | value: GoogleTTSGender.female,
38 | tags: []
39 | },
40 | {
41 | name: "neutral",
42 | value: GoogleTTSGender.neutral,
43 | tags: []
44 | },
45 | ],
46 | default: {
47 | name: "unspecified",
48 | value: GoogleTTSGender.unspecified,
49 | tags: []
50 | },
51 | },
52 | speakingRate: {
53 | rangeStart: 0.25,
54 | rangeEnd: 4,
55 | default: 1,
56 | },
57 | pitch: {
58 | rangeStart: -20,
59 | rangeEnd: 20,
60 | default: 0,
61 | },
62 | volumeGainDb: {
63 | rangeStart: -96.0,
64 | rangeEnd: 16.0,
65 | default: 0.0,
66 | },
67 | }
--------------------------------------------------------------------------------
/src/data-structure/provider-api-refrence/llm.ts:
--------------------------------------------------------------------------------
1 | import {IntRange} from "./types.ts"
2 |
3 | export type LLMAPIReference = {
4 | readonly maxAttached: IntRange
5 | }
6 |
7 | export const llmAPIReference: LLMAPIReference = {
8 | maxAttached: {
9 | rangeStart: 0,
10 | rangeEnd: Number.MAX_SAFE_INTEGER,
11 | default: 4,
12 | },
13 | }
--------------------------------------------------------------------------------
/src/data-structure/provider-api-refrence/types.ts:
--------------------------------------------------------------------------------
1 | export const k = 1024
2 |
3 | export interface IntRange {
4 | readonly rangeStart: number
5 | readonly rangeEnd: number
6 | readonly default: number
7 | }
8 |
9 | export interface FloatRange {
10 | readonly rangeStart: number
11 | readonly rangeEnd: number
12 | readonly default: number
13 | }
14 |
15 | export interface ChooseOne {
16 | readonly choices: Choice[]
17 | readonly default: Choice
18 | }
19 |
20 | export interface Choice {
21 | readonly name: string
22 | readonly value: T
23 | readonly tags: string[]
24 | }
25 |
26 | export const emptyStringChoice: Choice = {
27 | name: "",
28 | value: "",
29 | tags: []
30 | }
--------------------------------------------------------------------------------
/src/data-structure/provider-api-refrence/whisper.ts:
--------------------------------------------------------------------------------
1 | import {ChooseOne} from "./types.ts"
2 |
3 | export type WhisperAPIReference = {
4 | models: ChooseOne
5 | }
6 |
7 | // see https://platform.openai.com/docs/api-reference/audio/createTranscription
8 | export const whisperAPIReference = (): WhisperAPIReference => {
9 | return {
10 | models: {
11 | choices: [{name: "whisper-1", value: "whisper-1", tags: []}],
12 | default: {name: "whisper-1", value: "whisper-1", tags: []}
13 | }
14 | }
15 | }
--------------------------------------------------------------------------------
/src/data/chat.ts:
--------------------------------------------------------------------------------
1 | import {newReceived, newSent} from "../data-structure/message.tsx";
2 | import {appState, createChat} from "../state/app-state.ts";
3 |
4 | export const createDemoChatIfNecessary = () => {
5 | if (appState.ability.demo && appState.chats.length === 0 && !appState.pref.dismissDemo) {
6 | createChat("Demo", [
7 | newSent("Hello!"),
8 | newReceived(`Hello! How may I assist you today?
9 |
10 | Feel free to ask me anything. Please note, I’ll reply with **pseudo** text and voice.
11 |
12 | For genuine AI responses, you may want to set up your own instance following the instructions at
13 | [proxoar/talk](https://github.com/proxoar/talk).`),
14 | ])
15 | }
16 | }
--------------------------------------------------------------------------------
/src/data/google-tts-language.ts:
--------------------------------------------------------------------------------
1 | import {googleSTTLanguageSettings} from "./google-sst-language.ts"
2 |
3 | // see https://www.gstatic.com/cloud-site-ux/text_to_speech/text_to_speech.min.js
4 | // after comparing line by line, we found that googleSTTLanguageSettings and googleTTSLanguageStrings are identical
5 | // at the moment(2023-09-11 10:33:52 Mon)
6 | export const googleTTSLanguageStrings = googleSTTLanguageSettings
--------------------------------------------------------------------------------
/src/error.tsx:
--------------------------------------------------------------------------------
1 | import {useCallback, useState} from 'react'
2 | import {useNavigate, useRouteError} from "react-router-dom"
3 | import {CountDownButton, ResetButton} from "./home/panel/shared/widget/button.tsx"
4 | import {IoRefreshSharp} from "react-icons/io5"
5 | import {BsTrash3} from "react-icons/bs"
6 | import {cx} from "./util/util.tsx"
7 | import {Helmet} from 'react-helmet-async'
8 | import * as packageJson from '../package.json'
9 | import {GranimWallpaper} from "./wallpaper/granim-wallpaper.tsx"
10 | import {clearMessages, currentChatProxy} from "./state/app-state.ts";
11 | import {clearChats, clearSettings} from "./state/dangerous.ts";
12 |
13 | export default function Error() {
14 |
15 | const [textLight, setTextLight] = useState(false)
16 | const navigate = useNavigate()
17 | const error = useRouteError() as Record
18 |
19 | const onDark = useCallback((isDark: boolean) => {
20 | setTextLight(isDark)
21 | }, [])
22 |
23 | return (
24 |
25 |
26 | Talk - Error
27 |
28 |
29 |
30 |
33 |
34 |
36 |
We're sorry that something went wrong.
37 |
Here is something you can try to fix it.
38 |
Try them one by one until back to normal.
39 |
40 |
navigate("/")}
44 | icon={}
45 | />
46 | }
51 | action={() => {
52 | const chat = currentChatProxy()
53 | if (chat) {
54 | clearMessages(chat)
55 | }
56 | navigate("/")
57 | }}
58 | />
59 | }
64 | action={() => {
65 | clearChats()
66 | navigate("/")
67 | }}
68 | />
69 | }
74 | action={() => {
75 | clearSettings()
76 | navigate("/")
77 | }}
78 | />
79 |
80 |
81 |
84 |
85 |
Version
86 |
{packageJson.version}
87 |
88 |
89 |
Status
90 |
{error?.['statusCode'] ?? "None"} {error?.['statusText'] ?? ""}
91 |
92 |
93 |
Error Message
94 |
{error?.['message'] ?? "None"}
95 |
96 |
97 |
Stack trace
98 |
{error?.['stack'] ?? "None"}
99 |
100 | {error?.['stack'] &&
101 |
Report this on GitHub
106 | }
107 |
108 |
109 | )
110 | }
--------------------------------------------------------------------------------
/src/experiment/experiment.tsx:
--------------------------------------------------------------------------------
1 | import {cx, getRandomElement} from "../util/util.tsx"
2 | import React from "react"
3 | import {Art, wikiarts} from "../wallpaper/art.tsx"
4 |
5 |
6 | export const Experiment = () => {
7 | const art = getRandomElement(...wikiarts)
8 | return (
9 |
10 | )
11 | }
12 |
13 | const Wallpaper: React.FC = ({
14 | author,
15 | name,
16 | date,
17 | imageUrl,
18 | pageUrl,
19 | imageClassName = "bg-cover bg-center blur brightness-75",
20 | noiseClassname = "opacity-80 brightness-100"
21 | }) => {
22 | return (
23 |
44 | )
45 | }
--------------------------------------------------------------------------------
/src/experiment/subscribe-test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import {proxy} from "valtio";
3 | import {subscribeKey} from "valtio/utils";
4 |
5 | interface MState {
6 | loadAudio: boolean
7 | isAttached: boolean
8 | }
9 |
10 | interface MessageState {
11 | record: Record
12 | }
13 |
14 | const messageState = proxy({
15 | record: {}
16 | })
17 |
18 | const clear = () => {
19 | const keys = Object.keys(messageState.record);
20 | for (const key of keys) {
21 | delete messageState.record[key]
22 | }
23 | }
24 | subscribeKey(messageState.record, "a", () => {
25 | console.log("messageState.state.a changed:", messageState.record["a"])
26 | })
27 |
28 | export const Greeting: React.FC = () => {
29 | return (
30 |
31 |
{
32 | messageState.record["a"].loadAudio = true
33 | }}>
34 | set a
35 |
36 | {
37 | delete messageState.record["a"]
38 | }}>
39 | delete a
40 |
41 | {
42 | clear()
43 | }}>
44 | clear
45 |
46 |
47 | )
48 | }
49 |
50 |
51 |
--------------------------------------------------------------------------------
/src/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codegenius2/Talk-web/17eaef99bc62a8259c47b81627a8da2629f9dbb8/src/favicon.png
--------------------------------------------------------------------------------
/src/home/chat-window/attached/attached-item.tsx:
--------------------------------------------------------------------------------
1 | import React, {useEffect, useState} from "react"
2 | import {LLMMessage} from "../../../shared-types.ts";
3 | import {cx} from "../../../util/util.tsx";
4 | import {userColor, assistantColor} from "../compnent/theme.ts";
5 | import {LiaEllipsisHSolid} from "react-icons/lia";
6 | import {useSnapshot} from "valtio/react";
7 | import {layoutState} from "../../../state/layout-state.ts";
8 |
9 | type Props = {
10 | message: LLMMessage
11 | }
12 |
13 | export const AttachedItem: React.FC = ({message}) => {
14 |
15 | const [fullText, setFullText] = useState(false)
16 | const {isPAPinning, isPAFloating} = useSnapshot(layoutState)
17 | useEffect(() => {
18 | setFullText(false)
19 | }, [isPAPinning, isPAFloating]);
20 | let theme
21 | switch (message.role) {
22 | case "user":
23 | theme = userTheme
24 | break;
25 | case "assistant":
26 | theme = assistantTheme
27 | break;
28 | case "system":
29 | theme = systemTheme
30 | break;
31 | }
32 |
33 | return (
34 |
35 |
setFullText(true)}
37 | className={cx("flex rounded-xl whitespace-pre-wrap px-3 pt-0.5 pb-0.5",
38 | theme.bg, theme.text, theme.other
39 | )}>
40 |
41 | {fullText &&
42 |
{message.content}
43 | }
44 | {!fullText &&
45 |
46 | {message.content.slice(0, 100)}
47 | {message.content.length > 100 && }
48 |
}
49 |
50 |
51 |
52 | )
53 | }
54 |
55 | const userTheme = {
56 | bg: userColor.bg,
57 | text: userColor.text,
58 | other: "max-w-[80%] self-end",
59 | }
60 |
61 | const assistantTheme = {
62 | bg: assistantColor.bg,
63 | text: assistantColor.text,
64 | other: "max-w-[80%] self-start",
65 | }
66 |
67 | const systemTheme = {
68 | bg: "bg-fuchsia-600 bg-opacity-80 backdrop-blur",
69 | text: "text-violet-100",
70 | other: "max-w-[80%] self-center",
71 | }
72 |
--------------------------------------------------------------------------------
/src/home/chat-window/chat-window.tsx:
--------------------------------------------------------------------------------
1 | import {appState, Chat, currentChatProxy} from "../../state/app-state.ts"
2 | import TextArea from "./text-area.tsx"
3 | import Recorder from "./recorder.tsx"
4 | import React, {useEffect, useRef, useState} from "react"
5 | import {subscribeKey} from "valtio/utils"
6 | import {MessageList} from "./message-list/message-list.tsx"
7 | import {PromptAttached} from "./prompt-attached.tsx"
8 | import {cx} from "../../util/util.tsx"
9 | import {useSnapshot} from "valtio/react"
10 | import {layoutState} from "../../state/layout-state.ts"
11 | import {throttle} from "lodash"
12 | import {PromptAttachedButton} from "./compnent/prompt-attached-button.tsx"
13 |
14 |
15 | export const ChatWindow: React.FC = () => {
16 |
17 | const [chatProxy, setChatProxy] = useState(undefined)
18 | // console.info("ChatWindow rendered", new Date().toLocaleString())
19 | const buttonRef = useRef(null)
20 |
21 | const {isPAFloating, isPAPinning} = useSnapshot(layoutState)
22 | const {showRecorder} = useSnapshot(appState.pref)
23 |
24 | useEffect(() => {
25 | const callback = () => {
26 | const cp = currentChatProxy()
27 | setChatProxy(cp)
28 | }
29 | const unsubscribe = subscribeKey(appState, "currentChatId", callback)
30 | callback()
31 | return unsubscribe
32 | }, [])
33 |
34 |
35 | const handleMouseMove = throttle((event: React.MouseEvent) => {
36 | if (buttonRef.current) {
37 | const rect = buttonRef.current.getBoundingClientRect()
38 | const [x, y] = [(rect.left + 18), (rect.top + 18)]
39 | layoutState.PAButtonDistance = Math.hypot(x - event.clientX, y - event.clientY)
40 | }
41 | }, 50)
42 |
43 | return (
44 |
50 | {chatProxy !== undefined &&
51 | <>
52 |
55 |
64 |
65 |
67 | <>
68 |
69 |
81 | >
82 |
83 | >
84 | }
85 |
86 |
87 | )
88 | }
89 |
90 |
--------------------------------------------------------------------------------
/src/home/chat-window/compnent/drop-down-menu.tsx:
--------------------------------------------------------------------------------
1 | import React, {useEffect, useRef, useState} from 'react'
2 | import {HiMiniEllipsisHorizontal} from "react-icons/hi2"
3 |
4 | export type Item = {
5 | name: string
6 | action?: () => void
7 | download?: {
8 | url: string,
9 | fileName: string
10 | }
11 | icon: React.JSX.Element
12 | }
13 |
14 | interface Props {
15 | list: Item[]
16 | }
17 |
18 | export const DropDownMenu: React.FC = ({list}) => {
19 | const [open, setOpen] = useState(false)
20 | const dropdownRef = useRef(null)
21 |
22 | useEffect(() => {
23 | const handleOutsideClick = (event: unknown) => {
24 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
25 | // @ts-ignore
26 | if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
27 | setOpen(false)
28 | }
29 | }
30 |
31 | document.addEventListener('mousedown', handleOutsideClick)
32 |
33 | return () => {
34 | document.removeEventListener('mousedown', handleOutsideClick)
35 | }
36 | }, [])
37 |
38 | const toggleMenu = () => {
39 | setOpen(!open)
40 | }
41 |
42 | return (
43 |
44 |
48 | {open &&
49 | //add extra content to bottom, so that menu doesn't disappear immediately when cursor leaves the last option
50 |
67 | }
68 |
69 | )
70 | };
--------------------------------------------------------------------------------
/src/home/chat-window/compnent/error-boundary.tsx:
--------------------------------------------------------------------------------
1 | import React, { ErrorInfo } from 'react'
2 |
3 | interface ErrorBoundaryProps {
4 | children: React.ReactNode
5 | }
6 |
7 | interface ErrorBoundaryState {
8 | hasError: boolean
9 | error?: Error
10 | }
11 |
12 | class ErrorBoundary extends React.Component {
13 | constructor(props: ErrorBoundaryProps) {
14 | super(props)
15 | this.state = { hasError: false }
16 | }
17 |
18 | static getDerivedStateFromError(error: Error): ErrorBoundaryState {
19 | return { hasError: true, error }
20 | }
21 |
22 | componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
23 | console.error('Error caught by ErrorBoundary:', error, errorInfo)
24 | }
25 |
26 | render() {
27 | if (this.state.hasError) {
28 | return (
29 | // todo put error on message box UI
30 |
31 |
Something went wrong.
32 |
{this.state.error?.message}
33 |
34 | )
35 | }
36 |
37 | return this.props.children
38 | }
39 | }
40 |
41 | export default ErrorBoundary
42 |
43 |
--------------------------------------------------------------------------------
/src/home/chat-window/compnent/my-error.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import {cx, formatAgo} from "../../../util/util.tsx"
3 | import {Message} from "../../../data-structure/message.tsx"
4 | import {CgDanger} from "react-icons/cg"
5 | import {Theme} from "./theme.ts"
6 |
7 | interface TextProps {
8 | messageSnap: Message
9 | theme: Theme
10 | }
11 |
12 | export const MyError: React.FC = ({messageSnap, theme}) => {
13 | return
17 |
18 |
19 |
{messageSnap.errorMessage}
20 |
21 |
24 |
25 | }
26 |
27 |
--------------------------------------------------------------------------------
/src/home/chat-window/compnent/prompt-attached-button.tsx:
--------------------------------------------------------------------------------
1 | import {useSnapshot} from "valtio/react"
2 | import {useEffect, useState} from "react"
3 | import {cx} from "../../../util/util.tsx"
4 | import {layoutState} from "../../../state/layout-state.ts"
5 | import {BsCircle} from "react-icons/bs"
6 |
7 | const handMonitorRadius = 200
8 | const handMaxOpacity = 1
9 | const handBgMaxOpacity = 0.4
10 | export const PromptAttachedButton = () => {
11 | const {PAButtonDistance, isPAPinning} = useSnapshot(layoutState, {sync: true})
12 | const [iconOpacity, setIconOpacity] = useState(0)
13 | const [bgOpacity, setBgOpacity] = useState(0)
14 | const [isButtonVisible, setIsButtonVisible] = useState(false)
15 |
16 | useEffect(() => {
17 | setIsButtonVisible(PAButtonDistance <= handMonitorRadius - 50)
18 | if (PAButtonDistance < 10) {
19 | setIconOpacity(handMaxOpacity)
20 | setBgOpacity(handBgMaxOpacity)
21 | } else {
22 | setIconOpacity((handMonitorRadius - PAButtonDistance) / handMonitorRadius * handMaxOpacity)
23 | setBgOpacity((handMonitorRadius - PAButtonDistance) / handMonitorRadius * handBgMaxOpacity)
24 | }
25 | }, [PAButtonDistance])
26 | // console.log("hand,bg,dist", handOpacity, handBgOpacity, distance)
27 |
28 | return (
29 | 0 ? 10 * bgOpacity : 0}px)`
33 | }}
34 | className={cx("absolute w-8 h-8 top-1 rounded-full p-1 cursor-pointer ",
35 | "hover:scale-125 hover:backdrop-blur-xl transition-transform duration-200",
36 | isButtonVisible ? "z-10" : "opacity-0 -z-10",
37 | isPAPinning && "hidden"
38 | )}
39 | onMouseEnter={() => layoutState.isPAFloating = true}
40 | onMouseLeave={() => layoutState.isPAFloating = false}
41 | onClick={() => layoutState.isPAPinning = !layoutState.isPAPinning}
42 | >
43 | layoutState.PAButtonWheelDeltaY = e.deltaY}
45 | onMouseLeave={() => layoutState.PAButtonWheelDeltaY = 0}
46 | style={{
47 | opacity: iconOpacity
48 | }}
49 | className={cx("w-full h-full",
50 | "fill-neutral-100 stroke-neutral-100",
51 | )}/>
52 |
53 | )
54 | }
--------------------------------------------------------------------------------
/src/home/chat-window/compnent/theme.ts:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export type Theme = {
4 | bg: string
5 | text: string
6 | normalIcon: string
7 | warningIcon: string
8 | attachIcon: string
9 | code: { [key: string]: React.CSSProperties }
10 |
11 | playBg: string
12 | play: string
13 | pause: string
14 | wave: string
15 | progress: string
16 | hoverLine: string
17 | labelBg: string
18 | label: string
19 | }
20 |
21 | export const userColor: Theme = {
22 | bg: 'bg-blue-700',
23 | text: 'text-violet-100',
24 | normalIcon: 'fill-violet-100',
25 | warningIcon: 'text-yellow-500',
26 | attachIcon: 'fill-violet-100 -left-2 -bottom-1 rotate-45',
27 | code: {},
28 |
29 | playBg: 'bg-blue-grey',
30 | play: 'white',
31 | pause: 'text-white',
32 | wave: 'rgb(128, 154, 241)',
33 | progress: 'rgb(213, 221, 250)',
34 | hoverLine: 'white',
35 | labelBg: '#94a3b8',
36 | label: 'white',
37 | }
38 |
39 | export const assistantColor: Theme = {
40 | bg: 'bg-neutral-100 bg-opacity-80',
41 | text: 'text-neutral-800',
42 | normalIcon: 'fill-neutral-800',
43 | warningIcon: 'text-yellow-500',
44 | attachIcon: '-right-2 -bottom-2 fill-neutral-800 -rotate-45',
45 | code: {},
46 |
47 | playBg: 'bg-white',
48 | play: '#5e5e5e',
49 | pause: 'text-neutral-500',
50 | wave: '#8c8c8c',
51 | progress: '#2f2f2f',
52 | hoverLine: 'black',
53 | labelBg: '#d1d5db',
54 | label: 'black',
55 | }
--------------------------------------------------------------------------------
/src/home/chat-window/compnent/widget/highlightjs-plugins/copy-button-plugin.css:
--------------------------------------------------------------------------------
1 | .hljs-copy-wrapper {
2 | position: relative;
3 | overflow: hidden
4 | }
5 |
6 | .hljs-copy-wrapper:hover .hljs-copy-button, .hljs-copy-button:focus {
7 | transform: translateX(0)
8 | }
9 |
10 | .hljs-copy-button {
11 | position: absolute;
12 | transform: translateX(calc(100% + 1.125em));
13 | top: 1.5em;
14 | right: 1em;
15 | width: 2rem;
16 | height: 2rem;
17 | text-indent: -9999px;
18 | color: #fff;
19 | border-radius: .25rem;
20 | border: 1px solid #ffffff22;
21 | background-color: #2d2b57;
22 | background-color: var(--hljs-theme-background);
23 | background-image: url('data:image/svg+xml;utf-8,');
24 | background-repeat: no-repeat;
25 | background-position: center;
26 | transition: background-color 200ms ease, transform 200ms ease-out;
27 | }
28 |
29 | .hljs-copy-button:hover {
30 | border-color: #ffffff44
31 | }
32 |
33 | .hljs-copy-button:active {
34 | border-color: #ffffff66
35 | }
36 |
37 | .hljs-copy-button[data-copied="true"] {
38 | text-indent: 0;
39 | width: auto;
40 | background-image: none
41 | }
42 |
43 | @media (prefers-reduced-motion) {
44 | .hljs-copy-button {
45 | transition: none
46 | }
47 | }
48 |
49 | .hljs-copy-alert {
50 | clip: rect(0 0 0 0);
51 | clip-path: inset(50%);
52 | height: 1px;
53 | overflow: hidden;
54 | position: absolute;
55 | white-space: nowrap;
56 | width: 1px
57 | }
58 |
--------------------------------------------------------------------------------
/src/home/chat-window/compnent/widget/highlightjs-plugins/copy-button-plugin.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @file highlight-copy.js
3 | * @author Arron Hunt
4 | * @copyright Copyright 2021. All rights reserved.
5 | */
6 |
7 |
8 | /**
9 | * Adds a copy button to highlightjs code blocks
10 | */
11 | export class CopyButtonPlugin {
12 |
13 |
14 | "after:highlightElement"({el, text}: { el: Element, text: string }) {
15 | if (!el.parentElement) {
16 | return;
17 | }
18 | if (el.parentElement.classList.contains("hljs-copy-wrapper")) {
19 | return;
20 | }
21 |
22 | // Create the copy button and append it to the codeblock.
23 | const button = Object.assign(document.createElement("button"), {
24 | innerHTML: "Copy",
25 | className: "hljs-copy-button px-2 select-none",
26 | });
27 |
28 | button.dataset.copied = 'false';
29 |
30 | el.parentElement.classList.add("hljs-copy-wrapper");
31 | el.parentElement.appendChild(button);
32 |
33 | // Add a custom proprety to the code block so that the copy button can reference and match its background-color value.
34 | el.parentElement.style.setProperty(
35 | "--hljs-theme-background",
36 | window.getComputedStyle(el).backgroundColor
37 | );
38 |
39 | const parentElement = el.parentElement
40 | button.onclick = function () {
41 | if (!navigator.clipboard) return;
42 | navigator.clipboard
43 | .writeText(text)
44 | .then(function () {
45 | button.innerHTML = "Copied!";
46 | button.dataset.copied = 'true';
47 |
48 | let alert: HTMLDivElement & {
49 | role: string;
50 | innerHTML: string;
51 | className: string
52 | } | undefined = Object.assign(document.createElement("div"), {
53 | role: "status",
54 | className: "hljs-copy-alert",
55 | innerHTML: "Copied to clipboard",
56 | });
57 |
58 | parentElement.appendChild(alert);
59 |
60 | setTimeout(() => {
61 | button.innerHTML = "Copy";
62 | button.dataset.copied = 'false';
63 | if (alert) {
64 | parentElement.removeChild(alert);
65 | }
66 | alert = undefined;
67 | }, 2000);
68 | })
69 | .then(() => {
70 | });
71 | };
72 | }
73 | }
--------------------------------------------------------------------------------
/src/home/chat-window/compnent/widget/highlightjs-plugins/language-label-plugin.tsx:
--------------------------------------------------------------------------------
1 | import hljs, {HighlightResult} from "highlight.js";
2 |
3 | /**
4 | * Adds a language label to highlightjs code blocks
5 | */
6 | export class LanguageLabelPlugin {
7 |
8 | constructor() {
9 | }
10 |
11 | "after:highlightElement"({el, result}: { el: Element, result: HighlightResult }) {
12 | if (!el.parentElement) {
13 | return;
14 | }
15 |
16 | if (!result.language || el.parentElement.classList.contains("lang-label-added")) {
17 | return;
18 | }
19 |
20 | let langeName = result.language
21 | if(result.language){
22 | langeName = hljs.getLanguage(result.language)?.name ?? result.language
23 | }
24 |
25 | el.parentElement.classList.add("lang-label-added")
26 | const label = Object.assign(document.createElement("label"), {
27 | innerHTML: langeName,
28 | className: "language-label hljs-string select-none absolute top-1 right-1 opacity-70 text-xs",
29 | });
30 | el.parentElement!.appendChild(label);
31 | }
32 | }
--------------------------------------------------------------------------------
/src/home/chat-window/compnent/widget/icon.tsx:
--------------------------------------------------------------------------------
1 | import React, {CSSProperties} from "react";
2 |
3 | type Props = {
4 | className?: string
5 | onClick?: (event: React.MouseEvent) => void
6 | style?: CSSProperties | undefined
7 | }
8 |
9 | export const MySpin: React.FC = ({className = ""}) => {
10 | return (
11 |
12 |
42 |
43 | )
44 | }
45 |
46 | export const CloseIcon: React.FC = ({className = "", onClick = undefined, style = undefined}) => {
47 | return
53 | }
--------------------------------------------------------------------------------
/src/home/chat-window/message-list/menu.tsx:
--------------------------------------------------------------------------------
1 | import React, {useEffect, useState} from "react"
2 | import {CopyToClipboard} from "react-copy-to-clipboard"
3 | import {MdOutlineContentCopy} from "react-icons/md"
4 | import {cx} from "../../../util/util.tsx"
5 | import {DropDownMenu} from "../compnent/drop-down-menu.tsx"
6 | import {BsTrash3} from "react-icons/bs"
7 | import {audioDb} from "../../../state/db.ts"
8 | import {PiDownloadSimpleLight} from "react-icons/pi"
9 |
10 | type CopyProps = {
11 | text: string
12 | }
13 |
14 | export const Copy: React.FC = ({text}) => {
15 | const [copied, setCopied] = useState(false)
16 | useEffect(() => {
17 | const timer = setTimeout(() => {
18 | setCopied(false)
19 | }, 350)
20 |
21 | return () => {
22 | clearTimeout(timer)
23 | }
24 | }, [copied])
25 | return setCopied(true)}>
27 |
31 |
32 | }
33 |
34 | type TextMenuProps = {
35 | deleteAction: () => void
36 | }
37 |
38 | export const GeneralMenu: React.FC = ({deleteAction}) => {
39 | return
44 | }
45 | ]}/>
46 | }
47 |
48 | type AudioMenuProps = {
49 | deleteAction: () => void
50 | audioId: string
51 | }
52 |
53 | export const AudioMenu: React.FC = ({deleteAction, audioId}) => {
54 | const [url, setUrl] = useState("")
55 |
56 | useEffect(() => {
57 | if (audioId) {
58 | audioDb.getItem(audioId, (err, blob) => {
59 | if (err) {
60 | console.warn("failed to loaded audio blob, audioId:", audioId, err)
61 | return
62 | }
63 | if (blob) {
64 | const url = URL.createObjectURL(blob)
65 | setUrl(url)
66 | } else {
67 | // audio is expected to be empty after restoring from an uploaded json file
68 | // console.error("audio blob is empty, audioId:", audioId)
69 | }
70 | }
71 | ).then(() => true)
72 | }
73 | }, [audioId]
74 | )
75 |
76 | return
84 | }, {
85 | name: "Delete",
86 | action: deleteAction,
87 | icon:
88 | }
89 | ]}/>
90 | }
--------------------------------------------------------------------------------
/src/home/chat-window/message-list/row.tsx:
--------------------------------------------------------------------------------
1 | import {Message} from "../../../data-structure/message.tsx"
2 | import React, {useCallback, useEffect, useState} from "react"
3 | import {markMessageAsDeleted} from "../../../state/app-state.ts"
4 | import {userColor, assistantColor} from "../compnent/theme.ts"
5 | import {cx} from "../../../util/util.tsx"
6 | import {MySpin} from "../compnent/widget/icon.tsx"
7 | import {Audio} from "../compnent/audio.tsx"
8 | import {MyText} from "../compnent/my-text.tsx"
9 | import {PiButterflyThin} from "react-icons/pi"
10 | import {MyError} from "../compnent/my-error.tsx"
11 | import {AudioMenu, Copy, GeneralMenu} from "./menu.tsx"
12 | import {useSnapshot} from "valtio/react";
13 | import {subscribeKey} from "valtio/utils";
14 | import {messageState} from "../../../state/message-state.ts";
15 |
16 | type Props = {
17 | chatId: string
18 | messageProxy: Message
19 | }
20 |
21 | export const Row: React.FC = ({
22 | chatId,
23 | messageProxy: messageProxy,
24 | }) => {
25 | // console.info("Row rendered, messageId:", messageProxy.id, new Date().toLocaleString())
26 | const messageSnap = useSnapshot(messageProxy)
27 | const [theme, setTheme] = useState(assistantColor)
28 | const [hoveringOnRow, setHoveringOnRow] = useState(false)
29 | const [isAttached, setIsAttached] = useState(false)
30 |
31 | useEffect(() => {
32 | const callback = () => {
33 | setIsAttached(messageState.record[messageProxy.id]?.attached ?? false)
34 | }
35 | const un = subscribeKey(messageState.record, messageProxy.id, callback)
36 | callback()
37 | return un
38 | // eslint-disable-next-line react-hooks/exhaustive-deps
39 | }, []);
40 |
41 | const markAsDeleted = useCallback(() => {
42 | markMessageAsDeleted(chatId, messageSnap.id)
43 | }, [chatId, messageSnap.id])
44 |
45 | useEffect(() => {
46 | setTheme(messageSnap.role === "user" ? userColor : assistantColor)
47 | }, [messageSnap.role])
48 |
49 | return (
50 | setHoveringOnRow(true)}
53 | onMouseLeave={() => setHoveringOnRow(false)}
54 | >
55 | {messageSnap.role === "user" && messageSnap.status !== 'thinking' && hoveringOnRow &&
56 |
57 | {
58 | messageSnap.audio ?
59 |
60 | :
61 |
62 | }
63 | {messageSnap.text && }
64 |
65 | }
66 |
67 | {messageSnap.status === 'thinking' &&
68 |
69 | }
70 |
71 | {messageSnap.audio &&
72 |
79 | }
80 | {messageSnap.text &&
81 |
82 |
83 | {isAttached &&
84 |
85 | }
86 |
87 | }
88 | {messageSnap.status === 'error' && !messageSnap.text && !messageSnap.audio &&
89 |
90 |
91 |
92 | }
93 |
94 | {messageSnap.role === "assistant" && messageSnap.status !== 'thinking' && hoveringOnRow &&
95 |
96 | {messageSnap.text &&
}
97 | {
98 | messageSnap.audio ?
99 |
100 | :
101 |
102 | }
103 |
104 | }
105 |
106 | )
107 | }
--------------------------------------------------------------------------------
/src/home/chat-window/prompt-attached.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import {Chat} from "../../state/app-state.ts"
3 | import {escapeSpaceKey} from "../../util/util.tsx";
4 | import {PromptList} from "./prompt/prompt-list.tsx";
5 | import {AttachedPreview} from "./attached/attached-preview.tsx";
6 |
7 | type MLProps = {
8 | chatProxy: Chat
9 | }
10 |
11 | // prompt and attached
12 | export const PromptAttached: React.FC = ({chatProxy}) => {
13 | // console.info("Promptory rendered", new Date().toLocaleString())
14 |
15 | return (
16 |
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/src/home/chat-window/prompt/prompt-editor.tsx:
--------------------------------------------------------------------------------
1 | import React, {useCallback} from "react"
2 | import {Prompt} from "../../../state/promt-state.ts";
3 | import {PromptEditorItem} from "./prompt-editor-item.tsx";
4 | import {Chat} from "../../../state/app-state.ts";
5 | import {useSnapshot} from "valtio/react";
6 | import {PiPlusLight} from "react-icons/pi";
7 | import {cx} from "../../../util/util.tsx";
8 |
9 | type Props = {
10 | chatProxy: Chat
11 | promptProxy: Prompt
12 | }
13 |
14 | export const PromptEditor: React.FC = ({promptProxy}) => {
15 | useSnapshot(promptProxy)
16 |
17 | const add = useCallback(() => {
18 | promptProxy.messages.push({role: "user", content: ""})
19 | }, [promptProxy.messages]);
20 |
21 | return (
22 |
23 | {promptProxy.messages.length > 0 ?
24 | promptProxy.messages.map((m, index) =>
25 |
26 | )
27 | :
28 |
31 | }
32 |
33 | )
34 | }
--------------------------------------------------------------------------------
/src/home/chat-window/prompt/prompt-item.tsx:
--------------------------------------------------------------------------------
1 | import React, {useCallback, useState} from "react"
2 | import {CloseIcon} from "../compnent/widget/icon.tsx";
3 | import {cx} from "../../../util/util.tsx";
4 | import {clonePrompt, deletePrompt, Prompt, promptCountState, syncPromptIdCounts} from "../../../state/promt-state.ts";
5 | import {Chat} from "../../../state/app-state.ts";
6 | import {useSnapshot} from "valtio/react";
7 | import {MdOutlineContentCopy} from "react-icons/md";
8 | import {capitalize} from "lodash";
9 |
10 | type Props = {
11 | chatProxy: Chat
12 | prompt: Prompt
13 | }
14 |
15 | export const PromptItem: React.FC = ({chatProxy, prompt}) => {
16 | const {promptId} = useSnapshot(chatProxy)
17 | const {counts} = useSnapshot(promptCountState)
18 | const {id, name, messages} = useSnapshot(prompt)
19 | const [over, setOver] = useState(false)
20 |
21 | const select = useCallback(() => {
22 | chatProxy.promptId = prompt.id
23 | syncPromptIdCounts()
24 | // eslint-disable-next-line react-hooks/exhaustive-deps
25 | }, []);
26 |
27 | const clone = useCallback((event: React.MouseEvent) => {
28 | event.stopPropagation()
29 | clonePrompt(prompt)
30 | syncPromptIdCounts()
31 | // eslint-disable-next-line react-hooks/exhaustive-deps
32 | }, []);
33 |
34 | const delete_ = useCallback((event: React.MouseEvent) => {
35 | event.stopPropagation()
36 | deletePrompt(prompt.id)
37 | if (chatProxy.promptId === prompt.id) {
38 | chatProxy.promptId = ""
39 | }
40 | syncPromptIdCounts()
41 | // eslint-disable-next-line react-hooks/exhaustive-deps
42 | }, []);
43 | return (
44 | setOver(true)}
47 | onMouseLeave={() => setOver(false)}
48 | className={cx("relative flex justify-between items-center gap-0.5 w-full px-1 h-14 font-medium",
49 | "rounded-lg transition-all duration-100 bg-white select-none",
50 | promptId === id ? "bg-opacity-75" : "bg-opacity-40",
51 | promptId !== id && "hover:bg-neutral-100/[0.6]",
52 | )}>
53 |
55 |
58 |
59 |
60 | {messages.length > 0 &&
61 |
{capitalize(messages[0].role)}: {messages[0].content}
62 | }
63 |
64 |
65 |
66 | {over &&
67 |
68 |
71 |
74 |
75 | }
76 | {(counts[id] ?? 0) > 0 &&
77 |
78 |
80 | {counts[id]}
81 |
82 |
83 | }
84 |
85 |
86 | )
87 | }
88 |
--------------------------------------------------------------------------------
/src/home/home.tsx:
--------------------------------------------------------------------------------
1 | import {Panel} from "./panel/panel.tsx"
2 | import {SSE} from "../api/sse/sse.tsx"
3 | import {Workers} from "../worker/workers.tsx"
4 | import {WindowListeners} from "../window-listeners.tsx"
5 | import {ChatWindow} from "./chat-window/chat-window.tsx"
6 | import {useEffect} from "react"
7 | import {useSnapshot} from "valtio/react"
8 | import {appState, hydrationState} from "../state/app-state.ts"
9 | import {useNavigate} from "react-router-dom"
10 | import {Helmet} from 'react-helmet-async'
11 | import {TheWallpaper} from "../wallpaper/wallpaper.tsx"
12 |
13 |
14 | export default function Home() {
15 | const {hydrated} = useSnapshot(hydrationState)
16 | const {auth} = useSnapshot(appState)
17 | const navigate = useNavigate()
18 |
19 | useEffect(() => {
20 | if (hydrationState.hydrated && !appState.auth.loggedIn) {
21 | navigate("/auth")
22 | }
23 | }, [hydrated, auth, navigate])
24 | // console.info("Home rendered", new Date().toLocaleString())
25 |
26 | return (
27 |
28 |
29 | Let's Talk
30 |
31 |
32 | {hydrated &&
33 | <>
34 |
41 |
42 |
43 |
44 | >
45 | }
46 |
47 | )
48 | }
49 |
--------------------------------------------------------------------------------
/src/home/panel/chat-list/avatar.tsx:
--------------------------------------------------------------------------------
1 | import React, {useEffect, useState} from "react"
2 | import Avatar, {genConfig, GlassesStyle, HairStyle} from 'react-nice-avatar'
3 |
4 | type Props = {
5 | id: string
6 | }
7 |
8 | const avatarStyle = {
9 | width: '2rem',
10 | height: '2rem',
11 | minWidth: '2rem',
12 | minHeight: '2rem',
13 | }
14 | const dftConf = genConfig()
15 |
16 | export const TalkAvatar: React.FC = ({id}) => {
17 | const [avatarConf,setAvatarConf] = useState(dftConf)
18 | useEffect(() => {
19 | const conf =createAvatarConf(id)
20 | setAvatarConf(conf)
21 | }, [id])
22 | return
23 | }
24 |
25 | const createAvatarConf = (id: string) => {
26 | const conf = genConfig(id)
27 | if (conf.sex === 'man') {
28 | // do you like mohawk? voting: (👍:0 👎:1)
29 | conf.hairStyle = ['normal', 'thick'][id[0].charCodeAt(0) % 2] as HairStyle
30 | } else {
31 | conf.hairStyle = ['womanLong', 'womanShort'][id[1].charCodeAt(0) % 2] as HairStyle
32 | }
33 | conf.hatStyle = 'none'
34 | if (id[2].charCodeAt(0) % 3 == 0) {
35 | conf.glassesStyle = ['round', 'square'][id[3].charCodeAt(0) % 2] as GlassesStyle
36 | }
37 | if (id[4].charCodeAt(0) % 3 == 0) {
38 | conf.isGradient = true // has no impact as far as I see
39 | }
40 | return conf
41 | }
--------------------------------------------------------------------------------
/src/home/panel/chat-list/chat-list.tsx:
--------------------------------------------------------------------------------
1 | import React, {memo, useCallback, useEffect, useRef, useState} from "react"
2 | import {useSnapshot} from "valtio/react"
3 | import {subscribe} from "valtio"
4 | import {PiPlusLight} from "react-icons/pi"
5 | import {CiSearch} from "react-icons/ci"
6 | import {appState, Chat, createChat} from "../../../state/app-state.ts"
7 | import {DndProvider} from "react-dnd"
8 | import {HTML5Backend} from "react-dnd-html5-backend"
9 | import {DraggableChat} from "./draggable-chat.tsx"
10 | import {motion} from "framer-motion"
11 |
12 | const animation = {
13 | x: [0, -10, 10, -10, 10, 0],
14 | y: [0, 0, 0, 0, 0, 0],
15 | }
16 |
17 | const ChatList_ = () => {
18 | const {currentChatId} = useSnapshot(appState)
19 | const chatRef = useRef(null)
20 | const [chats, setChats] = useState([])
21 | const [showSearch, setShowSearch] = useState(true)
22 |
23 | useEffect(() => {
24 | const callback = () => {
25 | // only rerender when chat list size changed, or reordering happened(triggered by drag and drop)
26 | // never rerender the whole chat list when chat message changes
27 | if (appState.chats.length !== chats.length) {
28 | setChats(appState.chats.slice())
29 | } else {
30 | for (let i = 0; i < chats.length; i++) {
31 | if (appState.chats[i]?.id != chats[i].id) {
32 | setChats(appState.chats.slice())
33 | return
34 | }
35 | }
36 | }
37 | }
38 | const unsubscribe = subscribe(appState.chats, callback)
39 | callback()
40 | return unsubscribe
41 | // eslint-disable-next-line react-hooks/exhaustive-deps
42 | }, [chats.length])
43 |
44 | const newChat = useCallback((e: React.MouseEvent) => {
45 | e.stopPropagation()
46 | createChat("New Chat", [])
47 | // at this point, we suppose the user has see the demo chat
48 | appState.pref.dismissDemo = true
49 | }, [])
50 |
51 | // delete other chats should not trigger auto scrolling
52 | useEffect(() => {
53 | setTimeout(() => {
54 | if (chatRef.current) {
55 | chatRef.current.scrollIntoView({
56 | behavior: "smooth",
57 | block: "nearest"
58 | })
59 | }
60 | }
61 | )
62 | }, [currentChatId])
63 |
64 | return (
65 |
66 |
67 |
setShowSearch(!showSearch)}
69 | className="mr-auto flex w-full items-center justify-center gap-2 rounded-xl bg-white bg-opacity-40 backdrop-blur">
70 | {showSearch &&
}
71 | {showSearch &&
Search
}
72 | {!showSearch &&
73 |
77 | Come see me in '24 😬
78 |
79 | }
80 |
81 |
e.stopPropagation()}
86 | onMouseUp={e => e.stopPropagation()}
87 | >
88 |
89 |
90 |
91 |
93 |
95 |
96 | {chats.map((chatProxy, index) =>
97 |
99 |
100 |
101 | )}
102 |
103 |
104 |
105 |
106 | )
107 | }
108 |
109 | export const ChatList = memo(ChatList_)
--------------------------------------------------------------------------------
/src/home/panel/chat-list/draggable-chat.tsx:
--------------------------------------------------------------------------------
1 | import React, {useEffect, useRef} from "react"
2 | import {Chat, dragChat} from "../../../state/app-state.ts"
3 | import {useDrag, useDrop} from "react-dnd"
4 | import {Identifier, XYCoord} from "dnd-core"
5 | import {ChatComponent} from "./chat-component.tsx"
6 | import {cx} from "../../../util/util.tsx"
7 | import {controlState} from "../../../state/control-state.ts"
8 | import {DragSourceMonitor} from "react-dnd/src/types"
9 |
10 | export const ItemTypes = {
11 | CARD: 'card',
12 | }
13 |
14 | export interface DragItem {
15 | index: number
16 | chat: Chat,
17 | type: string
18 | }
19 |
20 | type Props = {
21 | chatProxy: Chat
22 | index: number
23 | }
24 |
25 | export const DraggableChat: React.FC = ({chatProxy, index}) => {
26 | const ref = useRef(null)
27 | const [{handlerId}, drop] = useDrop<
28 | DragItem,
29 | void,
30 | { handlerId: Identifier | null }
31 | >({
32 | accept: ItemTypes.CARD,
33 | collect(monitor) {
34 | return {
35 | handlerId: monitor.getHandlerId(),
36 | }
37 | },
38 | hover(item: DragItem, monitor) {
39 | if (!ref.current) {
40 | return
41 | }
42 | const dragIndex = item.index
43 | const hoverIndex = index
44 |
45 | // Don't replace items with themselves
46 | if (dragIndex === hoverIndex) {
47 | return
48 | }
49 |
50 | // Determine rectangle on screen
51 | const hoverBoundingRect = ref.current?.getBoundingClientRect()
52 |
53 | // Get vertical middle
54 | const hoverMiddleY =
55 | (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2
56 |
57 | // Determine mouse position
58 | const clientOffset = monitor.getClientOffset()
59 |
60 | // Get pixels to the top
61 | const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top
62 |
63 | // Only perform the move when the mouse has crossed half of the items height
64 | // When dragging downwards, only move when the cursor is below 50%
65 | // When dragging upwards, only move when the cursor is above 50%
66 |
67 | // Dragging downwards
68 | if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
69 | return
70 | }
71 |
72 | // Dragging upwards
73 | if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
74 | return
75 | }
76 |
77 | // Time to actually perform the action
78 | dragChat(dragIndex, hoverIndex)
79 |
80 | // Note: we're mutating the monitor item here!
81 | // Generally it's better to avoid mutations,
82 | // but it's good here for the sake of performance
83 | // to avoid expensive index searches.
84 | item.index = hoverIndex
85 | },
86 | })
87 |
88 | const [{isDragging}, drag] = useDrag({
89 | type: ItemTypes.CARD,
90 | item: () => {
91 | return {chatProxy: chatProxy, index}
92 | },
93 | collect: (monitor: DragSourceMonitor) => ({
94 | isDragging: monitor.isDragging(),
95 | }),
96 | })
97 |
98 | useEffect(() => {
99 | controlState.isMouseDragging = isDragging
100 | if (!isDragging) {
101 | // there seem to be a bug when using react-dnd: window onMouseUp listener is not called after drop
102 | controlState.isMouseLeftDown = false
103 | }
104 | }, [isDragging])
105 |
106 | drag(drop(ref))
107 | return (
108 |
113 |
114 |
115 | )
116 | }
117 |
--------------------------------------------------------------------------------
/src/home/panel/chat-list/preview.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import {BsSoundwave} from "react-icons/bs"
3 | import {Chat} from "../../../state/app-state.ts"
4 | import {MySpin} from "../../chat-window/compnent/widget/icon.tsx"
5 | import {useSnapshot} from "valtio/react";
6 |
7 | type Props = {
8 | chatProxy: Chat
9 | }
10 |
11 | export const Preview: React.FC = ({chatProxy}) => {
12 | // console.info("Preview rendered, chatId", chatProxy.id, new Date().toLocaleString())
13 | const {messages} = useSnapshot(chatProxy)
14 |
15 | if (messages.length === 0) {
16 | return null
17 | }
18 | const message = messages[messages.length - 1]
19 |
20 | return
21 |
{message.role == "user" ? "You" : "Assistant"}:
22 | {["sending", "thinking"].includes(message.status) &&
}
23 | {["sent", "typing", "received"].includes(message.status) &&
24 | (message.audio ?
25 |
26 | :
27 |
{message.text.slice(0, 20)}
28 | )
29 | }
30 | {"error" === message.status &&
☹️
}
31 |
32 | }
33 |
34 |
--------------------------------------------------------------------------------
/src/home/panel/current/current.tsx:
--------------------------------------------------------------------------------
1 | import React, {memo, useEffect, useState} from 'react'
2 | import {LLM} from "../shared/llm/llm.tsx"
3 | import {TTS} from "../shared/tts/tts.tsx"
4 | import {STT} from "../shared/stt/stt.tsx"
5 | import {OtherSetting} from "./other-setting.tsx"
6 | import {useSnapshot} from "valtio/react"
7 | import {appState, Chat, currentChatProxy} from "../../../state/app-state.ts"
8 |
9 | const Current_: React.FC = () => {
10 | // console.info("Current rendered", new Date().toLocaleString())
11 | const {currentChatId} = useSnapshot(appState)
12 | const [chatProxy, setChatProxy] = useState()
13 | useEffect(() => {
14 | setChatProxy(currentChatProxy())
15 | }, [currentChatId])
16 |
17 | if (chatProxy) {
18 | return (
19 | <>
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | >
33 | )
34 | }else{
35 | return <>>
36 | }
37 | }
38 |
39 | export const Current = memo(Current_)
--------------------------------------------------------------------------------
/src/home/panel/current/other-setting.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import {Chat, clearMessages} from "../../../state/app-state.ts"
3 | import {CountDownButton} from "../shared/widget/button.tsx"
4 | import {BsTrash3} from "react-icons/bs"
5 | import {useSnapshot} from "valtio/react"
6 |
7 | type Props = {
8 | chatProxy: Chat
9 | }
10 |
11 | export const OtherSetting: React.FC = ({chatProxy}) => {
12 | useSnapshot(chatProxy)
13 | return
16 |
19 |
21 | clearMessages(chatProxy)}
25 | icon={}
26 | />
27 |
28 |
29 | }
30 |
--------------------------------------------------------------------------------
/src/home/panel/global/global.tsx:
--------------------------------------------------------------------------------
1 | import React, {memo} from 'react'
2 | import {LLM} from "../shared/llm/llm.tsx"
3 | import {TTS} from "../shared/tts/tts.tsx"
4 | import {STT} from "../shared/stt/stt.tsx"
5 | import {OtherSetting} from "./other-setting.tsx"
6 | import {appState} from "../../../state/app-state.ts";
7 | import {ShortcutsSetting} from "./shortcuts-setting.tsx";
8 |
9 | const Global_: React.FC = () => {
10 | // console.info("Global rendered", new Date().toLocaleString())
11 | return (
12 | <>
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | >
29 | )
30 | }
31 |
32 | export const Global = memo(Global_)
33 |
--------------------------------------------------------------------------------
/src/home/panel/panel.tsx:
--------------------------------------------------------------------------------
1 | import React, {useCallback, useState} from 'react'
2 | import {useSnapshot} from "valtio/react"
3 | import {appState, PanelSelection} from "../../state/app-state.ts"
4 | import {ChatList} from "./chat-list/chat-list.tsx"
5 | import {cx, escapeSpaceKey} from "../../util/util.tsx"
6 | import {Global} from "./global/global.tsx"
7 | import {Current} from "./current/current.tsx"
8 | import {layoutState} from "../../state/layout-state.ts"
9 |
10 | export const Panel: React.FC = () => {
11 | // console.info("Panel rendered", new Date().toLocaleString())
12 |
13 | const {currentChatId, panelSelection} = useSnapshot(appState)
14 | const [isMouseDown, setIsMouseDown] = useState(false)
15 |
16 | const onMouseUpOrDown = useCallback((p: PanelSelection) => {
17 | appState.panelSelection = p
18 | }, [])
19 |
20 | const onMouseEnter = useCallback((p: PanelSelection) => {
21 | if (isMouseDown) {
22 | appState.panelSelection = p
23 | }
24 | }, [isMouseDown])
25 |
26 | const handleScroll = useCallback((e: React.UIEvent) => {
27 | layoutState.settingPanelScrollOffset = e.currentTarget.scrollTop
28 | }, [])
29 |
30 | return (
31 | setIsMouseDown(true)}
34 | onMouseUp={() => setIsMouseDown(false)}
35 | onMouseLeave={() => setIsMouseDown(false)}
36 | onBlur={() => setIsMouseDown(false)}
37 | >
38 |
40 |
onMouseUpOrDown("chats")}
44 | onMouseDown={(e) => {
45 | e.stopPropagation()
46 | onMouseUpOrDown("chats")
47 | }}
48 | onMouseEnter={() => onMouseEnter("chats")}
49 | >
50 |
Chats
51 |
52 |
onMouseUpOrDown("global")}
56 | onMouseDown={() => onMouseUpOrDown("global")}
57 | onMouseEnter={() => onMouseEnter("global")}
58 | >
59 |
Global
60 |
61 |
onMouseUpOrDown("current")}
68 | onMouseDown={() => onMouseUpOrDown("current")}
69 | onMouseEnter={() => onMouseEnter("current")}
70 | >
71 |
Current
72 |
73 |
74 |
75 | {/* utilizing hidden to avoid flashing animation on panel selection*/}
76 |
83 |
84 |
85 |
86 |
93 |
94 | {ninja}
95 |
96 |
103 | {currentChatId !== "" &&
104 |
105 | }
106 | {ninja}
107 |
108 |
109 | )
110 | }
111 |
112 | const ninja =
113 | (
116 | )
--------------------------------------------------------------------------------
/src/home/panel/shared/llm/llm.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react-hooks/exhaustive-deps */
2 | import React, {useCallback, useEffect} from 'react'
3 | import ChatGpt from "./chat-gpt.tsx"
4 | import {LLMOption} from "../../../../data-structure/client-option.tsx"
5 | import {useSnapshot} from "valtio/react"
6 | import Gemini from "./gemini.tsx";
7 |
8 | type Props = {
9 | llmOptionProxy: LLMOption
10 | }
11 |
12 | export const LLM: React.FC = ({llmOptionProxy}) => {
13 | const llmSnap = useSnapshot(llmOptionProxy)
14 |
15 | const disableAll = useCallback(() => {
16 | const switchable = [llmOptionProxy.chatGPT, llmOptionProxy.gemini]
17 | switchable.forEach(it => it.enabled = false)
18 | }, [llmSnap])
19 |
20 | // Only one can be enabled simultaneously
21 | useEffect(() => {
22 | const switchable = [llmOptionProxy.chatGPT, llmOptionProxy.gemini]
23 | const available = switchable.filter(it => it.available)
24 | const enabled = available.filter(it => it.enabled)
25 | if (enabled.length > 1) {
26 | enabled.slice(1).forEach(e => e.enabled = false)
27 | }
28 | })
29 |
30 | return (
31 |
33 |
34 |
Large Language Model
35 |
36 | {llmSnap.chatGPT.available &&
37 |
{
40 | if (enabled) {
41 | disableAll()
42 | }
43 | llmOptionProxy.chatGPT.enabled = enabled
44 | }}
45 | />}
46 | {llmSnap.gemini.available &&
47 | {
50 | if (enabled) {
51 | disableAll()
52 | }
53 | llmOptionProxy.gemini.enabled = enabled
54 | }}
55 | />}
56 |
57 | )
58 | }
--------------------------------------------------------------------------------
/src/home/panel/shared/select-box-or-not-available.tsx:
--------------------------------------------------------------------------------
1 | import {SelectBox} from "./widget/select-box.tsx"
2 | import {Choice} from "../../../data-structure/provider-api-refrence/types.ts"
3 |
4 |
5 | type Props = {
6 | title: string
7 | choices: Choice[]
8 | defaultValue?: T
9 | setValue: (value?: T) => void
10 | hoverOnValue?: (value?: T) => void
11 | }
12 |
13 | export function SelectBoxOrNotAvailable({title, choices, defaultValue, setValue,hoverOnValue}: Props) {
14 | return (
15 |
16 |
{title}
17 | {defaultValue !== undefined && choices.length > 0 ?
18 |
19 |
25 |
26 | :
27 |
30 | }
31 |
32 | )
33 | }
--------------------------------------------------------------------------------
/src/home/panel/shared/stt/google-stt.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react-hooks/exhaustive-deps */
2 | import React, {useCallback, useEffect, useState} from 'react'
3 | import {MySwitch} from "../widget/switch.tsx"
4 | import {useSnapshot} from "valtio/react"
5 | import {GoogleOption,} from "../../../../data-structure/client-option.tsx"
6 | import {appState} from "../../../../state/app-state.ts"
7 | import {SelectBoxOrNotAvailable} from "../select-box-or-not-available.tsx"
8 | import {Choice, emptyStringChoice} from "../../../../data-structure/provider-api-refrence/types.ts"
9 | import {googleSTTAPIReference} from "../../../../data-structure/provider-api-refrence/google-stt.ts"
10 | import { GoogleLogo } from '../widget/logo.tsx'
11 | import {map} from "lodash";
12 |
13 | type Props = {
14 | googleOptionProxy: GoogleOption
15 | setEnabled: (enabled: boolean) => void
16 | }
17 |
18 | const GoogleStt: React.FC = ({googleOptionProxy, setEnabled}) => {
19 | const googleOptionSnap = useSnapshot(googleOptionProxy)
20 | const googleAbilitySnap = useSnapshot(appState.ability.stt.google)
21 | const [recognizerChoices, setRecognizerChoices] = useState[]>([emptyStringChoice])
22 |
23 | const setRecognizer = useCallback((rec?: string) => {
24 | googleOptionProxy.recognizer = rec ?? ""
25 | }, [googleOptionSnap])
26 |
27 | const setModel = useCallback((model?: string) => {
28 | googleOptionProxy.model = model ?? ""
29 | }, [googleOptionSnap])
30 |
31 | const setLanguage = useCallback((lang?: string) => {
32 | googleOptionProxy.language = lang ?? ""
33 | }, [googleOptionSnap])
34 |
35 | useEffect(() => {
36 | // eslint-disable-next-line valtio/state-snapshot-rule
37 | const choices = map(googleAbilitySnap.recognizers, r => ({
38 | name: r.id,
39 | value: r.name,
40 | tags: map(r.tags, t => t)
41 | }))
42 | setRecognizerChoices(choices)
43 | }, [googleAbilitySnap])
44 |
45 | return (
46 |
47 |
50 |
51 |
52 |
53 |
54 | {googleOptionSnap.enabled &&
55 | <>
56 |
61 |
66 |
71 | >
72 | }
73 |
74 |
75 | )
76 | }
77 |
78 | export default GoogleStt
79 |
80 |
--------------------------------------------------------------------------------
/src/home/panel/shared/stt/stt.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react-hooks/exhaustive-deps */
2 | import React, {useCallback, useEffect} from 'react'
3 | import {useSnapshot} from "valtio/react"
4 | import {STTOption,} from "../../../../data-structure/client-option.tsx"
5 | import Whisper from "./whisper.tsx"
6 | import GoogleStt from "./google-stt.tsx"
7 |
8 | type Props = {
9 | sttOptionProxy: STTOption
10 | }
11 |
12 | export const STT: React.FC = ({sttOptionProxy}) => {
13 | const sttOptionSnap = useSnapshot(sttOptionProxy)
14 | const whisperSnap = useSnapshot(sttOptionProxy.whisper)
15 | const googleSnap = useSnapshot(sttOptionProxy.google)
16 |
17 | const disableAll = useCallback(() => {
18 | const switchable = [sttOptionProxy.whisper, sttOptionProxy.google]
19 | switchable.forEach(it => it.enabled = false)
20 | }, [sttOptionSnap])
21 |
22 | // Only one can be enabled simultaneously
23 | useEffect(() => {
24 | const switchable = [sttOptionProxy.whisper, sttOptionProxy.google]
25 | const available = switchable.filter(it => it.available)
26 | const enabled = available.filter(it => it.enabled)
27 | if (enabled.length > 1) {
28 | enabled.slice(1).forEach(e => e.enabled = false)
29 | }
30 | })
31 |
32 | return (
33 |
35 |
38 | {whisperSnap.available &&
39 |
{
42 | if (enabled) {
43 | disableAll()
44 | }
45 | sttOptionProxy.whisper.enabled = enabled
46 | }}
47 | />}
48 | {googleSnap.available &&
49 | {
52 | if (enabled) {
53 | disableAll()
54 | }
55 | sttOptionProxy.google.enabled = enabled
56 | }}
57 | />}
58 |
59 | )
60 | }
--------------------------------------------------------------------------------
/src/home/panel/shared/stt/whisper.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react-hooks/exhaustive-deps */
2 | import React, {useCallback} from 'react'
3 | import {MySwitch} from "../widget/switch.tsx"
4 | import {useSnapshot} from "valtio/react"
5 | import {WhisperOption,} from "../../../../data-structure/client-option.tsx"
6 | import {appState} from "../../../../state/app-state.ts"
7 | import {SelectBoxOrNotAvailable} from "../select-box-or-not-available.tsx"
8 | import {WhisperGPTLogo} from "../widget/logo.tsx"
9 | import {map} from "lodash";
10 |
11 | type Props = {
12 | whisperOptionProxy: WhisperOption
13 | setEnabled: (enabled: boolean) => void
14 | }
15 |
16 | const Whisper: React.FC = ({whisperOptionProxy, setEnabled}) => {
17 | const whisperOptionSnap = useSnapshot(whisperOptionProxy)
18 | const whisperAbilitySnap = useSnapshot(appState.ability.stt.whisper)
19 |
20 | const setModel = useCallback((model?: string) => {
21 | whisperOptionProxy.model = model ?? ""
22 | }, [whisperOptionSnap])
23 |
24 | return (
25 |
26 |
29 |
30 |
31 |
32 |
33 | {whisperOptionSnap.enabled &&
34 | <>
35 |
({
38 | name: m,
39 | value: m,
40 | tags: []
41 | }))}
42 | defaultValue={whisperOptionSnap.model}
43 | setValue={setModel}/>
44 | >
45 | }
46 |
47 |
48 | )
49 | }
50 |
51 | export default Whisper
52 |
53 |
--------------------------------------------------------------------------------
/src/home/panel/shared/tts/elevenlabs.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react-hooks/exhaustive-deps */
2 | import React, {useCallback, useEffect, useState} from 'react'
3 | import {MySwitch} from "../widget/switch.tsx"
4 | import {useSnapshot} from "valtio/react"
5 | import {Choice} from "../../../../data-structure/provider-api-refrence/types.ts"
6 | import {SliderRange} from "../widget/slider-range.tsx"
7 | import {ElevenlabsTTSOption,} from "../../../../data-structure/client-option.tsx"
8 | import {appState} from "../../../../state/app-state.ts"
9 | import {elevenlabsAPIReference} from "../../../../data-structure/provider-api-refrence/elevenlabs-tts.ts"
10 | import {SelectBoxOrNotAvailable} from "../select-box-or-not-available.tsx"
11 | import {ElevenlabsLogo} from "../widget/logo.tsx"
12 | import {map, uniq} from "lodash";
13 |
14 | type Props = {
15 | elevenlabsTTSOptionProxy: ElevenlabsTTSOption
16 | setEnabled: (enabled: boolean) => void
17 | }
18 |
19 | const ElevenlabsTTS: React.FC = ({elevenlabsTTSOptionProxy, setEnabled}) => {
20 | const elevenlabsTTSSnap = useSnapshot(elevenlabsTTSOptionProxy)
21 | const elevenlabsAbilitySnap = useSnapshot(appState.ability.tts.elevenlabs)
22 |
23 | const [voiceChoices, setVoicesChoice] = useState[]>([])
24 |
25 | useEffect(() => {
26 | // eslint-disable-next-line valtio/state-snapshot-rule
27 | const voices = map(elevenlabsAbilitySnap.voices,
28 | v => ({
29 | name: v.name,
30 | value: v.id,
31 | tags: uniq(v.tags).map(tag => tag)
32 | }))
33 | setVoicesChoice(voices)
34 | }, [elevenlabsAbilitySnap])
35 |
36 | const setVoice = useCallback((voiceId?: string) => {
37 | elevenlabsTTSOptionProxy.voiceId = voiceId
38 | }, [elevenlabsTTSSnap])
39 |
40 | const setStability = useCallback((stability: number) => {
41 | elevenlabsTTSOptionProxy.stability = stability
42 | }, [elevenlabsTTSSnap])
43 |
44 | const setClarity = useCallback((clarity: number) => {
45 | elevenlabsTTSOptionProxy.clarity = clarity
46 | }, [elevenlabsTTSSnap])
47 |
48 | return (
49 |
50 |
53 |
54 |
55 |
56 |
57 | {elevenlabsTTSSnap.enabled &&
58 | <>
59 |
64 |
72 |
73 |
81 |
82 | >
83 | }
84 |
85 |
86 | )
87 | }
88 |
89 | export default ElevenlabsTTS
90 |
91 |
--------------------------------------------------------------------------------
/src/home/panel/shared/tts/tts.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react-hooks/exhaustive-deps */
2 | import React, {useCallback, useEffect} from 'react'
3 | import {useSnapshot} from "valtio/react"
4 | import {TTSOption} from "../../../../data-structure/client-option.tsx"
5 | import GoogleTTS from "./google-tts.tsx"
6 | import ElevenlabsTTS from "./elevenlabs.tsx"
7 |
8 | type Props = {
9 | ttsOptionProxy: TTSOption
10 | }
11 |
12 | export const TTS: React.FC = ({ttsOptionProxy}) => {
13 | const ttsOptionSnapshot = useSnapshot(ttsOptionProxy)
14 | const googleSnap = useSnapshot(ttsOptionProxy.google)
15 | const elevenlabsSnap = useSnapshot(ttsOptionProxy.elevenlabs)
16 |
17 | const disableAll = useCallback(() => {
18 | const switchable = [ttsOptionProxy.google, ttsOptionProxy.elevenlabs]
19 | switchable.forEach(it => it.enabled = false)
20 | }, [ttsOptionSnapshot])
21 |
22 | // Only one can be enabled simultaneously
23 | useEffect(() => {
24 | const switchable = [ttsOptionProxy.google, ttsOptionProxy.elevenlabs]
25 | const available = switchable.filter(it => it.available)
26 | const enabled = available.filter(it => it.enabled)
27 | if (enabled.length > 1) {
28 | enabled.slice(1).forEach(e => e.enabled = false)
29 | }
30 | }, [])
31 |
32 | return (
33 |
35 |
38 | {googleSnap.available &&
39 |
{
42 | if (enabled) {
43 | disableAll()
44 | }
45 | ttsOptionProxy.google.enabled = enabled
46 | }}
47 | />}
48 | {elevenlabsSnap.available &&
49 | {
52 | if (enabled) {
53 | disableAll()
54 | }
55 | ttsOptionProxy.elevenlabs.enabled = enabled
56 | }}
57 |
58 | />}
59 |
60 | )
61 | }
--------------------------------------------------------------------------------
/src/home/panel/shared/widget/button.tsx:
--------------------------------------------------------------------------------
1 | import React, {useCallback, useState} from "react"
2 | import Countdown from "react-countdown"
3 | import {useNavigate} from "react-router-dom"
4 | import {BsBootstrapReboot} from "react-icons/bs"
5 | import {cx} from "../../../../util/util.tsx"
6 | import {resetEverything} from "../../../../state/dangerous.ts";
7 |
8 | type Color = 'red' | 'blue' | 'black'
9 |
10 | type Props = {
11 | text: string
12 | countDownMs: number
13 | color: Color
14 | icon?: React.JSX.Element
15 | action?: () => void
16 | }
17 |
18 | export const CountDownButton: React.FC = ({
19 | text,
20 | countDownMs,
21 | icon,
22 | color,
23 | action,
24 | }) => {
25 |
26 | const [holding, setHolding] = useState(false)
27 |
28 | return {
38 | if (e.button === 0) {
39 | // only respond to left-click
40 | setHolding(true)
41 | }
42 | }}
43 | onMouseUp={() => setHolding(false)}
44 | onMouseLeave={() => setHolding(false)}
45 | onContextMenu={(e) => {
46 | e.preventDefault()
47 | setHolding(false)
48 | }}>
49 | {icon}
50 |
51 |
52 |
53 | {holding &&
{
56 | setHolding(false)
57 | if (action) {
58 | action()
59 | }
60 | }}
61 | intervalDelay={0}
62 | precision={1}
63 | autoStart={true}
64 | renderer={props => {props.total / 1000}
}
66 | />}
67 |
68 |
69 | {text}
70 |
71 |
72 |
73 | }
74 |
75 | type CountDwnProps = {
76 | countDownMs: number
77 | }
78 |
79 | export const ResetButton: React.FC = ({countDownMs = 2000}) => {
80 | const navigate = useNavigate()
81 |
82 | const reset = useCallback(() => {
83 | resetEverything(() => navigate("/"))
84 | }, [navigate])
85 |
86 | return }
91 | />
92 | }
93 |
--------------------------------------------------------------------------------
/src/home/panel/shared/widget/logo.tsx:
--------------------------------------------------------------------------------
1 | ///
2 | // see https://github.com/pd4d10/vite-plugin-svgr#usage
3 | import GoogleSVG from "../../../../assets/svg/Google_2015_logo.svg?react"
4 | import OpenAISVG from "../../../../assets/svg/openai-icon.svg?react"
5 | import GeminiIco from "../../../../assets/svg/gemini.ico"
6 |
7 | import {HiPause} from "react-icons/hi2"
8 |
9 | export const GoogleLogo = () =>
10 |
11 | export const ChatGPTLogo = () => (
12 |
16 | )
17 |
18 | export const GeminiLogo = () => (
19 |
20 |

23 |
Gemini
24 |
25 | )
26 |
27 | export const WhisperGPTLogo = () => (
28 |
32 | )
33 | export const ElevenlabsLogoSimple = () =>
34 | export const ElevenlabsLogoPureText = () => (
35 |
38 | IIElevenLabs
39 |
40 | )
41 |
42 | export const ElevenlabsLogo = () => (
43 |
44 |
45 |
Elevenlabs
46 |
47 | )
48 |
--------------------------------------------------------------------------------
/src/home/panel/shared/widget/select-box.tsx:
--------------------------------------------------------------------------------
1 | import {useSelect} from "downshift"
2 | import {cx} from "../../../../util/util.tsx"
3 | import {Choice} from "../../../../data-structure/provider-api-refrence/types.ts"
4 | import {useEffect, useRef, useState} from "react"
5 | import {layoutState} from "../../../../state/layout-state.ts"
6 | import {useSnapshot} from "valtio/react"
7 | import {HiCheck} from "react-icons/hi2"
8 |
9 | type Props = {
10 | choices: Choice[]
11 | defaultValue?: T
12 | setValue: (value?: T) => void
13 | // set value if hovering, set undefined if not hovering on any
14 | hoverOnValue?:(value?: T) => void
15 | }
16 |
17 | export function SelectBox({choices, defaultValue, setValue, hoverOnValue}: Props) {
18 | const {
19 | isOpen,
20 | selectedItem,
21 | getToggleButtonProps,
22 | getMenuProps,
23 | highlightedIndex,
24 | getItemProps,
25 | selectItem
26 | } = useSelect({
27 | defaultSelectedItem: choices[0],
28 | items: choices,
29 | itemToString:
30 | c => c?.name ?? " ",
31 | onSelectedItemChange:
32 | ({selectedItem: newSelectedItem}) => setValue(newSelectedItem?.value)
33 | })
34 |
35 | useEffect(() => {
36 | const found = choices.find(c => c.value === defaultValue)
37 | if (found) {
38 | selectItem(found)
39 | setValue(found.value)
40 | }
41 | // eslint-disable-next-line react-hooks/exhaustive-deps
42 | }, [defaultValue, choices])
43 |
44 | const layoutSnap = useSnapshot(layoutState)
45 | const buttonRef = useRef(null)
46 | const [currentButtonBottom, setCurrentButtonBottom] = useState(0)
47 | const [buttonWidth, setButtonWidth] = useState(0)
48 | // set init state for UL, or UL flashes on top of screen on first open
49 | useEffect(() => {
50 | if (buttonRef.current) {
51 | const {bottom, left, right} = buttonRef.current.getBoundingClientRect()
52 | setButtonWidth(right - left)
53 | setCurrentButtonBottom(bottom)
54 | return
55 | }
56 | }, [])
57 | // monitor the button's location and situate the dropdown list underneath it
58 | useEffect(() => {
59 | if (isOpen && buttonRef.current) {
60 | const {bottom, left, right} = buttonRef.current.getBoundingClientRect()
61 | setButtonWidth(right - left)
62 | setCurrentButtonBottom(bottom)
63 | return
64 | }
65 | }, [layoutSnap, isOpen])
66 |
67 | return (
68 |
69 |
72 | {selectedItem?.name ?? " "}
73 |
74 |
85 | {isOpen &&
86 | choices.map((c, index) => (
87 | - hoverOnValue && hoverOnValue(index as T)}
94 | onMouseLeave={() => hoverOnValue && hoverOnValue(undefined)}
95 | key={`${c.value}${index}`}
96 | {...getItemProps({item: c, index})}
97 | >
98 | {selectedItem?.value === c.value && (
99 | )
102 | }
103 |
104 |
106 | {c.name}
107 | {c.tags.length > 0 &&
108 |
109 | {c.tags.filter(it => it).map(tag =>
110 | {tag}
113 | )}
114 |
115 | }
116 |
117 |
118 |
119 |
120 | ))}
121 |
122 |
123 | )
124 | }
--------------------------------------------------------------------------------
/src/home/panel/shared/widget/separator.tsx:
--------------------------------------------------------------------------------
1 | export const Separator: React.FC =()=> (
2 | {" "}
4 | )
5 |
--------------------------------------------------------------------------------
/src/home/panel/shared/widget/switch.tsx:
--------------------------------------------------------------------------------
1 | import React, {useEffect, useRef} from 'react'
2 | import {Switch} from '@headlessui/react'
3 |
4 | export type MySwitchProps = {
5 | enabled: boolean
6 | setEnabled: (enabled: boolean) => void
7 | }
8 |
9 | export const MySwitch: React.FC = ({enabled, setEnabled}) => {
10 | const switchBoxRef = useRef(null)
11 | useEffect(() => {
12 | if (switchBoxRef.current) {
13 | switchBoxRef.current.blur()
14 | }
15 | }, [enabled])
16 | return (
17 |
18 |
27 |
32 |
33 |
34 | )
35 | }
36 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | /*How to fix Tailwind CSS's h-screen on iOS Safari
6 | https://benborgers.com/posts/tailwind-h-screen
7 | */
8 | @supports (-webkit-touch-callout: none) {
9 | .h-screen {
10 | /*noinspection CssInvalidPropertyValue*/
11 | height: -webkit-fill-available;
12 | }
13 | }
14 |
15 | ::-webkit-scrollbar {
16 | width: 3px;
17 | height: 4px;
18 | }
19 |
20 | ::-webkit-scrollbar-thumb {
21 | background: #eeeeee;
22 | border-radius: 10px;
23 | }
24 |
25 | /*for Firefox*/
26 | :root {
27 | scrollbar-color: #eeeeee transparent;
28 | scrollbar-width: thin;
29 | }
30 |
31 | @layer utilities {
32 | .scrollbar-visible::-webkit-scrollbar-thumb {
33 | background-color: #eeeeee;
34 | transition: background-color 1000ms linear;
35 | }
36 |
37 | /*for Firefox*/
38 | .scrollbar-visible {
39 | scrollbar-color: #eeeeee transparent;
40 | transition: background-color 1000ms linear;
41 | }
42 |
43 | .scrollbar-visible-neutral-300::-webkit-scrollbar-thumb {
44 | background-color: #d4d4d4;
45 | transition: background-color 1000ms linear;
46 | }
47 |
48 | /*for Firefox*/
49 | .scrollbar-visible-neutral-300 {
50 | scrollbar-color: #d4d4d4 transparent;
51 | transition: background-color 1000ms linear;
52 | }
53 |
54 | .scrollbar-visible-neutral-500::-webkit-scrollbar-thumb {
55 | background-color: #71717a;
56 | transition: background-color 1000ms linear;
57 | }
58 |
59 | /*for Firefox*/
60 | .scrollbar-visible-neutral-500 {
61 | scrollbar-color: #71717a transparent;
62 | transition: background-color 1000ms linear;
63 | }
64 |
65 | .scrollbar-gone::-webkit-scrollbar {
66 | width: 0;
67 | height: 0;
68 | }
69 |
70 | .scrollbar-gone {
71 | /*for Firefox*/
72 | scrollbar-width: none;
73 | }
74 |
75 | .scrollbar-hidden::-webkit-scrollbar-thumb {
76 | background-color: transparent;
77 | transition: background-color 1000ms linear;
78 | }
79 |
80 | .scrollbar-hidden-instant::-webkit-scrollbar-thumb {
81 | background-color: transparent;
82 | }
83 |
84 | /*for Firefox*/
85 | .scrollbar-hidden {
86 | scrollbar-color: transparent transparent;
87 | transition: background-color 1000ms linear;
88 | }
89 | }
90 |
91 | @font-face {
92 | font-family: 'borel';
93 | src: url('assets/font/Borel/Borel-Regular.ttf') format('truetype');
94 | font-weight: 400;
95 | font-style: normal;
96 | }
97 |
98 | @layer utilities {
99 | /*
100 | Chromium browsers don't render nested backdrop filters.
101 | As a workaround, add 'before:' to the outer filter, along with 'before:backdrop-hack':
102 |
103 |
105 |
106 |
107 | See https://stackoverflow.com/a/76207141.
108 | */
109 | .backdrop-hack {
110 | @apply absolute inset-0 -z-10;
111 | }
112 | }
113 |
114 | .brightness-200 {
115 | -webkit-filter: brightness(150%);
116 | }
117 |
118 | /*Prevent an element from being selected and copied with CSS. see https://danoc.me/blog/css-prevent-copy*/
119 | [data-pseudo-content]::before {
120 | content: attr(data-pseudo-content);
121 | }
122 |
123 | h1,
124 | h2,
125 | h3,
126 | h4{
127 | @apply my-0.5 !important;
128 | }
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom/client'
3 | import './index.css'
4 | import './prose.css'
5 |
6 | import {createBrowserRouter, RouterProvider,} from "react-router-dom"
7 | import Auth from "./auth/auth.tsx"
8 | import Error from "./error.tsx"
9 | import Home from "./home/home.tsx"
10 | import {Experiment} from "./experiment/experiment.tsx"
11 | import {HelmetProvider} from 'react-helmet-async'
12 |
13 | const router = createBrowserRouter([
14 | {
15 | path: "/",
16 | element: ,
17 | errorElement: ,
18 | },
19 | {
20 | path: "/chat",
21 | element: ,
22 | errorElement: ,
23 | },
24 | {
25 | path: "/auth",
26 | element: ,
27 | errorElement: ,
28 | },
29 | {
30 | path: "/exp",
31 | element: ,
32 | errorElement: ,
33 | },
34 | ])
35 |
36 | ReactDOM.createRoot(document.getElementById('root')!).render(
37 |
38 |
39 |
40 |
41 | ,
42 | )
43 |
--------------------------------------------------------------------------------
/src/other.ts:
--------------------------------------------------------------------------------
1 | export type RecordingCtx = {
2 | triggeredBy: 'spacebar' | 'click' | 'touch'
3 | }
--------------------------------------------------------------------------------
/src/prose.css:
--------------------------------------------------------------------------------
1 | /*.user-prose h1,*/
2 | /*.user-prose h2,*/
3 | /*.user-prose h3,*/
4 | /*.user-prose h4,*/
5 | /*.user-prose p,*/
6 | /*.user-prose a,*/
7 | /*.user-prose blockquote,*/
8 | /*.user-prose figure,*/
9 | /*.user-prose figcaption,*/
10 | /*.user-prose strong,*/
11 | /*.user-prose em,*/
12 | /*.user-prose code,*/
13 | /*.user-prose pre,*/
14 | /*.user-prose ol,*/
15 | /*.user-prose ul,*/
16 | /*.user-prose li,*/
17 | /*.user-prose table,*/
18 | /*.user-prose thead,*/
19 | /*.user-prose tr,*/
20 | /*.user-prose th,*/
21 | /*.user-prose td,*/
22 | /*.user-prose img,*/
23 | /*.user-prose video,*/
24 | /*.user-prose hr {*/
25 | /* !*@apply text-violet-100 !important;*!*/
26 | /*}*/
27 | /*.user-prose a{*/
28 | /* !*@apply visited:text-purple-300 !important;*!*/
29 | /*}*/
30 |
31 | /*.assistant-prose h1,*/
32 | /*.assistant-prose h2,*/
33 | /*.assistant-prose h3,*/
34 | /*.assistant-prose h4,*/
35 | /*.assistant-prose p,*/
36 | /*.assistant-prose a,*/
37 | /*.assistant-prose blockquote,*/
38 | /*.assistant-prose figure,*/
39 | /*.assistant-prose figcaption,*/
40 | /*.assistant-prose strong,*/
41 | /*.assistant-prose em,*/
42 | /*.assistant-prose code,*/
43 | /*.assistant-prose pre,*/
44 | /*.assistant-prose ol,*/
45 | /*.assistant-prose ul,*/
46 | /*.assistant-prose li,*/
47 | /*.assistant-prose table,*/
48 | /*.assistant-prose thead,*/
49 | /*.assistant-prose tr,*/
50 | /*.assistant-prose th,*/
51 | /*.assistant-prose td,*/
52 | /*.assistant-prose img,*/
53 | /*.assistant-prose video,*/
54 | /*.assistant-prose hr {*/
55 | /* !*@apply text-neutral-800 leading-snug !important;*!*/
56 | /*}*/
57 | /*.assistant-prose a{*/
58 | /* !*@apply visited:text-purple-600 !important;*!*/
59 | /*}*/
60 |
61 | /*h1,*/
62 | /*h2,*/
63 | /*h3,*/
64 | /*h4{*/
65 | /* !*@apply my-2 !important;*!*/
66 | /*}*/
67 |
68 | /*pre{*/
69 | /* !*@apply p-0 my-2 rounded-lg !important;*!*/
70 | /*}*/
71 |
72 | /*pre div{*/
73 | /* !*@apply py-1 pl-3 m-0 !important;*!*/
74 | /*}*/
75 |
76 | /*code{*/
77 | /* @apply text-violet-100 !important;*/
78 | /*}*/
79 |
--------------------------------------------------------------------------------
/src/shared-types.ts:
--------------------------------------------------------------------------------
1 | export type Role = 'user' | 'assistant' | 'system'
2 |
3 | export type LLMMessage = {
4 | role: Role
5 | content: string
6 | }
7 |
8 | export type Switchable = {
9 | // there is a distinction between 'enabled' and 'available'.
10 | enabled: boolean // represents user's choice to disable ChatGPT, irrespective of its availability - preventing use of LLM.
11 | available: boolean // indicates if server provides support for ChatGPT
12 | }
--------------------------------------------------------------------------------
/src/state/control-state.ts:
--------------------------------------------------------------------------------
1 | import {proxy, ref, subscribe} from "valtio"
2 | import {EnhancedRecorder} from "../util/enhanced-recorder.ts"
3 | import {popularMimeTypes, RecordingMimeType} from "../config.ts"
4 | import {chooseAudioMimeType} from "../util/util.tsx"
5 |
6 | export type Player = {
7 | autoPlay: boolean
8 | isPlaying: boolean
9 | current: string // audio id
10 | playList: string[] // audio id list
11 | }
12 |
13 | export type RecordingCtx = {
14 | triggeredBy: 'spacebar' | 'click' | 'touch'
15 | }
16 |
17 | export type SendingMessage = {
18 | chatId: string
19 | text: string
20 | // if message
21 | audioBlob?: Blob
22 | durationMs?: number
23 | option?: SendMessageOption
24 | }
25 |
26 | export type SendMessageOption = {
27 | ignoreAttachedMessage?: boolean
28 | model?: string
29 | }
30 |
31 | export type AudioDurationUpdate = {
32 | chatId: string
33 | messageId: string
34 | durationMs: number
35 | }
36 |
37 | type ControlState = {
38 | isMouseLeftDown: boolean
39 | isMouseDragging: boolean
40 | isWindowsBlurred: boolean,
41 | isTextPending: boolean,
42 | player: Player
43 | recordingMimeType?: RecordingMimeType
44 | recorder: EnhancedRecorder
45 | sendingMessages: SendingMessage[]
46 | sendingMessageSignal: number
47 | audioDurationUpdates: AudioDurationUpdate[]
48 | audioDurationUpdateSignal: number
49 | }
50 |
51 | export const controlState = proxy({
52 | isMouseLeftDown: false,
53 | isMouseDragging: false,
54 | isWindowsBlurred: false,
55 | isTextPending: false,
56 | player: {
57 | autoPlay: true,
58 | isPlaying: false,
59 | current: "",
60 | playList: []
61 | },
62 | recordingMimeType: chooseAudioMimeType(popularMimeTypes),
63 | recorder: ref>(new EnhancedRecorder(false)),
64 | sendingMessages: ref([]),
65 | sendingMessageSignal: 0,
66 | audioDurationUpdates: ref([]),
67 | audioDurationUpdateSignal: 0
68 | })
69 |
70 | subscribe(controlState.player, () => {
71 | console.debug("player status:", controlState.player)
72 | })
73 |
74 | export const playerState = controlState.player
75 |
76 | // but ignore the audio if auto-play is not enabled
77 | export const addToPlayList = (audioId: string) => {
78 | console.debug("adding audio to playlist,autoPlay: ", playerState.autoPlay)
79 | if (!playerState.autoPlay) {
80 | return
81 | }
82 |
83 | if (playerState.isPlaying) {
84 | console.debug("adding audio to playlist, queued")
85 | playerState.playList.push(audioId)
86 | } else {
87 | console.debug("adding audio to playlist, now playing it")
88 | playerState.isPlaying = true
89 | playerState.current = audioId
90 | }
91 | }
92 |
93 | // if prev audio is finished, auto play the next audio if auto-play is not enabled
94 | export const onFinish = (audioId: string) => {
95 | if (playerState.current != audioId) {
96 | return
97 | }
98 | if (!playerState.autoPlay || playerState.playList.length == 0) {
99 | playerState.isPlaying = false
100 | playerState.current = ""
101 | return
102 | }
103 | const [head] = playerState.playList.splice(0, 1)
104 | playerState.current = head
105 | playerState.isPlaying = true
106 | }
107 |
108 | export const pauseMe = (audioId: string) => {
109 | if (playerState.current === audioId) {
110 | playerState.isPlaying = false
111 | }
112 | }
113 |
114 | // clear playList if user ask to play a new audio
115 | export const play = (audioId: string) => {
116 | if (playerState.current !== audioId) {
117 | playerState.playList.splice(0, playerState.playList.length)
118 | }
119 | playerState.current = audioId
120 | playerState.isPlaying = true
121 | }
122 |
123 | export const clearPlayList = () => {
124 | playerState.isPlaying = false
125 | playerState.current = ""
126 | playerState.playList.splice(0, playerState.playList.length)
127 | }
128 |
129 |
--------------------------------------------------------------------------------
/src/state/dangerous.ts:
--------------------------------------------------------------------------------
1 | import {AppState, appState, defaultAppState} from "./app-state.ts";
2 | import {defaultPromptState, PromptState, promptState} from "./promt-state.ts";
3 | import {audioDb} from "./db.ts";
4 |
5 | export const resetPromptState = () => {
6 | const dft = defaultPromptState()
7 | Object.keys(promptState).forEach((key) => {
8 | console.debug("resetting promptState, key:", key)
9 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
10 | // @ts-ignore
11 | promptState[key as keyof PromptState] = dft[key]
12 | })
13 | }
14 |
15 | export const resetAppState = () => {
16 | const dft = defaultAppState()
17 | Object.keys(appState).forEach((key) => {
18 | console.debug("resetting appState, key:", key)
19 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
20 | // @ts-ignore
21 | appState[key as keyof AppState] = dft[key]
22 | })
23 | }
24 |
25 | export const resetEverything = (callback: () => void) => {
26 | audioDb.clear(() => {
27 | }).catch(e => {
28 | console.error("failed to clear audio blobs:", e)
29 | }
30 | ).finally(() => {
31 | resetAppState()
32 | resetPromptState()
33 | console.info("all reset")
34 | callback()
35 | })
36 | }
37 |
38 | export const clearSettings = () => {
39 | const dft = defaultAppState()
40 | Object.keys(appState).forEach((key) => {
41 | if (key !== "chats") {
42 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
43 | // @ts-ignore
44 | appState[key as keyof AppState] = dft[key]
45 | }
46 | })
47 | resetPromptState()
48 | }
49 |
50 | export const clearChats = () => {
51 | const dft = defaultAppState()
52 | appState.chats.splice(0, appState.chats.length)
53 | appState.currentChatId = dft.currentChatId
54 | }
55 |
--------------------------------------------------------------------------------
/src/state/db.ts:
--------------------------------------------------------------------------------
1 | import localforage from "localforage"
2 |
3 | const talkDbName = "talk"
4 | export const appStateKey = "app-state"
5 | export const promptStateKey = "prompt-state"
6 |
7 | localforage.config({
8 | version: 1.0,
9 | })
10 |
11 | export const talkDB = localforage.createInstance({
12 | name: talkDbName
13 | })
14 |
15 | const audioDB = "audio"
16 |
17 | export const audioDb = localforage.createInstance({
18 | name: audioDB
19 | })
20 |
21 | export const deleteBlobs = async (ids: string[]) => {
22 | for (const id of ids) {
23 | await audioDb.removeItem(id)
24 | }
25 | }
--------------------------------------------------------------------------------
/src/state/layout-state.ts:
--------------------------------------------------------------------------------
1 | import {proxy} from "valtio"
2 |
3 |
4 | export type Layout = {
5 | settingPanelScrollOffset: number
6 | isMessageListOverflow: boolean
7 | isMessageListAtBottom: boolean
8 | isPAFloating: boolean
9 | isPAPinning: boolean
10 | PAButtonDistance: number
11 | PAButtonWheelDeltaY: number
12 | }
13 |
14 | export const layoutState = proxy({
15 | settingPanelScrollOffset: 0,
16 | isMessageListOverflow: false,
17 | isMessageListAtBottom: false,
18 | isPAFloating: false,
19 | isPAPinning: false,
20 | PAButtonDistance: 1000,
21 | PAButtonWheelDeltaY:0,
22 | })
--------------------------------------------------------------------------------
/src/state/message-state.ts:
--------------------------------------------------------------------------------
1 | import {proxy} from "valtio";
2 |
3 |
4 | export interface MState {
5 | loadAudio: boolean
6 | attached: boolean
7 | }
8 |
9 | const defaultMState = (): MState => ({
10 | loadAudio: false,
11 | attached: false
12 | })
13 |
14 | export interface MessageState {
15 | record: Record
16 | }
17 |
18 | export const messageState = proxy({
19 | record: {}
20 | })
21 |
22 | export const clearMessageState = () => {
23 | const keys = Object.keys(messageState.record);
24 | for (const key of keys) {
25 | delete messageState.record[key]
26 | }
27 | }
28 |
29 | export const setMState = (messageId: string, key: keyof MState, value: boolean) => {
30 | let m = messageState.record[messageId]
31 | if (m) {
32 | m[key] = value
33 | } else {
34 | m = defaultMState()
35 | m[key] = value
36 | messageState.record[messageId] = m
37 | }
38 | }
--------------------------------------------------------------------------------
/src/state/migration.ts:
--------------------------------------------------------------------------------
1 | import {AppState} from "./app-state.ts"
2 | import * as packageJson from '../../package.json'
3 | import {defaultOption} from "../data-structure/client-option.tsx"
4 | import {defaultShortcuts, KeyCombo, Shortcuts} from "./shortcuts.ts";
5 | import semver from "semver/preload";
6 |
7 | const currentVersion = packageJson.version
8 |
9 | type Step = {
10 | fromVersion: string
11 | toVersion: string
12 | action: (app: AppState) => Error | null
13 | }
14 |
15 | const steps: Step[] = [
16 | {
17 | fromVersion: "0.0.0",
18 | toVersion: "0.0.1",
19 | action: (): Error | null => {
20 | return null
21 | }
22 | },
23 | {
24 | fromVersion: "0.0.1",
25 | toVersion: "0.0.2",
26 | action: (_app: AppState): Error | null => {
27 | _app.option.stt.google = defaultOption().tts.google
28 | for (const chat of _app.chats) {
29 | chat.option.tts.google = defaultOption().tts.google
30 | }
31 | return null
32 | }
33 | },
34 | {
35 | fromVersion: "0.0.2",
36 | toVersion: "0.0.3",
37 | action: (_app: AppState): Error | null => {
38 | _app.pref.wallpaper = {index: 0}
39 | return null
40 | }
41 | },
42 | {
43 | fromVersion: "0.0.3",
44 | toVersion: "1.1.0",
45 | action: (app: AppState): Error | null => {
46 | for (const chat of app.chats) {
47 | chat.promptId = ""
48 | chat.option.llm.maxAttached = defaultOption().llm.maxAttached
49 | }
50 | app.option.llm.maxAttached = defaultOption().llm.maxAttached
51 | return null
52 | }
53 | },
54 | {
55 | fromVersion: "1.1.0",
56 | toVersion: "1.2.8",
57 | action: (app: AppState): Error | null => {
58 | app.pref.dismissDemo = false
59 | return null
60 | }
61 | },
62 | {
63 | fromVersion: "1.2.8",
64 | toVersion: "1.3.0",
65 | action: (app: AppState): Error | null => {
66 | app.pref.showRecorder = true
67 | app.pref.shortcuts = defaultShortcuts()
68 | return null
69 | }
70 | },
71 | {
72 | fromVersion: "1.3.0",
73 | toVersion: "2.0.0",
74 | action: (app: AppState): Error | null => {
75 | app.pref.showMarkdown = true
76 | app.option.llm.gemini = defaultOption().llm.gemini
77 | for (const chat of app.chats) {
78 | chat.option.llm.gemini = defaultOption().llm.gemini
79 | }
80 | return null
81 | }
82 | },
83 | {
84 | fromVersion: "2.0.0",
85 | toVersion: "2.0.3",
86 | action: (app: AppState): Error | null => {
87 | const dft = defaultShortcuts()
88 | Object.keys(app.pref.shortcuts).forEach((field) => {
89 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
90 | // @ts-ignore
91 | const shortcut = dft[field] as KeyCombo|undefined
92 | if (!shortcut){
93 | return Error(`${field} was not found in defaultShortcuts`)
94 | }
95 | app.pref.shortcuts[field as keyof Shortcuts].name = shortcut.name
96 | })
97 | return null
98 | }
99 | }
100 | ]
101 |
102 | export const migrateAppState = (app: AppState): Error | null => {
103 | if (app.version === currentVersion) {
104 | return null
105 | }
106 | if (!app.version) {
107 | app.version = steps[0].fromVersion
108 | }
109 |
110 | for (const step of steps) {
111 | if (semver.gte(app.version, step.fromVersion) && semver.lt(app.version, step.toVersion)) {
112 | console.info(`migrating from ${step.fromVersion} to ${step.toVersion}`)
113 | const error = step.action(app)
114 | if (error) {
115 | console.error("fatal error, failed to migrate to new version")
116 | console.error(`from ${step.fromVersion} to ${step.toVersion}`)
117 | console.error("appState:", app)
118 | return error
119 | }
120 | app.version = step.toVersion
121 | }
122 | }
123 | return null
124 | }
--------------------------------------------------------------------------------
/src/state/network-state.ts:
--------------------------------------------------------------------------------
1 | import {proxy} from 'valtio'
2 |
3 |
4 | export const networkState = proxy({
5 | streamId: ""
6 | })
--------------------------------------------------------------------------------
/src/state/promt-state.ts:
--------------------------------------------------------------------------------
1 | import {proxy, snapshot, subscribe} from 'valtio'
2 | import {promptStateKey, talkDB} from "./db.ts"
3 | import {presetPrompts} from "../data/prompt.ts";
4 | import {randomHash16Char} from "../util/util.tsx";
5 | import {LLMMessage} from "../shared-types.ts";
6 | import {appState, hydrationState} from "./app-state.ts";
7 | import {subscribeKey} from "valtio/utils";
8 | import {groupBy} from "lodash";
9 |
10 | export type Prompt = {
11 | id: string,
12 | name: string
13 | messages: LLMMessage[]
14 | preset: boolean
15 | }
16 |
17 | export type PromptState = {
18 | prompts: Prompt[]
19 | }
20 |
21 | export const promptState = proxy({
22 | prompts: presetPrompts()
23 | })
24 |
25 | export const defaultPromptState = (): PromptState => ({
26 | prompts: presetPrompts()
27 | })
28 |
29 | const apply = (ps: PromptState | null) => {
30 | if (ps !== null) {
31 | const dft = defaultPromptState()
32 | Object.keys(promptState).forEach((key) => {
33 | console.debug("restoring from db, key:", key)
34 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
35 | // @ts-ignore
36 | promptState[key as keyof PromptState] = ps[key] ?? dft[key]
37 | })
38 | }
39 | }
40 |
41 | talkDB.getItem(promptStateKey).then((ps) => {
42 | console.debug("restoring promptState from db:", ps)
43 | apply(ps)
44 | console.debug("restored")
45 | })
46 |
47 | subscribe(promptState, () => {
48 | const ps = snapshot(promptState)
49 | talkDB.setItem(promptStateKey, ps).then(() => {
50 | console.debug("promptState saved")
51 | })
52 | })
53 |
54 | export const findPrompt = (id: string): Prompt | undefined => {
55 | if (id === "") {
56 | return undefined
57 | }
58 | return promptState.prompts.find(it => it.id === id)
59 | }
60 |
61 | export const newPrompt = (): string => {
62 | const newP: Prompt = {
63 | id: randomHash16Char(),
64 | name: "New Prompt",
65 | messages: [{role: "user", content: ""}],
66 | preset: false
67 | }
68 | promptState.prompts.unshift(newP)
69 | return newP.id
70 | }
71 |
72 | export const clonePrompt = (p: Prompt): string => {
73 | const newP: Prompt = {
74 | id: randomHash16Char(),
75 | name: p.name,
76 | messages: p.messages.map(m => ({...m})),
77 | preset: false
78 | }
79 | promptState.prompts.unshift(newP)
80 | return newP.id
81 | }
82 |
83 | export const deletePrompt = (id: string): void => {
84 | console.debug("deletePrompt", id)
85 | for (let i = 0; i < promptState.prompts.length; i++) {
86 | if (promptState.prompts[i].id === id) {
87 | promptState.prompts.splice(i, 1)
88 | return
89 | }
90 | }
91 | }
92 |
93 | export type PromptCountState = {
94 | counts: Record
95 | }
96 |
97 | export const promptCountState = proxy({
98 | counts: {}
99 | })
100 |
101 | export const syncPromptIdCounts = () => {
102 | // console.log("syncPromptIdCounts")
103 | for (const key in promptCountState.counts) {
104 | delete promptCountState.counts[key]
105 | }
106 | const group = groupBy(appState.chats, c => c.promptId)
107 | for (const key in group) {
108 | promptCountState.counts[key] = group[key].length
109 | }
110 | }
111 |
112 | subscribeKey(appState.chats, "length", () => {
113 | syncPromptIdCounts()
114 | })
115 |
116 | subscribe(hydrationState, () => {
117 | syncPromptIdCounts()
118 | })
119 |
120 | syncPromptIdCounts()
--------------------------------------------------------------------------------
/src/state/shortcuts.ts:
--------------------------------------------------------------------------------
1 | import React, {ModifierKey} from "react";
2 |
3 | export type Shortcuts = {
4 | send: KeyCombo
5 | newLine: KeyCombo
6 | sendZeroAttachedMessage: KeyCombo
7 | sendWithBestModel: KeyCombo
8 | sendWithBestModelAndZeroAttachedMessage: KeyCombo
9 | }
10 |
11 | export type KeyCombo = {
12 | name: string,
13 | key: string,
14 | mKeys: ModifierKey[]
15 | }
16 |
17 |
18 | export const defaultShortcuts = (): Shortcuts => ({
19 | send: {
20 | name: "Send",
21 | key: "Enter",
22 | mKeys: []
23 | },
24 | newLine: {
25 | name: "New line",
26 | key: "Enter",
27 | mKeys: ["Shift"]
28 | },
29 | sendZeroAttachedMessage: {
30 | name: "Send with 0 attached message",
31 | key: "Enter",
32 | mKeys: ["Control"]
33 | },
34 | sendWithBestModel: {
35 | name: `Send with ${bestModel} model`,
36 | key: "Enter",
37 | mKeys: ["Meta"]
38 | },
39 | sendWithBestModelAndZeroAttachedMessage: {
40 | name: `Send with ${bestModel} model and 0 attached message`,
41 | key: "Enter",
42 | mKeys: ["Meta", "Control"]
43 | }
44 | })
45 |
46 | export const bestModel = "gpt-4-turbo-preview"
47 |
48 | const mods: ModifierKey[] = ["Meta", "Control", "Alt", "Shift"]
49 | export const matchKeyCombo = (keyCombo: KeyCombo, e: React.KeyboardEvent): boolean => {
50 | if (e.key !== keyCombo.key) {
51 | return false
52 | }
53 | for (const m of mods) {
54 | if (e.getModifierState(m) !== keyCombo.mKeys.includes(m)) {
55 | return false
56 | }
57 | }
58 | return true
59 | }
--------------------------------------------------------------------------------
/src/util/util.tsx:
--------------------------------------------------------------------------------
1 | import {SHA256} from 'crypto-js'
2 | import {KeyboardEventHandler} from "react"
3 | import {format} from 'date-fns'
4 | import {RecordingMimeType} from "../config.ts"
5 | import {floor} from "lodash"
6 |
7 | export const base64ToBlob = (base64String: string, mimeType: string): Blob => {
8 | console.debug("decoding base64(truncated to 20 chars)", base64String.slice(0, 20))
9 | const byteCharacters = atob(base64String)
10 | const byteNumbers: number[] = []
11 |
12 | for (let i = 0; i < byteCharacters.length; i++) {
13 | byteNumbers.push(byteCharacters.charCodeAt(i))
14 | }
15 |
16 | const byteArray = new Uint8Array(byteNumbers)
17 | return new Blob([byteArray], {type: mimeType})
18 | }
19 |
20 | // duration is in ms
21 | export const timeElapsedMMSS = (duration: number): string => {
22 | let seconds = Math.floor(duration / 1000)
23 | const minutes = Math.floor((seconds % 3600) / 60)
24 | seconds = seconds % 60
25 | return `${padZero(minutes)}:${padZero(seconds)}`
26 | }
27 |
28 | const padZero = (num: number): string => {
29 | return num.toString().padStart(2, '0')
30 | }
31 |
32 | // duration is in ms
33 | export const formatNow = (): string => {
34 | return format(new Date(), 'yyyy-MM-dd_HH_mm_SS')
35 | }
36 |
37 | // time in ms
38 | export const formatAgo = (time: number): string => {
39 | const now = new Date()
40 | const date = new Date(time)
41 | const delta = Math.abs(now.getTime() - date.getTime()) / 1000
42 |
43 | if (delta < 60) {
44 | return 'Now'
45 | } else if (delta < 60 * 60) {
46 | const minutes = Math.floor(delta / (60))
47 | return `${minutes} min`
48 | } else if (delta < 24 * 60 * 60) {
49 | return format(date, 'HH:mm')
50 | } else if (isSameMonth(now, date)) {
51 | return format(date, 'do, HH:mm')
52 | } else if (isSameMonthAndYear(now, date)) {
53 | return format(date, 'do MMM, HH:mm')
54 | } else {
55 | return format(date, 'do MMM yyyy, HH:mm')
56 | }
57 | }
58 |
59 | function isSameMonth(date1: Date, date2: Date): boolean {
60 | return (
61 | date1.getMonth() === date2.getMonth()
62 | )
63 | }
64 |
65 | function isSameMonthAndYear(date1: Date, date2: Date): boolean {
66 | return (
67 | date1.getFullYear() === date2.getFullYear() &&
68 | date1.getMonth() === date2.getMonth()
69 | )
70 | }
71 |
72 | // duration is in ms
73 | export const formatAudioDuration = (duration?: number): string => {
74 | if (!duration) {
75 | return ""
76 | }
77 | duration /= 1000
78 | const min = floor(duration / 60)
79 | const sec = floor(duration % 60)
80 | if (min === 0) {
81 | return `${sec}s`
82 | } else {
83 | return `${min}:${sec}`
84 | }
85 | }
86 |
87 | export const generateAudioId = (action: "recording" | "synthesis"): string => {
88 | return action + "-" + formatNow() + "-" + randomHash16Char()
89 | }
90 |
91 | export function currentProtocolHostPortPath(): string {
92 | const protocol = window.location.protocol
93 | const hostname = window.location.hostname
94 | const port = window.location.port
95 | return `${protocol}//${hostname}:${port}/`
96 | }
97 |
98 | export function joinUrl(...parts: string[]): string {
99 | return parts.map(part => part.replace(/^\/+|\/+$/g, '')).join('/')
100 | }
101 |
102 | export function chooseAudioMimeType(mimeTypes: RecordingMimeType[]): RecordingMimeType | undefined {
103 | if (MediaRecorder) {
104 | const found = mimeTypes.find(m => MediaRecorder.isTypeSupported(m.mimeType))
105 | if (found) {
106 | return found
107 | }
108 | }
109 | console.error("cannot find mimeType for recorder")
110 | return undefined
111 | }
112 |
113 | export const cx = (...classes: (boolean | string | undefined)[]): string => {
114 | return classes.filter(c => typeof c === "string").join(" ")
115 | }
116 |
117 | // join keys
118 | export const kx = (...keys: string[]): string => {
119 | return keys.filter(c => c !== "").join("+")
120 | }
121 |
122 | export function getRandomElement(...arr: T[]): T {
123 | const randomIndex = Math.floor(Math.random() * arr.length)
124 | return arr[randomIndex]
125 | }
126 |
127 | export function compareSlices(arr1: T[], arr2: T[]): boolean {
128 | const slice1 = arr1.slice()
129 | const slice2 = arr2.slice()
130 |
131 | return JSON.stringify(slice1) === JSON.stringify(slice2)
132 | }
133 |
134 | export const escapeSpaceKey: KeyboardEventHandler = (event) => {
135 | if (event.key === ' ') {
136 | event.stopPropagation()
137 | }
138 | }
139 |
140 | // return a string contains 16 chars
141 | export const randomHash16Char = (): string => {
142 | const str = randomString(20)
143 | // 256**16>3.4e38, it's not likely to cause collision, but save more bytes to speed up state management
144 | return SHA256(str).toString().slice(0, 16)
145 | }
146 |
147 | // return a string contains 16 chars
148 | export const randomHash32Char = (): string => {
149 | const str = randomString(20)
150 | return SHA256(str).toString().slice(0, 32)
151 | }
152 |
153 | // noinspection SpellCheckingInspection
154 | const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
155 |
156 | function randomString(length: number): string {
157 | let result = ''
158 | for (let i = 0; i < length; i++) {
159 | result += chars[Math.floor(Math.random() * chars.length)]
160 | }
161 | return result
162 | }
163 |
164 | export function generateHash(input: string): string {
165 | return SHA256(input).toString()
166 | }
167 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/src/wallpaper/granim-wallpaper.tsx:
--------------------------------------------------------------------------------
1 | import React, {useEffect, useRef} from "react"
2 | import Granim from "granim"
3 | import {compareSlices} from "../util/util.tsx"
4 |
5 | export type WallpaperAuthProps = {
6 | onDark?: (isDark: boolean) => void
7 | }
8 |
9 | export const GranimWallpaper: React.FC = ({onDark}) => {
10 | const canvasRef = useRef(null)
11 | useEffect(() => {
12 | const darkColor = ["#000428", "#004e92"]
13 | let granim: Granim
14 | if (canvasRef.current) {
15 | granim = new Granim({
16 | element: canvasRef.current,
17 | direction: "diagonal",
18 | states: {
19 | "default-state": {
20 | gradients: [
21 | ["#00d2ff", "#3a7bd5"],
22 | ["#4776E6", "#8E54E9"],
23 | darkColor,
24 | ["#FF512F", "#DD2476"],
25 | ["#fd746c", "#ff9068"],
26 | ["#6a3093", "#a044ff"],
27 | ["#76b852", "#8DC26F"],
28 | ["#005C97", "#363795"]
29 | ],
30 | },
31 | },
32 | onGradientChange: (e) => {
33 | const isDark = compareSlices(e.colorsTo, darkColor)
34 | if (onDark !== undefined) {
35 | onDark(isDark)
36 | }
37 | }
38 | })
39 | }
40 | return () => {
41 | if (granim) {
42 | granim.destroy()
43 | }
44 | }
45 | }, [onDark])
46 |
47 | return (
48 |
55 | )
56 | }
--------------------------------------------------------------------------------
/src/wallpaper/wallpaper.tsx:
--------------------------------------------------------------------------------
1 | import {cx} from "../util/util.tsx"
2 | import React, {useCallback, useEffect, useState} from "react"
3 | import {allArts, Art} from "./art.tsx"
4 | import {useSnapshot} from "valtio/react"
5 | import {appState} from "../state/app-state.ts"
6 |
7 |
8 | export const TheWallpaper = () => {
9 | const {previewIndex, index} = useSnapshot(appState.pref.wallpaper)
10 | return (
11 |
12 | )
13 | }
14 |
15 | const defaultImageClassName = "bg-cover bg-center blur brightness-75"
16 | const defaultNoiseClassname = "opacity-80 brightness-100"
17 |
18 | type Props = {
19 | art: Art
20 | }
21 |
22 | const Wallpaper: React.FC = ({art}) => {
23 | const [showInfo, setShowInfo] = useState(true)
24 | const [timer, setHideOnTime] = useState()
25 |
26 | const clearTimer = useCallback(() => {
27 | if (timer) {
28 | clearTimeout(timer)
29 | }
30 | setShowInfo(true)
31 | }, [timer])
32 |
33 | const startNewTimer = useCallback(() => {
34 | if (timer) {
35 | clearTimeout(timer)
36 | }
37 | const t = setTimeout(() => setShowInfo(false), 5e3)
38 | setHideOnTime(t)
39 | }, [timer])
40 |
41 | useEffect(() => {
42 | if (timer) {
43 | clearTimeout(timer)
44 | }
45 | setShowInfo(true)
46 | const t = setTimeout(() => setShowInfo(false), 5e3)
47 | setHideOnTime(t)
48 | return () => clearTimeout(t)
49 | // eslint-disable-next-line react-hooks/exhaustive-deps
50 | }, [art])
51 |
52 | return (
53 |
78 | )
79 | }
--------------------------------------------------------------------------------
/src/window-listeners.tsx:
--------------------------------------------------------------------------------
1 | import React, {useCallback, useEffect} from "react"
2 | import {controlState} from "./state/control-state.ts"
3 | import {useSnapshot} from "valtio/react";
4 | import {appState} from "./state/app-state.ts";
5 |
6 | export const WindowListeners: React.FC = () => {
7 |
8 | const {showRecorder} = useSnapshot(appState.pref)
9 |
10 | const setMouseDown = useCallback((isMouseLeftDown: boolean) => {
11 | controlState.isMouseLeftDown = isMouseLeftDown
12 | }, [])
13 |
14 | const recorder = controlState.recorder
15 | useEffect(() => {
16 |
17 | // spacebar has the lowest priority on starting/ending a recording
18 | const handleKeyUp = (event: KeyboardEvent) => {
19 | if (event.key == ' ' && recorder.currentContext()?.triggeredBy === 'spacebar') {
20 | // prevent space from scrolling message list
21 | event.preventDefault()
22 | recorder.done()
23 | }
24 | }
25 |
26 | const handleKeyDown = (event: KeyboardEvent) => {
27 | if (event.key === ' ') {
28 | // prevent space from scrolling message list
29 | event.preventDefault()
30 | if (event.repeat) {
31 | return
32 | }
33 | if (!recorder.currentContext()?.triggeredBy) {
34 | console.debug('handleKeyDown with not repeated space')
35 | recorder.start({triggeredBy: 'spacebar'})
36 | .then(() => {
37 | return true
38 | })
39 | .catch((e) => {
40 | console.error("failed to start recorder", e)
41 | return true
42 | })
43 | }
44 | } else if (event.key === 'Escape') {
45 | if (recorder.currentContext()) {
46 | // press Escape to cancel the recording
47 | recorder.cancel()
48 | }
49 | } else {
50 | if (recorder.currentContext()?.triggeredBy === 'spacebar') {
51 | // press any key other spacebar to cancel the recording started by spacebar
52 | recorder.cancel()
53 | }
54 | }
55 | }
56 |
57 | const handleMouseDown = (event: MouseEvent) => {
58 | if (event.button === 0) {
59 | setMouseDown(true)
60 | }
61 | }
62 |
63 | const handleMouseUp = (event: MouseEvent) => {
64 | if (event.button === 0) {
65 | setMouseDown(false)
66 | }
67 | }
68 |
69 | const handleBrowserBlur = () => {
70 | setMouseDown(false)
71 | controlState.isWindowsBlurred = true
72 | }
73 |
74 | const handleBrowserFocus = () => {
75 | controlState.isWindowsBlurred = false
76 | }
77 |
78 | if (showRecorder) {
79 | window.addEventListener("keydown", handleKeyDown)
80 | window.addEventListener("keyup", handleKeyUp)
81 | }
82 | window.addEventListener("mousedown", handleMouseDown)
83 | window.addEventListener("mouseup", handleMouseUp)
84 | window.addEventListener("blur", handleBrowserBlur)
85 | window.addEventListener("focus", handleBrowserFocus)
86 |
87 | return () => {
88 | window.removeEventListener("keydown", handleKeyDown)
89 | window.removeEventListener("keyup", handleKeyUp)
90 | window.removeEventListener("mousedown", handleMouseDown)
91 | window.removeEventListener("mouseup", handleMouseUp)
92 | window.removeEventListener("blur", handleBrowserBlur)
93 | window.removeEventListener("focus", handleBrowserFocus)
94 | }
95 | },
96 | [setMouseDown, recorder, showRecorder]
97 | )
98 | return null
99 | }
--------------------------------------------------------------------------------
/src/worker/subscribe-audio-duration-update.tsx:
--------------------------------------------------------------------------------
1 | import React, {useEffect} from "react"
2 | import {useSnapshot} from "valtio/react"
3 | import {controlState} from "../state/control-state.ts"
4 | import {findChatProxy, findMessage} from "../state/app-state.ts"
5 |
6 | export const SubscribeAudioDurationUpdate: React.FC = () => {
7 |
8 | const {audioDurationUpdateSignal} = useSnapshot(controlState)
9 | useEffect(() => {
10 | if (controlState.audioDurationUpdates.length === 0) {
11 | return
12 | }
13 | const [au] = controlState.audioDurationUpdates.splice(0, 1)
14 | if (!au) {
15 | return
16 | }
17 | const chat = findChatProxy(au.chatId)
18 | if (!chat) {
19 | return
20 | }
21 | const message = findMessage(chat[0], au.messageId)
22 | if (message && message.audio) {
23 | message.audio.durationMs = au.durationMs
24 | }
25 | }, [audioDurationUpdateSignal])
26 | return null
27 | }
28 |
--------------------------------------------------------------------------------
/src/worker/subscribe-sending-message.tsx:
--------------------------------------------------------------------------------
1 | import React, {useEffect} from "react"
2 | import {useSnapshot} from "valtio/react"
3 | import {snapshot} from "valtio"
4 | import {AxiosError} from "axios"
5 | import {controlState} from "../state/control-state.ts"
6 | import {findChatProxy, findMessage} from "../state/app-state.ts"
7 | import {attachedMessages} from "../api/restful/util.ts"
8 | import {newSending, onAudio, onError, onSent} from "../data-structure/message.tsx"
9 | import {postAudioChat, postChat} from "../api/restful/api.ts"
10 | import {generateAudioId} from "../util/util.tsx"
11 | import {audioDb} from "../state/db.ts"
12 | import {minSpeakTimeMillis} from "../config.ts"
13 | import {toRestfulAPIOption} from "../data-structure/client-option.tsx"
14 | import {findPrompt} from "../state/promt-state.ts";
15 |
16 | export const SubscribeSendingMessage: React.FC = () => {
17 |
18 | const {sendingMessageSignal} = useSnapshot(controlState)
19 | useEffect(() => {
20 | if (controlState.sendingMessages.length === 0) {
21 | return
22 | }
23 | const [sm] = controlState.sendingMessages.splice(0, 1)
24 | if (!sm) {
25 | return
26 | }
27 | // in case there are more messages in the queue
28 | controlState.sendingMessageSignal++
29 |
30 | if (sm.audioBlob) {
31 | if (sm.durationMs! < minSpeakTimeMillis) {
32 | console.info("audio is less than ms", minSpeakTimeMillis)
33 | return
34 | }
35 | }
36 |
37 | const chatProxy = findChatProxy(sm.chatId)?.[0]
38 | if (!chatProxy) {
39 | console.warn("chat does exist any more, chatId:", sm.chatId)
40 | return
41 | }
42 |
43 | const option = snapshot(chatProxy.option)
44 |
45 | let messages =
46 | sm.option?.ignoreAttachedMessage ? [] : attachedMessages(chatProxy.messages, option.llm.maxAttached)
47 | const attachedCount = messages.length
48 | const prompt = findPrompt(chatProxy.promptId);
49 | if (prompt) {
50 | const pms = prompt.messages.filter(it => it.content.trim() !== "")
51 | messages = [...pms, ...messages]
52 | }
53 |
54 | const talkOption = toRestfulAPIOption(option, {model: sm.option?.model})
55 |
56 | const nonProxyMessage = newSending({
57 | promptCount: prompt?.messages?.length ?? 0,
58 | attachedMessageCount: attachedCount,
59 | talkOption: talkOption
60 | })
61 |
62 | let postPromise
63 | if (sm.audioBlob) {
64 | nonProxyMessage.audio = {id: ""}
65 | chatProxy.messages.push(nonProxyMessage)
66 |
67 | console.debug("sending audio and chat, chatId,messages: ", chatProxy.id, messages)
68 | postPromise = postAudioChat(sm.audioBlob as Blob, controlState.recordingMimeType?.fileName ?? "audio.webm", {
69 | chatId: chatProxy.id,
70 | ticketId: nonProxyMessage.ticketId,
71 | ms: messages,
72 | talkOption: talkOption
73 | })
74 | } else {
75 | messages.push({role: "user", content: sm.text})
76 | nonProxyMessage.text = sm.text
77 | chatProxy.messages.push(nonProxyMessage)
78 | postPromise = postChat({
79 | chatId: chatProxy.id,
80 | ticketId: nonProxyMessage.ticketId,
81 | ms: messages,
82 | talkOption: talkOption
83 | })
84 | }
85 |
86 | postPromise.then((r) => {
87 | const msg = findMessage(chatProxy, nonProxyMessage.id)
88 | if (!msg) {
89 | console.error("message not found after pushing, chatId,messageId:", chatProxy.id, nonProxyMessage.id)
90 | return
91 | }
92 | if (r.status >= 200 && r.status < 300) {
93 | onSent(msg)
94 | } else {
95 | const data = typeof r.data === "string" ? r.data : ""
96 | onError(msg, "Failed to send, reason: " + r.statusText + "," + data)
97 | }
98 | }
99 | ).catch((e: AxiosError) => {
100 | const msg = findMessage(chatProxy, nonProxyMessage.id)
101 | if (!msg) {
102 | console.error("message not found after pushing, chatId,messageId:", chatProxy.id, nonProxyMessage.id)
103 | return
104 | }
105 | onError(msg, "Failed to send, reason:" + e.message)
106 | })
107 |
108 | if (sm.audioBlob) {
109 | const audioId = generateAudioId("recording")
110 | audioDb.setItem(audioId, sm.audioBlob as Blob, (err, value) => {
111 | if (err || !value) {
112 | console.debug("failed to save audio blob, audioId:", audioId, err)
113 | } else {
114 | const msg = findMessage(chatProxy, nonProxyMessage.id)
115 | if (!msg) {
116 | console.error("message not found after pushing, chatId,messageId:", chatProxy.id, nonProxyMessage.id)
117 | return
118 | }
119 | onAudio(msg, {id: audioId, durationMs: sm.durationMs})
120 | console.debug("saved audio blob, audioId:", audioId)
121 | }
122 | }
123 | )
124 | }
125 | }, [sendingMessageSignal])
126 | return null
127 | }
128 |
--------------------------------------------------------------------------------
/src/worker/timeout-content-detection.tsx:
--------------------------------------------------------------------------------
1 | import React, {useEffect} from "react"
2 | import {appState} from "../state/app-state.ts"
3 | import {setErrorIfTimeout} from "../data-structure/message.tsx"
4 |
5 | // if content stays at 'sending', 'thinking' or 'receiving' status for over contentTimeoutSeconds, mark it as timeout
6 | export const TimeoutContentDetection: React.FC = () => {
7 |
8 | useEffect(() => {
9 | const interval = setInterval(() => {
10 | const chats = appState.chats
11 | // for better performance, only check last 20 messages
12 | for (const entry of Object.entries(chats)) {
13 | for (const message of entry[1].messages.slice(-20)) {
14 | setErrorIfTimeout(message)
15 | }
16 | }
17 | }, 2000)
18 |
19 | return () => {
20 | if (interval) {
21 | clearInterval(interval)
22 | }
23 | }
24 | }, [])
25 | return null
26 | }
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/src/worker/workers.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import {SubscribeSendingMessage} from "./subscribe-sending-message"
3 | import {TimeoutContentDetection} from "./timeout-content-detection"
4 | import {SubscribeAudioDurationUpdate} from "./subscribe-audio-duration-update.tsx"
5 |
6 | export const Workers: React.FC = () => {
7 | return <>
8 |
9 |
10 |
11 | >
12 | }
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | import defaultTheme from 'tailwindcss/defaultTheme'
3 | import typography from '@tailwindcss/typography';
4 |
5 | export default {
6 | mode: 'jit', // https://v2.tailwindcss.com/docs/just-in-time-mode
7 | content: [
8 | "./index.html",
9 | "./src/**/*.{js,ts,jsx,tsx}",
10 | ],
11 | theme: {
12 | extend: {
13 | animation: {
14 | 'spin-slow': 'spin 5s linear infinite',
15 | },
16 | colors: {
17 | 'blue-grey': 'rgb(85,121,235)',
18 | 'slider-blue': '#61d0f0',
19 | 'slider-red': 'rgb(184, 42, 21)',
20 | 'slider-pink': '#f56e83'
21 | // 'slider-blue':'#61d0f0',
22 | // 'slider-red':'rgb(184, 42, 21)',
23 | // 'slider-pink':'#f56e83'
24 | },
25 | maxWidth: {
26 | '11': '2.75rem',
27 | '60': '20rem',
28 | '80': '20rem',
29 | '86': '22rem',
30 | '96': '24rem',
31 | '1/4': '25%',
32 | '1/5': '20%',
33 | '1/2': '50%',
34 | '2/5': '40%',
35 | '3/4': '75%',
36 | 'screen-105': '105vw',
37 | },
38 | minWidth: {
39 | '11': '2.75rem',
40 | '12': '3rem',
41 | '60': '20rem',
42 | '80': '20rem',
43 | '86': '22rem',
44 | '96': '24rem',
45 | '1/2': '50%',
46 | '1/4': '25%',
47 | '1/5': '20%',
48 | '2/5': '40%',
49 | '3/4': '75%',
50 | 'screen-105': '105vw',
51 | },
52 | width: {
53 | 'screen-105': '105vw',
54 | '26': '6.5rem',
55 | },
56 | maxHeight: {
57 | 'screen-105': '105vh',
58 | },
59 | height: {
60 | 'screen-105': '105vh',
61 | },
62 | minHeight: {
63 | '12': '3rem',
64 | '24': '6rem',
65 | '96': '24rem',
66 | },
67 | transitionDuration: {
68 | '1500': '1500ms',
69 | '2500': '2500ms',
70 | '2000': '2000ms',
71 | '3000': '3000ms',
72 | '4000': '4000ms',
73 | '5000': '5000ms',
74 | },
75 | brightness: {
76 | 25: '.25',
77 | },
78 | fontFamily: {
79 | 'borel': ['borel', ...defaultTheme.fontFamily.sans],
80 | },
81 | backgroundImage: {
82 | // we have no copyright. limited to personal use. do not put them on a website that is public accessible
83 | // see https://www.behance.net/gallery/84818869/Fuzzies-vol-1
84 | 'balloon': "url('/src/assets/bg/no-copyright/959e3384818869.5d6bfdf2b5e1b.png')",
85 | 'walk-in-green': "url('/src/assets/bg/no-copyright/84974784818869.5d6bfdf4e8260.png')",
86 |
87 | 'noise': "url('/src/assets/bg/noise.svg')",
88 | 'noise-lg': "url('/src/assets/bg/noise-lg.svg')",
89 | }
90 | },
91 |
92 | },
93 | plugins: [
94 | typography
95 | ],
96 |
97 | }
98 |
99 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "lib": [
6 | "ES2020",
7 | "DOM",
8 | "DOM.Iterable"
9 | ],
10 | "module": "ESNext",
11 | "skipLibCheck": true,
12 | "types": [
13 | "node"
14 | ],
15 | /* Bundler mode */
16 | "moduleResolution": "node",
17 | "allowImportingTsExtensions": true,
18 | "resolveJsonModule": true,
19 | "isolatedModules": true,
20 | "noEmit": true,
21 | "jsx": "react-jsx",
22 | /* Linting */
23 | "strict": true,
24 | "noUnusedLocals": true,
25 | "noUnusedParameters": true,
26 | "noFallthroughCasesInSwitch": true,
27 | "allowSyntheticDefaultImports": true,
28 | "baseUrl": "src",
29 | "paths": {
30 | "*": [
31 | "src/*"
32 | ]
33 | // "components/*": [
34 | // "src/components/*"
35 | // ],
36 | // "utils/*": [
37 | // "src/utils/*"
38 | // ]
39 | }
40 | },
41 | "include": [
42 | "src"
43 | ],
44 | "references": [
45 | {
46 | "path": "./tsconfig.node.json"
47 | }
48 | ]
49 | }
50 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true
8 | },
9 | "include": ["vite.config.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import {defineConfig, splitVendorChunkPlugin} from 'vite'
2 | import react from '@vitejs/plugin-react'
3 | import svgr from 'vite-plugin-svgr'
4 | import {visualizer} from 'rollup-plugin-visualizer';
5 |
6 | const VISUALIZER = false; // change to see the current bundle.
7 |
8 | const bigPackages = [
9 | 'prompt.ts',
10 | 'lodash',
11 | 'react-nice',
12 | 'localforage',
13 | 'highlight.js',
14 | 'mathematica.js',
15 | 'entities.json',
16 | 'chroma.js',
17 | '',]
18 | // https://vitejs.dev/config/
19 | export default defineConfig({
20 | plugins: [
21 | react(),
22 | svgr(),
23 | splitVendorChunkPlugin(),
24 | VISUALIZER &&
25 | visualizer({
26 | open: true,
27 | gzipSize: true,
28 | filename: 'chunks-report.html',
29 | }),
30 | ],
31 | server: {
32 | host: '0.0.0.0'
33 | },
34 | base: './',
35 | build: {
36 | outDir: "build/dist",
37 | minify: true,
38 | emptyOutDir: true,
39 | rollupOptions: {
40 | output: {
41 | manualChunks(id: string) {
42 | for (const p of bigPackages) {
43 | if (p && id.includes(p)) {
44 | return p
45 | }
46 | }
47 | if (
48 | id.includes('react-router-dom') ||
49 | id.includes('@remix-run') ||
50 | id.includes('react-router')
51 | ) {
52 | return '@react-router';
53 | }
54 | },
55 | },
56 | },
57 | },
58 | })
59 |
--------------------------------------------------------------------------------