first commit
This commit is contained in:
commit
dea1b6a851
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
*.md
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
LICENSE
|
||||||
|
netlify.toml
|
||||||
|
vercel.json
|
7
.eslintignore
Normal file
7
.eslintignore
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
dist
|
||||||
|
public
|
||||||
|
node_modules
|
||||||
|
.netlify
|
||||||
|
.vercel
|
||||||
|
.github
|
||||||
|
.changeset
|
34
.eslintrc.js
Normal file
34
.eslintrc.js
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
module.exports = {
|
||||||
|
extends: ['@evan-yang', 'plugin:astro/recommended'],
|
||||||
|
rules: {
|
||||||
|
'no-console': 'off',
|
||||||
|
'react/display-name': 'off',
|
||||||
|
'react-hooks/rules-of-hooks': 'off',
|
||||||
|
'@typescript-eslint/no-use-before-define': 'off',
|
||||||
|
'@typescript-eslint/no-unused-vars': 'warn',
|
||||||
|
'react/jsx-key': 'off',
|
||||||
|
'import/namespace': 'off',
|
||||||
|
},
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
files: ['*.astro'],
|
||||||
|
parser: 'astro-eslint-parser',
|
||||||
|
parserOptions: {
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
extraFileExtensions: ['.astro'],
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'no-mixed-spaces-and-tabs': ['error', 'smart-tabs'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Define the configuration for `<script>` tag.
|
||||||
|
// Script in `<script>` is assigned a virtual file name with the `.js` extension.
|
||||||
|
files: ['**/*.astro/*.js', '*.astro/*.js'],
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
rules: {
|
||||||
|
'prettier/prettier': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
62
.github/ISSUE_TEMPLATE/bug_report_when_use.yml
vendored
Normal file
62
.github/ISSUE_TEMPLATE/bug_report_when_use.yml
vendored
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
name: 🐞 Bug report (When using)
|
||||||
|
description: Report an issue or possible bug when using `anse.app`
|
||||||
|
labels: ['pending triage', 'use']
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
### Before submitting...
|
||||||
|
Thanks for taking the time to fill out this bug report! Please confirm the following points before submitting:
|
||||||
|
|
||||||
|
✅ I am using Anse's **official site** ([anse.app](https://anse.app)) or a fork version that has not been modified much.
|
||||||
|
✅ I have checked the bug was not already reported by searching on GitHub under issues.
|
||||||
|
✅ Use English to ask questions. This allows more people to search and participate in the issue.
|
||||||
|
- type: input
|
||||||
|
id: os
|
||||||
|
attributes:
|
||||||
|
label: What operating system are you using?
|
||||||
|
placeholder: Mac, Windows, Linux
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: browser
|
||||||
|
attributes:
|
||||||
|
label: What browser are you using?
|
||||||
|
placeholder: Chrome, Firefox, Safari
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: bug-description
|
||||||
|
attributes:
|
||||||
|
label: Describe the bug
|
||||||
|
description: A clear and concise description of what the bug is.
|
||||||
|
placeholder: Bug description
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: provider
|
||||||
|
attributes:
|
||||||
|
label: What provider are you using?
|
||||||
|
description: If the issue is related to a provider, please fill in this field.
|
||||||
|
options:
|
||||||
|
- N/A
|
||||||
|
- OpenAI
|
||||||
|
- Stable Diffusion
|
||||||
|
- Others (Specify in description)
|
||||||
|
- type: textarea
|
||||||
|
id: prompt
|
||||||
|
attributes:
|
||||||
|
label: What prompt did you enter?
|
||||||
|
description: If the issue is related to the prompt you entered, please fill in this field.
|
||||||
|
- type: textarea
|
||||||
|
id: console-logs
|
||||||
|
attributes:
|
||||||
|
label: Console Logs
|
||||||
|
description: Please check your browser and fill in the error message if it exists.
|
||||||
|
- type: checkboxes
|
||||||
|
id: will-pr
|
||||||
|
attributes:
|
||||||
|
label: Participation
|
||||||
|
options:
|
||||||
|
- label: I am willing to submit a pull request for this issue.
|
||||||
|
required: false
|
47
.github/ISSUE_TEMPLATE/bus_report_when_deploying.yml
vendored
Normal file
47
.github/ISSUE_TEMPLATE/bus_report_when_deploying.yml
vendored
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
name: 🐞 Bug report (When self-deploying)
|
||||||
|
description: Report an issue or possible bug when deploy to your own server or cloud.
|
||||||
|
labels: ['pending triage', 'deploy']
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
### Before submitting...
|
||||||
|
Thanks for taking the time to fill out this bug report! Please confirm the following points before submitting:
|
||||||
|
|
||||||
|
✅ I am using **latest version of Anse**.
|
||||||
|
✅ I have checked the bug was not already reported by searching on GitHub under issues.
|
||||||
|
✅ Use English to ask questions. This allows more people to search and participate in the issue.
|
||||||
|
- type: dropdown
|
||||||
|
id: server
|
||||||
|
attributes:
|
||||||
|
label: How is Anse deployed?
|
||||||
|
description: Select the used deployment method.
|
||||||
|
options:
|
||||||
|
- Node
|
||||||
|
- Docker
|
||||||
|
- Vercel
|
||||||
|
- Netlify
|
||||||
|
- Railway
|
||||||
|
- Others (Specify in description)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: bug-description
|
||||||
|
attributes:
|
||||||
|
label: Describe the bug
|
||||||
|
description: A clear and concise description of what the bug is.
|
||||||
|
placeholder: Bug description
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: console-logs
|
||||||
|
attributes:
|
||||||
|
label: Console Logs
|
||||||
|
description: Please check your browser and node console, fill in the error message if it exists.
|
||||||
|
- type: checkboxes
|
||||||
|
id: will-pr
|
||||||
|
attributes:
|
||||||
|
label: Participation
|
||||||
|
options:
|
||||||
|
- label: I am willing to submit a pull request for this issue.
|
||||||
|
required: false
|
11
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
11
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: 📚 Documentation
|
||||||
|
url: https://docs.anse.app/
|
||||||
|
about: Check the documentation for usage of Anse, or make improvements to it.
|
||||||
|
- name: ⏱️ Roadmap
|
||||||
|
url: https://github.com/orgs/anse-app/projects/2
|
||||||
|
about: Explore upcoming features.
|
||||||
|
- name: 💬 Discussions
|
||||||
|
url: https://github.com/anse-app/anse/discussions
|
||||||
|
about: Use discussions if you have an idea for improvement or for asking questions.
|
32
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
32
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
name: 🚀 Feature request
|
||||||
|
description: Suggest a feature or an improvement
|
||||||
|
labels: ['enhancement']
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
### Before submitting...
|
||||||
|
Thank you for taking the time to fill out this feature request! Please confirm the following points before submitting:
|
||||||
|
|
||||||
|
✅ I have checked the feature was not already submitted by searching on GitHub under issues or discussions.
|
||||||
|
✅ I have checked the feature was not listed on [Anse's Roadmap](https://github.com/orgs/anse-app/projects/2).
|
||||||
|
✅ Use English. This allows more people to search and participate in the issue.
|
||||||
|
- type: textarea
|
||||||
|
id: feature-description
|
||||||
|
attributes:
|
||||||
|
label: Describe the feature
|
||||||
|
description: A clear and concise description of what you think would be a helpful addition.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: additional-context
|
||||||
|
attributes:
|
||||||
|
label: Additional context
|
||||||
|
description: Any other context or screenshots about the feature request here.
|
||||||
|
- type: checkboxes
|
||||||
|
id: will-pr
|
||||||
|
attributes:
|
||||||
|
label: Participation
|
||||||
|
options:
|
||||||
|
- label: I am willing to submit a pull request for this feature.
|
||||||
|
required: false
|
15
.github/ISSUE_TEMPLATE/typo.yml
vendored
Normal file
15
.github/ISSUE_TEMPLATE/typo.yml
vendored
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
name: 👀 Typo / Grammar fix
|
||||||
|
description: You can just go ahead and send a PR! Thank you!
|
||||||
|
labels: []
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
## PR Welcome!
|
||||||
|
|
||||||
|
If the typo / grammar issue is trivial and straightforward, you can help by **directly sending a quick pull request**!
|
||||||
|
If you spot multiple of them, we suggest combining them into a single PR. Thanks!
|
||||||
|
- type: textarea
|
||||||
|
id: context
|
||||||
|
attributes:
|
||||||
|
label: Additional context
|
18
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
18
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<!-- DO NOT IGNORE THE TEMPLATE!
|
||||||
|
Thank you for contributing!
|
||||||
|
Before submitting the PR, please make sure you do the following:
|
||||||
|
- Discuss first. It's always better to open a feature request issue first to discuss with the maintainers whether the feature is desired and the design of those features.
|
||||||
|
- Use [Conventional Commits](https://www.conventionalcommits.org/) for commit messages.
|
||||||
|
- Check that there isn't already a PR that solves the problem the same way to avoid creating a duplicate.
|
||||||
|
-->
|
||||||
|
|
||||||
|
### Description
|
||||||
|
|
||||||
|
<!-- Please insert your description here and provide especially info about the "what" this PR is solving -->
|
||||||
|
|
||||||
|
### Linked Issues
|
||||||
|
|
||||||
|
|
||||||
|
### Additional context
|
||||||
|
|
||||||
|
<!-- e.g. is there anything you'd like reviewers to focus on? -->
|
36
.github/workflows/build-docker.yml
vendored
Normal file
36
.github/workflows/build-docker.yml
vendored
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
name: build_docker
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build_docker:
|
||||||
|
name: Build docker
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v2
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
- name: Login to DockerHub
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
# https://hub.docker.com/settings/security?generateToken=true
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
- name: Build and push
|
||||||
|
id: docker_build
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
tags: |
|
||||||
|
${{ secrets.DOCKERHUB_USERNAME }}/anse:${{ github.ref_name }}
|
||||||
|
${{ secrets.DOCKERHUB_USERNAME }}/anse:latest
|
31
.github/workflows/lint.yml
vendored
Normal file
31
.github/workflows/lint.yml
vendored
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
name: Lint CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v2
|
||||||
|
|
||||||
|
- name: Set node
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 18.x
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install
|
||||||
|
run: pnpm install --no-frozen-lockfile
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: pnpm run lint
|
30
.github/workflows/release.yml
vendored
Normal file
30
.github/workflows/release.yml
vendored
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v2
|
||||||
|
|
||||||
|
- name: Set node
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 18.x
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- run: npx changelogithub
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{secrets.GH_TOKEN}}
|
40
.github/workflows/sync.yml
vendored
Normal file
40
.github/workflows/sync.yml
vendored
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
name: Upstream Sync
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 0 * * *" # every day
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync_latest_from_upstream:
|
||||||
|
name: Sync latest commits from upstream repo
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: ${{ github.event.repository.fork }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# Step 1: run a standard checkout action
|
||||||
|
- name: Checkout target repo
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
# Step 2: run the sync action
|
||||||
|
- name: Sync upstream changes
|
||||||
|
id: sync
|
||||||
|
uses: aormsby/Fork-Sync-With-Upstream-action@v3.4
|
||||||
|
with:
|
||||||
|
upstream_sync_repo: anse-app/anse
|
||||||
|
upstream_sync_branch: main
|
||||||
|
target_sync_branch: main
|
||||||
|
target_repo_token: ${{ secrets.GITHUB_TOKEN }} # automatically generated, no need to set
|
||||||
|
|
||||||
|
# Set test_mode true to run tests instead of the true action!!
|
||||||
|
test_mode: false
|
||||||
|
|
||||||
|
- name: Sync check
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
echo "::error::由于权限不足,导致同步失败(这是预期的行为),请前往仓库首页手动执行[Sync fork]。"
|
||||||
|
echo "::error::Due to insufficient permissions, synchronization failed (as expected). Please go to the repository homepage and manually perform [Sync fork]."
|
||||||
|
exit 1
|
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
# build output
|
||||||
|
dist/
|
||||||
|
.vercel/
|
||||||
|
.netlify/
|
||||||
|
|
||||||
|
# generated types
|
||||||
|
.astro/
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# environment variables
|
||||||
|
.env
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# macOS-specific files
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Local
|
||||||
|
*.local
|
||||||
|
|
||||||
|
**/.DS_Store
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.idea
|
3
.npmrc
Normal file
3
.npmrc
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
registry=https://registry.npmjs.org/
|
||||||
|
strict-peer-dependencies=false
|
||||||
|
auto-install-peers=true
|
4
.vscode/extensions.json
vendored
Normal file
4
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["astro-build.astro-vscode","dbaeumer.vscode-eslint","antfu.unocss"],
|
||||||
|
"unwantedRecommendations": [],
|
||||||
|
}
|
11
.vscode/launch.json
vendored
Normal file
11
.vscode/launch.json
vendored
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"command": "./node_modules/.bin/astro dev",
|
||||||
|
"name": "Development server",
|
||||||
|
"request": "launch",
|
||||||
|
"type": "node-terminal"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
17
.vscode/settings.json
vendored
Normal file
17
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll.eslint": true
|
||||||
|
},
|
||||||
|
"editor.formatOnSave": false,
|
||||||
|
"eslint.validate": [
|
||||||
|
"javascript",
|
||||||
|
"javascriptreact",
|
||||||
|
"astro", // Enable .astro
|
||||||
|
"typescript", // Enable .ts
|
||||||
|
"typescriptreact" // Enable .tsx
|
||||||
|
],
|
||||||
|
"i18n-ally.localesPaths": [
|
||||||
|
"src/locale",
|
||||||
|
"src/locale/lang"
|
||||||
|
]
|
||||||
|
}
|
10
Dockerfile
Normal file
10
Dockerfile
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
FROM node:alpine
|
||||||
|
WORKDIR /usr/src
|
||||||
|
RUN npm install -g pnpm
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
RUN pnpm install
|
||||||
|
COPY . .
|
||||||
|
RUN pnpm run build
|
||||||
|
ENV HOST=0.0.0.0 PORT=3000 NODE_ENV=production
|
||||||
|
EXPOSE $PORT
|
||||||
|
CMD ["node", "dist/server/entry.mjs"]
|
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2023 Diu
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
81
README.md
Normal file
81
README.md
Normal file
@ -0,0 +1,81 @@
|
|||||||
|

|
||||||
|
|
||||||
|
# Anse
|
||||||
|
|
||||||
|
English | [简体中文](./README.zh-CN.md)
|
||||||
|
|
||||||
|
Anse is a fully optimized UI for AI Chats.
|
||||||
|
|
||||||
|
- 🍿 **Live preview**: https://anse.app
|
||||||
|
- 📖 **Documentation**: https://docs.anse.app
|
||||||
|
- ✨ **Release Notes**: https://github.com/anse-app/anse/releases
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **🚀 Powerful Plugin System** - Powered by `Provider plugin` , easy to extend AI platforms such as [OpenAI](https://openai.com/), [Replicate](https://replicate.com/), and also supports custom model parameters.
|
||||||
|
- **💬 Session Record Saving** - We use `IndexDB` to store local data, it will not be uploaded to the server, security issues are guaranteed.
|
||||||
|
- **🎉 Multiple Session Modes** - Provides different conversations modes,support `Single Conversation`, `Continuous Conversation`, `OpenAI Image Generation`、`Stable Diffusion` and more.
|
||||||
|
- **💎 Improved UI Experience** - We have refactored the website UI for the previous version, optimized a lot of details, and also adapted to `mobile end` and `dark mode`.
|
||||||
|
- **🌈 One-Click Deployment** - Support one-click deployment, abandoned use environment variables, you can refer to our documentation to deploy the website to [Vercel](https://vercel.com/), [Netlify](https://www.netlify.com/), `Docker`, `Node` and other platforms.
|
||||||
|
|
||||||
|
## Running Locally
|
||||||
|
|
||||||
|
### Pre environment
|
||||||
|
1. **Node**: Check that both your development environment and deployment environment are using `Node v18` or later. You can use [nvm](https://github.com/nvm-sh/nvm) to manage multiple `node` versions locally。
|
||||||
|
```bash
|
||||||
|
node -v
|
||||||
|
```
|
||||||
|
2. **PNPM**: We recommend using [pnpm](https://pnpm.io/) to manage dependencies. If you have never installed pnpm, you can install it with the following command:
|
||||||
|
```bash
|
||||||
|
npm i -g pnpm
|
||||||
|
```
|
||||||
|
3. **OPENAI_API_KEY**: Before running this application, you need to obtain the API key from OpenAI. You can register the API key at [https://beta.openai.com/signup](https://beta.openai.com/signup).
|
||||||
|
|
||||||
|
### Getting Started
|
||||||
|
|
||||||
|
1. Install dependencies
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
2. Run the application, the local project runs on `http://localhost:3000/`
|
||||||
|
```bash
|
||||||
|
pnpm run dev
|
||||||
|
```
|
||||||
|
3. Add your [OpenAI API key](https://platform.openai.com/account/api-keys) to the settings panel, then enjoy it!
|
||||||
|
|
||||||
|
## How to deploy
|
||||||
|
For more details, please refer to this document: https://docs.anse.app/self-deploy
|
||||||
|
|
||||||
|
## Enable Automatic Updates
|
||||||
|
|
||||||
|
After forking the project, you need to manually enable Workflows and Upstream Sync Action on the Actions page of the forked project. Once enabled, automatic updates will be scheduled every day:
|
||||||
|
|
||||||
|

|
||||||
|
## Frequently Asked Questions
|
||||||
|
|
||||||
|
Q: TypeError: fetch failed (can't connect to OpenAI Api)
|
||||||
|
|
||||||
|
A: Reference: https://github.com/anse-app/chatgpt-demo/issues/34
|
||||||
|
|
||||||
|
Q: throw new TypeError(`${context}` is not a ReadableStream.)
|
||||||
|
|
||||||
|
A: The Node version needs to be `v18` or later,reference: https://github.com/anse-app/chatgpt-demo/issues/65
|
||||||
|
|
||||||
|
Q: Accelerate domestic access without the need for proxy deployment tutorial?
|
||||||
|
|
||||||
|
A: You can refer to this tutorial: https://github.com/anse-app/chatgpt-demo/discussions/270
|
||||||
|
|
||||||
|
Q: `PWA` is not working?
|
||||||
|
|
||||||
|
A: Current `PWA` does not support deployment on Netlify, you can choose vercel or node deployment.
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
This project exists thanks to all those who contributed.
|
||||||
|
|
||||||
|
Thank you to all our supporters!🙏
|
||||||
|
|
||||||
|
[](https://github.com/anse-app/anse/graphs/contributors)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT © [ddiu8081](https://github.com/anse-app/anse/blob/main/LICENSE)
|
84
README.zh-CN.md
Normal file
84
README.zh-CN.md
Normal file
@ -0,0 +1,84 @@
|
|||||||
|

|
||||||
|
|
||||||
|
# Anse
|
||||||
|
|
||||||
|
[English](./README.md) | 简体中文
|
||||||
|
|
||||||
|
Anse 是一个极致优化的 AI 聊天 UI.
|
||||||
|
|
||||||
|
- 🍿 **在线预览**: https://anse.app
|
||||||
|
- 📖 **文档地址**: https://docs.anse.app
|
||||||
|
- ✨ **版本日志**: https://github.com/anse-app/anse/releases
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- **🚀 强大的插件系统** - 归功于 `Provider plugin` ,轻松扩展类似于 [OpenAI](https://openai.com/), [Replicate](https://replicate.com/) 等 AI 平台, 并且支持自定义模型参数.
|
||||||
|
- **💬 会话记录保存** - 使用 `IndexDB` 保存本地数据,不会上传到服务器,保证安全问题。.
|
||||||
|
- **🎉 多种对话模式** - 提供不同的对话模式:`单词对话`, `连续对话`, `OpenAI 图像生成`、`Stable Diffusion` 和更多.
|
||||||
|
- **💎 优化用户界面体验** - 我们对上一个版本重构了网站用户界面,优化了很多细节,还适应了移动端和黑暗模式.
|
||||||
|
- **🌈 一键部署** -支持一键部署,不再需要环境变量,可以参考我们的留档将网站部署到 [Vercel](https://vercel.com/), [Netlify](https://www.netlify.com/), `Docker`, `Node` 和更多平台.
|
||||||
|
|
||||||
|
## 本地运行
|
||||||
|
|
||||||
|
### 前置环境
|
||||||
|
1. **Node**: 检查您的开发环境和部署环境是否都使用 `Node v18` 或更高版本。你可以使用 [nvm](https://github.com/nvm-sh/nvm) 管理本地多个 `node` 版本
|
||||||
|
```bash
|
||||||
|
node -v
|
||||||
|
```
|
||||||
|
2. **PNPM**: 我们推荐使用 [pnpm](https://pnpm.io/) 来管理依赖,如果你从来没有安装过 pnpm,可以使用下面的命令安装:
|
||||||
|
```bash
|
||||||
|
npm i -g pnpm
|
||||||
|
```
|
||||||
|
3. **OPENAI_API_KEY**: 在运行此应用程序之前,您需要从 OpenAI 获取 API 密钥。您可以在 [https://beta.openai.com/signup](https://beta.openai.com/signup) 注册 API 密钥
|
||||||
|
|
||||||
|
### 起步运行
|
||||||
|
|
||||||
|
1. 安装依赖
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
2. 运行应用,本地项目运行在 `http://localhost:3000/`
|
||||||
|
```bash
|
||||||
|
pnpm run dev
|
||||||
|
```
|
||||||
|
3. 在设置面板添加你的 [OpenAI API key](https://platform.openai.com/account/api-keys), 然后尽情享受吧!
|
||||||
|
|
||||||
|
## 部署
|
||||||
|
|
||||||
|
获取更多信息,请参考部署文档: https://docs.anse.app/self-deploy
|
||||||
|
|
||||||
|
## 开启同步更新
|
||||||
|
|
||||||
|
Fork 项目后,您需要在 Fork 项目的操作页面上手动启用工作流和上游同步操作。启用后,每天都会执行自动更新:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
Q: TypeError: fetch failed (can't connect to OpenAI Api)
|
||||||
|
|
||||||
|
A: 参考: https://github.com/anse-app/chatgpt-demo/issues/34
|
||||||
|
|
||||||
|
Q: throw new TypeError(`${context}` is not a ReadableStream.)
|
||||||
|
|
||||||
|
A: Node 版本需要在 `v18` 或者更高,参考: https://github.com/anse-app/chatgpt-demo/issues/65
|
||||||
|
|
||||||
|
Q: 无需代理部署教程即可加速国内访问??
|
||||||
|
|
||||||
|
A: 你可以参考此教程: https://github.com/anse-app/chatgpt-demo/discussions/270
|
||||||
|
|
||||||
|
Q: `PWA` 不工作?
|
||||||
|
|
||||||
|
A: 当前的 PWA 不支持 Netlify 部署,您可以选择 vercel 或 node 部署。
|
||||||
|
|
||||||
|
## 参与贡献
|
||||||
|
|
||||||
|
这个项目的存在要感谢所有做出贡献的人。
|
||||||
|
|
||||||
|
感谢我们所有的支持者!🙏
|
||||||
|
|
||||||
|
[](https://github.com/anse-app/anse/graphs/contributors)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT © [ddiu8081](https://github.com/anse-app/anse/blob/main/LICENSE)
|
21
SECURITY.md
Normal file
21
SECURITY.md
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported Versions
|
||||||
|
|
||||||
|
Use this section to tell people about which versions of your project are
|
||||||
|
currently being supported with security updates.
|
||||||
|
|
||||||
|
| Version | Supported |
|
||||||
|
| ------- | ------------------ |
|
||||||
|
| 5.1.x | :white_check_mark: |
|
||||||
|
| 5.0.x | :x: |
|
||||||
|
| 4.0.x | :white_check_mark: |
|
||||||
|
| < 4.0 | :x: |
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
Use this section to tell people how to report a vulnerability.
|
||||||
|
|
||||||
|
Tell them where to go, how often they can expect to get an update on a
|
||||||
|
reported vulnerability, what to expect if the vulnerability is accepted or
|
||||||
|
declined, etc.
|
74
astro.config.mjs
Normal file
74
astro.config.mjs
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { defineConfig } from 'astro/config'
|
||||||
|
import unocss from 'unocss/astro'
|
||||||
|
import solidJs from '@astrojs/solid-js'
|
||||||
|
import node from '@astrojs/node'
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
import vercel from '@astrojs/vercel/edge'
|
||||||
|
import netlify from '@astrojs/netlify/edge-functions'
|
||||||
|
import disableBlocks from './plugins/disableBlocks'
|
||||||
|
|
||||||
|
const envAdapter = () => {
|
||||||
|
if (process.env.OUTPUT === 'vercel') {
|
||||||
|
return vercel()
|
||||||
|
} else if (process.env.OUTPUT === 'netlify') {
|
||||||
|
return netlify()
|
||||||
|
} else {
|
||||||
|
return node({
|
||||||
|
mode: 'standalone',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://astro.build/config
|
||||||
|
export default defineConfig({
|
||||||
|
integrations: [
|
||||||
|
unocss(),
|
||||||
|
solidJs(),
|
||||||
|
],
|
||||||
|
// output: 'server',
|
||||||
|
adapter: envAdapter(),
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
},
|
||||||
|
vite: {
|
||||||
|
plugins: [
|
||||||
|
process.env.OUTPUT === 'vercel' && disableBlocks(),
|
||||||
|
process.env.OUTPUT === 'netlify' && disableBlocks('netlify'),
|
||||||
|
process.env.OUTPUT !== 'netlify' && VitePWA({
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
manifest: {
|
||||||
|
name: 'Ansnid',
|
||||||
|
short_name: 'Ansnid',
|
||||||
|
description: 'Ansnid is a fully optimized UI for AI Chats.',
|
||||||
|
theme_color: '#101010',
|
||||||
|
background_color: '#ffffff',
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: 'pwa-192.png',
|
||||||
|
sizes: '192x192',
|
||||||
|
type: 'image/png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'pwa-512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'logo.svg',
|
||||||
|
sizes: '32x32',
|
||||||
|
type: 'image/svg',
|
||||||
|
purpose: 'any maskable',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
client: {
|
||||||
|
installPrompt: true,
|
||||||
|
periodicSyncForUpdates: 20,
|
||||||
|
},
|
||||||
|
devOptions: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
8
docker-compose.yml
Normal file
8
docker-compose.yml
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
version: '3'
|
||||||
|
services:
|
||||||
|
anse-demo:
|
||||||
|
image: ddiu8081/anse:latest
|
||||||
|
container_name: anse
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- '3000:3000'
|
12
netlify.toml
Normal file
12
netlify.toml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
[build.environment]
|
||||||
|
NETLIFY_USE_PNPM = "true"
|
||||||
|
NODE_VERSION = "18"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
command = "OUTPUT=netlify astro build"
|
||||||
|
publish = "dist"
|
||||||
|
|
||||||
|
[[headers]]
|
||||||
|
for = "/manifest.webmanifest"
|
||||||
|
[headers.values]
|
||||||
|
Content-Type = "application/manifest+json"
|
82
package.json
Normal file
82
package.json
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
{
|
||||||
|
"name": "Ansnid",
|
||||||
|
"version": "1.1.6",
|
||||||
|
"packageManager": "pnpm@7.28.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "astro dev",
|
||||||
|
"start": "astro dev",
|
||||||
|
"build": "astro build",
|
||||||
|
"build:vercel": "OUTPUT=vercel astro build",
|
||||||
|
"build:netlify": "OUTPUT=netlify astro build",
|
||||||
|
"preview": "astro preview",
|
||||||
|
"astro": "astro",
|
||||||
|
"lint": "eslint . --ext .js,.jsx,.ts,.tsx,.astro",
|
||||||
|
"lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx,.astro --fix",
|
||||||
|
"release": "bumpp",
|
||||||
|
"postinstall": "npx simple-git-hooks"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@astrojs/netlify": "2.0.0",
|
||||||
|
"@astrojs/node": "^5.1.1",
|
||||||
|
"@astrojs/solid-js": "^2.1.0",
|
||||||
|
"@astrojs/vercel": "^3.2.2",
|
||||||
|
"@mapbox/rehype-prism": "^0.8.0",
|
||||||
|
"@nanostores/solid": "^0.3.2",
|
||||||
|
"@solid-primitives/clipboard": "^1.5.4",
|
||||||
|
"@solid-primitives/keyboard": "^1.1.0",
|
||||||
|
"@solid-primitives/scheduled": "^1.3.2",
|
||||||
|
"@solid-primitives/scroll": "^2.0.14",
|
||||||
|
"@unocss/reset": "^0.50.6",
|
||||||
|
"@zag-js/dialog": "^0.9.2",
|
||||||
|
"@zag-js/menu": "^0.9.2",
|
||||||
|
"@zag-js/select": "^0.9.2",
|
||||||
|
"@zag-js/slider": "^0.9.2",
|
||||||
|
"@zag-js/solid": "^0.9.2",
|
||||||
|
"@zag-js/switch": "^0.9.2",
|
||||||
|
"@zag-js/toast": "^0.9.2",
|
||||||
|
"@zag-js/toggle": "^0.9.2",
|
||||||
|
"@zag-js/tooltip": "^0.9.2",
|
||||||
|
"astro": "^2.2.0",
|
||||||
|
"bumpp": "^9.1.0",
|
||||||
|
"destr": "^1.2.2",
|
||||||
|
"eslint": "^8.37.0",
|
||||||
|
"eventsource-parser": "^0.1.0",
|
||||||
|
"idb-keyval": "^6.2.0",
|
||||||
|
"js-sha256": "^0.9.0",
|
||||||
|
"katex": "^0.16.4",
|
||||||
|
"nanostores": "^0.7.4",
|
||||||
|
"prism-theme-vars": "^0.2.4",
|
||||||
|
"rehype-katex": "^6.0.2",
|
||||||
|
"rehype-stringify": "^9.0.3",
|
||||||
|
"remark-gfm": "^3.0.1",
|
||||||
|
"remark-math": "^5.1.1",
|
||||||
|
"remark-parse": "^10.0.1",
|
||||||
|
"remark-rehype": "^10.1.0",
|
||||||
|
"solid-emoji-picker": "^0.2.0",
|
||||||
|
"solid-js": "1.6.12",
|
||||||
|
"solid-transition-group": "^0.2.2",
|
||||||
|
"unified": "^10.1.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@evan-yang/eslint-config": "^1.0.9",
|
||||||
|
"@iconify-json/carbon": "^1.1.16",
|
||||||
|
"@iconify-json/simple-icons": "^1.1.48",
|
||||||
|
"@types/mapbox__rehype-prism": "^0.8.0",
|
||||||
|
"@typescript-eslint/parser": "^5.57.1",
|
||||||
|
"@unocss/preset-attributify": "^0.50.6",
|
||||||
|
"@unocss/preset-icons": "^0.50.6",
|
||||||
|
"@unocss/preset-typography": "^0.50.6",
|
||||||
|
"eslint-plugin-astro": "^0.24.0",
|
||||||
|
"lint-staged": "^13.2.2",
|
||||||
|
"punycode": "^2.3.0",
|
||||||
|
"simple-git-hooks": "^2.8.1",
|
||||||
|
"unocss": "^0.50.6",
|
||||||
|
"vite-plugin-pwa": "^0.14.7"
|
||||||
|
},
|
||||||
|
"simple-git-hooks": {
|
||||||
|
"pre-commit": "pnpm lint-staged"
|
||||||
|
},
|
||||||
|
"lint-staged": {
|
||||||
|
"*": "pnpm lint:fix"
|
||||||
|
}
|
||||||
|
}
|
22
plugins/disableBlocks.ts
Normal file
22
plugins/disableBlocks.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
export default function plugin(platform?: string) {
|
||||||
|
const transform = (code: string, id: string) => {
|
||||||
|
if (id.includes('pages/api/generate.ts')) {
|
||||||
|
return {
|
||||||
|
code: code.replace(/^.*?#vercel-disable-blocks([\s\S]+?)#vercel-end.*?$/gm, ''),
|
||||||
|
map: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (platform === 'netlify' && id.includes('layouts/Layout.astro')) {
|
||||||
|
return {
|
||||||
|
code: code.replace(/^.*?<!-- netlify-disable-blocks -->([\s\S]+?)<!-- netlify-disable-end -->.*?$/gm, ''),
|
||||||
|
map: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'vercel-disable-blocks',
|
||||||
|
enforce: 'pre',
|
||||||
|
transform,
|
||||||
|
}
|
||||||
|
}
|
10061
pnpm-lock.yaml
generated
Normal file
10061
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
BIN
public/apple-touch-icon.png
Normal file
BIN
public/apple-touch-icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.6 KiB |
10
public/logo.svg
Normal file
10
public/logo.svg
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" x2="0" y1="0" y2="1">
|
||||||
|
<stop stop-color="#d0b6fa" offset="0%" />
|
||||||
|
<stop stop-color="#947cff" offset="100%" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path fill="url(#bg)" d="M32.212 6.875C45.9522 6.875 57.125 18.3583 57.125 31.9914C57.125 45.6244 45.9695 57.0999 32.212 57.0999L6.875 57.125V31.5595C6.875 17.9344 18.4561 6.87657 32.212 6.87657V6.875Z" />
|
||||||
|
</svg>
|
||||||
|
|
After Width: | Height: | Size: 496 B |
BIN
public/pwa-192.png
Normal file
BIN
public/pwa-192.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.5 KiB |
BIN
public/pwa-512.png
Normal file
BIN
public/pwa-512.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.2 KiB |
2
public/robots.txt
Normal file
2
public/robots.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
16
shims.d.ts
vendored
Normal file
16
shims.d.ts
vendored
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import type { AttributifyAttributes } from '@unocss/preset-attributify'
|
||||||
|
|
||||||
|
// declare module 'solid-js' {
|
||||||
|
// namespace JSX {
|
||||||
|
// interface HTMLAttributes<T> extends AttributifyAttributes {}
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace astroHTML.JSX {
|
||||||
|
interface HTMLAttributes extends AttributifyAttributes { }
|
||||||
|
}
|
||||||
|
namespace JSX {
|
||||||
|
interface HTMLAttributes<> extends AttributifyAttributes {}
|
||||||
|
}
|
||||||
|
}
|
15
src/assets/emoji-picker.css
Normal file
15
src/assets/emoji-picker.css
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
.emoji-section-title {
|
||||||
|
@apply block text-xs mx-1 my-2 uppercase op-30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-button {
|
||||||
|
@apply hv-base;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
font-size: 26px;
|
||||||
|
font-family: "Apple Color Emoji", "Segoe UI Emoji", NotoColorEmoji, "Noto Color Emoji", "Segoe UI Symbol", "Android Emoji", EmojiSymbols;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
62
src/assets/prism.css
Normal file
62
src/assets/prism.css
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
@import "prism-theme-vars/base.css";
|
||||||
|
|
||||||
|
html:not(.dark) {
|
||||||
|
--prism-foreground: #333333;
|
||||||
|
--prism-background: #f6f6f6;
|
||||||
|
--prism-comment: #0000004f;
|
||||||
|
--prism-string: #377961;
|
||||||
|
--prism-literal: #6b588e;
|
||||||
|
--prism-keyword: #c05386;
|
||||||
|
--prism-function: #668f9a;
|
||||||
|
--prism-deleted: #cc6262;
|
||||||
|
--prism-class: #b5855c;
|
||||||
|
--prism-builtin: #c05386;
|
||||||
|
--prism-property: #6b588e;
|
||||||
|
--prism-namespace: #377961;
|
||||||
|
--prism-punctuation: #0000005f;
|
||||||
|
--prism-decorator: #668f9a;
|
||||||
|
--prism-operator: var(--prism-keyword);
|
||||||
|
--prism-number: #c7792b;
|
||||||
|
--prism-boolean: #c7792b;
|
||||||
|
--prism-constant: #c7792b;
|
||||||
|
--prism-selector: #377961;
|
||||||
|
--prism-regex: #6b588e;
|
||||||
|
--prism-json-property: var(--prism-literal);
|
||||||
|
--prism-line-number: #aaaaaa;
|
||||||
|
--prism-line-highlight-background: #f2f2f2;
|
||||||
|
--prism-block-padding-x: 1.25rem;
|
||||||
|
--prism-block-padding-y: 1.5rem;
|
||||||
|
--prism-block-radius: .375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark {
|
||||||
|
--prism-scheme: dark;
|
||||||
|
--prism-foreground: #dddddd;
|
||||||
|
--prism-background: #222222;
|
||||||
|
--prism-comment: #ffffff4f;
|
||||||
|
--prism-string: #74ccaa;
|
||||||
|
--prism-literal: #a0a5d6;
|
||||||
|
--prism-keyword: #ed9cc2;
|
||||||
|
--prism-function: #5fb5be;
|
||||||
|
--prism-deleted: #ff8787;
|
||||||
|
--prism-class: #f3a580;
|
||||||
|
--prism-builtin: #ed9cc2;
|
||||||
|
--prism-property: #a0a5d6;
|
||||||
|
--prism-namespace: #74ccaa;
|
||||||
|
--prism-punctuation: #ffffff5f;
|
||||||
|
--prism-decorator: #5fb5be;
|
||||||
|
--prism-operator: var(--prism-keyword);
|
||||||
|
--prism-number: #f6c177;
|
||||||
|
--prism-boolean: #f6c177;
|
||||||
|
--prism-constant: #f6c177;
|
||||||
|
--prism-selector: #74ccaa;
|
||||||
|
--prism-regex: #a0a5d6;
|
||||||
|
--prism-json-property: var(--prism-literal);
|
||||||
|
--prism-line-number: #666666;
|
||||||
|
--prism-line-number-gutter: #eeeeee;
|
||||||
|
--prism-line-highlight-background: #333333;
|
||||||
|
--prism-selection-background: #444444;
|
||||||
|
--prism-block-padding-x: 1.25rem;
|
||||||
|
--prism-block-padding-y: 1.5rem;
|
||||||
|
--prism-block-radius: .375rem;
|
||||||
|
}
|
56
src/assets/transition.css
Normal file
56
src/assets/transition.css
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
.slide-top-enter-active, .slide-top-exit-active,
|
||||||
|
.slide-bottom-enter-active, .slide-bottom-exit-active,
|
||||||
|
.slide-left-enter-active, .slide-left-exit-active,
|
||||||
|
.slide-right-enter-active, .slide-right-exit-active {
|
||||||
|
transition: opacity 0.3s, transform 0.36s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-top-enter, .slide-top-exit-to {
|
||||||
|
@apply -translate-y-20 opacity-0 sm:translate-y-2;
|
||||||
|
}
|
||||||
|
.slide-top-enter-to {
|
||||||
|
@apply translate-y-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-bottom-enter, .slide-bottom-exit-to {
|
||||||
|
@apply translate-y-20 opacity-0 sm:translate-y-2;
|
||||||
|
}
|
||||||
|
.slide-bottom-enter-to {
|
||||||
|
@apply translate-y-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-left-enter, .slide-left-exit-to {
|
||||||
|
@apply -translate-x-full opacity-0;
|
||||||
|
}
|
||||||
|
.slide-left-enter-to {
|
||||||
|
@apply translate-x-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-right-enter, .slide-right-exit-to {
|
||||||
|
@apply translate-x-full opacity-0;
|
||||||
|
}
|
||||||
|
.slide-right-enter-to {
|
||||||
|
@apply translate-x-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-anim::before {
|
||||||
|
content: ' ';
|
||||||
|
background-image: linear-gradient(90deg, #ffffff00 0%, var(--c-shadow) 35%, var(--c-shadow) 65%, #ffffff00 100%);
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
/* height: 1px; */
|
||||||
|
width: 60%;
|
||||||
|
animation-duration: 2s;
|
||||||
|
animation-iteration-count: infinite;
|
||||||
|
animation-name: progress-bar-loop;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes progress-bar-loop {
|
||||||
|
from {
|
||||||
|
left: -60%;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
left: 110%;
|
||||||
|
}
|
||||||
|
}
|
115
src/assets/zag-components.css
Normal file
115
src/assets/zag-components.css
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
/* -----------------------------------------------------------------------------
|
||||||
|
* Slider
|
||||||
|
* -----------------------------------------------------------------------------*/
|
||||||
|
|
||||||
|
[data-scope='slider'][data-part='root'] {
|
||||||
|
@apply w-full flex flex-col
|
||||||
|
}
|
||||||
|
[data-scope='slider'][data-part='root'][data-orientation='vertical'] {
|
||||||
|
@apply h-60
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-scope='slider'][data-part='control'] {
|
||||||
|
--slider-thumb-size: 14px;
|
||||||
|
--slider-track-height: 4px;
|
||||||
|
@apply relative fcc cursor-pointer
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-scope='slider'][data-part='control'][data-orientation='horizontal'] {
|
||||||
|
@apply h-[var(--slider-thumb-size)];
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-scope='slider'][data-part='control'][data-orientation='vertical'] {
|
||||||
|
@apply w-[var(--slider-thumb-size)];
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-scope='slider'][data-part='thumb'] {
|
||||||
|
all: unset;
|
||||||
|
@apply bg-gray-200 dark:bg-gray-500 w-[var(--slider-thumb-size)] h-[var(--slider-thumb-size)] rounded-full b-#c5c5d2 b-2
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-scope='slider'][data-part='thumb'][data-disabled] {
|
||||||
|
@apply w-0
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-scope='slider'] .control-area {
|
||||||
|
@apply flex mt-12px
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider [data-orientation='horizontal'] .control-area {
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider [data-orientation='vertical'] .control-area {
|
||||||
|
flex-direction: row;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-scope='slider'][data-part='track'] {
|
||||||
|
@apply rounded-full bg-gray-200 dark:bg-neutral-700
|
||||||
|
}
|
||||||
|
[data-scope='slider'][data-part='track'][data-orientation='horizontal'] {
|
||||||
|
@apply h-[var(--slider-track-height)] w-full;
|
||||||
|
}
|
||||||
|
[data-scope='slider'][data-part='track'][data-orientation='vertical'] {
|
||||||
|
@apply h-full w-[var(--slider-track-height)];
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-scope='slider'][data-part='range'] {
|
||||||
|
@apply bg-neutral-300 dark:bg-gray-700
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-scope='slider'][data-part='range'][data-disabled] {
|
||||||
|
@apply bg-neutral-300 dark:bg-gray-600
|
||||||
|
}
|
||||||
|
[data-scope='slider'][data-part='range'][data-orientation='horizontal'] {
|
||||||
|
@apply h-full;
|
||||||
|
}
|
||||||
|
[data-scope='slider'][data-part='range'][data-orientation='vertical'] {
|
||||||
|
@apply w-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-scope='slider'][data-part='output'] {
|
||||||
|
margin-inline-start: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-scope='slider'][data-part='marker'] {
|
||||||
|
color: lightgray;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------------------------------
|
||||||
|
* Select
|
||||||
|
* -----------------------------------------------------------------------------*/
|
||||||
|
|
||||||
|
[data-scope='select'][data-part='content'] {
|
||||||
|
@apply border border-base-100
|
||||||
|
}
|
||||||
|
[data-scope='select'][data-part='trigger'][data-expanded] {
|
||||||
|
@apply border border-base-100
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------------------------------
|
||||||
|
* Switch
|
||||||
|
* -----------------------------------------------------------------------------*/
|
||||||
|
|
||||||
|
[data-scope='switch'][data-part='root'] {
|
||||||
|
@apply relative mt-1 inline-block cursor-pointer
|
||||||
|
}
|
||||||
|
[data-scope='switch'][data-part='control'] {
|
||||||
|
@apply relative w-10 h-6 rounded-full shadow-inner bg-gray-400 transition-colors
|
||||||
|
}
|
||||||
|
[data-scope='switch'][data-part='control'][data-checked] {
|
||||||
|
@apply bg-emerald-600
|
||||||
|
}
|
||||||
|
[data-scope='switch'][data-part='control'][data-focus] {
|
||||||
|
/* box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.6); */
|
||||||
|
/* @apply shadow-lg shadow-cyan-500/50; */
|
||||||
|
@apply ring-1 ring-black/50 dark:ring-white/50
|
||||||
|
}
|
||||||
|
[data-scope='switch'][data-part='thumb'] {
|
||||||
|
@apply absolute inset-y-0 left-0 w-4 h-4 m-1 rounded-full bg-light dark:bg-dark transition-transform
|
||||||
|
}
|
||||||
|
[data-scope='switch'][data-part='thumb'][data-checked] {
|
||||||
|
@apply translate-x-full
|
||||||
|
}
|
18
src/components/Main.astro
Normal file
18
src/components/Main.astro
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
import Header from './header/Header'
|
||||||
|
import Send from './Send'
|
||||||
|
import '@/assets/prism.css'
|
||||||
|
import '@/assets/transition.css'
|
||||||
|
|
||||||
|
import Conversation from './main/Conversation'
|
||||||
|
---
|
||||||
|
|
||||||
|
<main class="relative h-full flex-1 flex flex-col overflow-hidden bg-base">
|
||||||
|
<Header client:only />
|
||||||
|
<main class="flex-1 mt-14 flex flex-col overflow-hidden">
|
||||||
|
<div class="flex-1 relative overflow-hidden">
|
||||||
|
<Conversation client:only />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<Send client:load />
|
||||||
|
</main>
|
41
src/components/Markdown.tsx
Normal file
41
src/components/Markdown.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { Show } from 'solid-js'
|
||||||
|
import { unified } from 'unified'
|
||||||
|
import remarkParse from 'remark-parse'
|
||||||
|
import remarkGfm from 'remark-gfm'
|
||||||
|
import remarkMath from 'remark-math'
|
||||||
|
import remarkRehype from 'remark-rehype'
|
||||||
|
import rehypeKatex from 'rehype-katex'
|
||||||
|
import rehypeStringify from 'rehype-stringify'
|
||||||
|
import rehypePrism from '@mapbox/rehype-prism'
|
||||||
|
import 'katex/dist/katex.min.css'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
class?: string
|
||||||
|
text: string
|
||||||
|
showRawCode?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseMarkdown = (raw: string) => {
|
||||||
|
const file = unified()
|
||||||
|
.use(remarkParse)
|
||||||
|
.use(remarkGfm)
|
||||||
|
.use(remarkMath)
|
||||||
|
.use(remarkRehype, { allowDangerousHtml: true })
|
||||||
|
.use(rehypePrism, {
|
||||||
|
ignoreMissing: true,
|
||||||
|
})
|
||||||
|
.use(rehypeKatex)
|
||||||
|
.use(rehypeStringify)
|
||||||
|
.processSync(raw)
|
||||||
|
return String(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default (props: Props) => {
|
||||||
|
const htmlString = () => props.showRawCode ? props.text : parseMarkdown(props.text)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={props.showRawCode} fallback={<div class={props.class ?? ''} innerHTML={htmlString()} />}>
|
||||||
|
<div class={`${props.class ?? ''} whitespace-pre-wrap overflow-auto my-0`} innerText={htmlString()} />
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
38
src/components/ModalsLayer.tsx
Normal file
38
src/components/ModalsLayer.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import {
|
||||||
|
showConversationEditModal,
|
||||||
|
showConversationSidebar,
|
||||||
|
showEmojiPickerModal,
|
||||||
|
showSettingsSidebar,
|
||||||
|
} from '@/stores/ui'
|
||||||
|
import ConversationSidebar from './conversations/ConversationSidebar'
|
||||||
|
import SettingsSidebar from './settings/SettingsSidebar'
|
||||||
|
import ConversationEditModal from './conversations/ConversationEditModal'
|
||||||
|
import EmojiPickerModal from './ui/EmojiPickerModal'
|
||||||
|
import Modal from './ui/Modal'
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal bindValue={showConversationSidebar} direction="left" closeBtnClass="hidden">
|
||||||
|
<div class="w-[70vw] max-w-[300px] h-full">
|
||||||
|
<ConversationSidebar />
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
<Modal bindValue={showSettingsSidebar} direction="right">
|
||||||
|
<div class="w-screen sm:w-[70vw] sm:max-w-[300px] h-full">
|
||||||
|
<SettingsSidebar />
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
<Modal bindValue={showConversationEditModal} direction="bottom" closeBtnClass="top-6 right-6">
|
||||||
|
<div class="max-h-[70vh] w-full">
|
||||||
|
<ConversationEditModal />
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
<Modal bindValue={showEmojiPickerModal} direction="bottom" closeBtnClass="top-6 right-6">
|
||||||
|
<div class="max-h-[70vh] w-full">
|
||||||
|
<EmojiPickerModal />
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
219
src/components/Send.tsx
Normal file
219
src/components/Send.tsx
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
import { Match, Switch, createSignal, onMount } from 'solid-js'
|
||||||
|
import { useStore } from '@nanostores/solid'
|
||||||
|
import { createShortcut } from '@solid-primitives/keyboard'
|
||||||
|
import { currentErrorMessage, isSendBoxFocus, scrollController } from '@/stores/ui'
|
||||||
|
import { addConversation, conversationMap, currentConversationId } from '@/stores/conversation'
|
||||||
|
import { loadingStateMap, streamsMap } from '@/stores/streams'
|
||||||
|
import { handlePrompt } from '@/logics/conversation'
|
||||||
|
import { globalAbortController } from '@/stores/settings'
|
||||||
|
import { useI18n, useMobileScreen } from '@/hooks'
|
||||||
|
import Button from './ui/Button'
|
||||||
|
import { fetchData } from '../http/api'
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
let inputRef: HTMLTextAreaElement
|
||||||
|
const $conversationMap = useStore(conversationMap)
|
||||||
|
const $currentConversationId = useStore(currentConversationId)
|
||||||
|
const $isSendBoxFocus = useStore(isSendBoxFocus)
|
||||||
|
const $currentErrorMessage = useStore(currentErrorMessage)
|
||||||
|
const $streamsMap = useStore(streamsMap)
|
||||||
|
const $loadingStateMap = useStore(loadingStateMap)
|
||||||
|
const $globalAbortController = useStore(globalAbortController)
|
||||||
|
|
||||||
|
const [inputPrompt, setInputPrompt] = createSignal('')
|
||||||
|
const [footerClass, setFooterClass] = createSignal('')
|
||||||
|
const isEditing = () => inputPrompt() || $isSendBoxFocus()
|
||||||
|
const currentConversation = () => {
|
||||||
|
return $conversationMap()[$currentConversationId()]
|
||||||
|
}
|
||||||
|
const isStreaming = () => !!$streamsMap()[$currentConversationId()]
|
||||||
|
const isLoading = () => !!$loadingStateMap()[$currentConversationId()]
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
|
||||||
|
fetchData({}, function(data) {
|
||||||
|
if(data.code==201 || data.code==401){
|
||||||
|
currentErrorMessage.set(data)
|
||||||
|
return ;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录后的一些操作
|
||||||
|
|
||||||
|
}, '/chatgptApi', 'POST');
|
||||||
|
|
||||||
|
|
||||||
|
createShortcut(['Control', 'Enter'], () => {
|
||||||
|
$isSendBoxFocus() && handleSend()
|
||||||
|
})
|
||||||
|
|
||||||
|
useMobileScreen(() => {
|
||||||
|
setFooterClass('sticky bottom-0 left-0 right-0 overflow-hidden')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const stateType = () => {
|
||||||
|
if ($currentErrorMessage())
|
||||||
|
return $currentErrorMessage().code==401 ? 'login' : 'error'
|
||||||
|
else if (isLoading() || isStreaming())
|
||||||
|
return 'loading'
|
||||||
|
else if (isEditing())
|
||||||
|
return 'editing'
|
||||||
|
else
|
||||||
|
return 'normal'
|
||||||
|
}
|
||||||
|
|
||||||
|
const EmptyState = () => (
|
||||||
|
<div
|
||||||
|
class="max-w-base h-full fi flex-row gap-2"
|
||||||
|
onClick={() => {
|
||||||
|
isSendBoxFocus.set(true)
|
||||||
|
inputRef.focus()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex-1 op-30 text-sm">{t('send.placeholder')}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const EditState = () => (
|
||||||
|
<div class="h-full flex flex-col">
|
||||||
|
<div class="flex-1 relative">
|
||||||
|
<textarea
|
||||||
|
ref={inputRef!}
|
||||||
|
placeholder={t('send.placeholder')}
|
||||||
|
autocomplete="off"
|
||||||
|
onBlur={() => { isSendBoxFocus.set(false) }}
|
||||||
|
onInput={() => { setInputPrompt(inputRef.value) }}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
e.key === 'Enter' && !e.isComposing && !e.shiftKey && handleSend()
|
||||||
|
}}
|
||||||
|
class="h-full w-full absolute inset-0 py-4 px-[calc(max(1.5rem,(100%-48rem)/2))] scroll-pa-4 input-base text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="fi justify-between gap-2 h-14 px-[calc(max(1.5rem,(100%-48rem)/2)-0.5rem)] border-t border-base">
|
||||||
|
<div>
|
||||||
|
{/*<Button
|
||||||
|
icon="i-carbon-plug"
|
||||||
|
onClick={() => {}}
|
||||||
|
/>*/}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
icon="i-carbon-send"
|
||||||
|
onClick={handleSend}
|
||||||
|
variant={inputPrompt() ? 'primary' : 'normal'}
|
||||||
|
prefix={t('send.button')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const LoginState = () => (
|
||||||
|
<div class="max-w-base h-full flex items-end flex-col justify-between gap-8 sm:(flex-row items-center) py-4 text-error text-sm" style="padding: 1rem;">
|
||||||
|
<div class="flex-1 w-full">
|
||||||
|
<div class="fi gap-0.5 mb-1">
|
||||||
|
<span i-carbon-warning />
|
||||||
|
<span class="font-semibold">{$currentErrorMessage()?.code}</span>
|
||||||
|
</div>
|
||||||
|
<div>{$currentErrorMessage()?.message}</div>
|
||||||
|
</div>
|
||||||
|
<div class="border border-error px-2 py-1 rounded-md hv-base hover:bg-white" onClick={() => { window.location.href = $currentErrorMessage()?.url }} >
|
||||||
|
登录
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const ErrorState = () => (
|
||||||
|
<div class="max-w-base h-full flex items-end flex-col justify-between gap-8 sm:(flex-row items-center) py-4 text-error text-sm" style="padding: 1rem;">
|
||||||
|
<div class="flex-1 w-full">
|
||||||
|
<div class="fi gap-0.5 mb-1">
|
||||||
|
<span i-carbon-warning />
|
||||||
|
<span class="font-semibold">{$currentErrorMessage()?.code}</span>
|
||||||
|
</div>
|
||||||
|
<div>{$currentErrorMessage()?.message}</div>
|
||||||
|
</div>
|
||||||
|
<div class="border border-error px-2 py-1 rounded-md hv-base hover:bg-white" onClick={() => { currentErrorMessage.set(null) }} >
|
||||||
|
Dismiss
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const clearPrompt = () => {
|
||||||
|
setInputPrompt('')
|
||||||
|
isSendBoxFocus.set(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAbortFetch = () => {
|
||||||
|
$globalAbortController()?.abort()
|
||||||
|
clearPrompt()
|
||||||
|
}
|
||||||
|
|
||||||
|
const LoadingState = () => (
|
||||||
|
<div class="max-w-base h-full fi flex-row gap-2">
|
||||||
|
<div class="flex-1 op-50">Thinking...</div>
|
||||||
|
<div
|
||||||
|
class="border border-base-100 px-2 py-1 rounded-md text-sm op-40 hv-base hover:bg-white"
|
||||||
|
onClick={() => { handleAbortFetch() }}
|
||||||
|
>
|
||||||
|
Abort
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSend = () => {
|
||||||
|
if (!inputRef.value)
|
||||||
|
return
|
||||||
|
if (!currentConversation())
|
||||||
|
addConversation()
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
globalAbortController.set(controller)
|
||||||
|
handlePrompt(currentConversation(), inputRef.value, controller.signal)
|
||||||
|
clearPrompt()
|
||||||
|
scrollController().scrollToBottom()
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateRootClass = () => {
|
||||||
|
if (stateType() === 'normal')
|
||||||
|
return 'hv-base'
|
||||||
|
else if (stateType() === 'error')
|
||||||
|
return 'bg-red/8'
|
||||||
|
else if (stateType() === 'loading')
|
||||||
|
return 'loading-anim bg-base-100'
|
||||||
|
else if (stateType() === 'editing')
|
||||||
|
return 'bg-base-100'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateHeightClass = () => {
|
||||||
|
if (stateType() === 'normal')
|
||||||
|
return 'px-6 h-14'
|
||||||
|
else if (stateType() === 'error')
|
||||||
|
return 'px-6'
|
||||||
|
else if (stateType() === 'loading')
|
||||||
|
return 'px-6 h-14'
|
||||||
|
else if (stateType() === 'editing')
|
||||||
|
return 'h-54'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={`relative shrink-0 border-t border-base pb-[env(safe-area-inset-bottom)] transition transition-colors duration-300 ${stateRootClass()} ${footerClass()}`}>
|
||||||
|
<div class={`relative transition transition-height duration-240 ${stateHeightClass()}`}>
|
||||||
|
<Switch fallback={<EmptyState />}>
|
||||||
|
<Match when={stateType() === 'login'}>
|
||||||
|
<LoginState />
|
||||||
|
</Match>
|
||||||
|
<Match when={stateType() === 'error'}>
|
||||||
|
<ErrorState />
|
||||||
|
</Match>
|
||||||
|
<Match when={stateType() === 'loading'}>
|
||||||
|
<LoadingState />
|
||||||
|
</Match>
|
||||||
|
<Match when={stateType() === 'editing'}>
|
||||||
|
<EditState />
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
11
src/components/Share.astro
Normal file
11
src/components/Share.astro
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
import Conversation from '@/components/share/Conversation'
|
||||||
|
---
|
||||||
|
|
||||||
|
<main class="relative h-full flex-1 flex flex-col overflow-hidden bg-base">
|
||||||
|
<main class="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<div class="flex-1 relative overflow-hidden">
|
||||||
|
<Conversation client:only />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</main>
|
54
src/components/StreamableText.tsx
Normal file
54
src/components/StreamableText.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { createEffect, createSignal, on } from 'solid-js'
|
||||||
|
import { convertReadableStreamToAccessor } from '@/logics/stream'
|
||||||
|
import { updateMessage } from '@/stores/messages'
|
||||||
|
import { deleteStreamById, getStreamByConversationId } from '@/stores/streams'
|
||||||
|
import Markdown from './Markdown'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
class?: string
|
||||||
|
text: string
|
||||||
|
showRawCode?: boolean
|
||||||
|
streamInfo?: () => {
|
||||||
|
conversationId: string
|
||||||
|
messageId: string
|
||||||
|
handleStreaming?: () => void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default (props: Props) => {
|
||||||
|
const [localText, setLocalText] = createSignal('')
|
||||||
|
|
||||||
|
createEffect(on(localText, () => {
|
||||||
|
if (props.streamInfo && props.streamInfo()?.handleStreaming)
|
||||||
|
props.streamInfo().handleStreaming!()
|
||||||
|
}, { defer: true }))
|
||||||
|
|
||||||
|
createEffect(async() => {
|
||||||
|
const text = props.text
|
||||||
|
if (props.text) {
|
||||||
|
setLocalText(text)
|
||||||
|
} else if (props.streamInfo) {
|
||||||
|
const streamInfo = props.streamInfo()
|
||||||
|
const streamInstance = getStreamByConversationId(streamInfo.conversationId)
|
||||||
|
if (streamInfo.messageId && streamInstance?.messageId === streamInfo.messageId) {
|
||||||
|
const finalText = await convertReadableStreamToAccessor(streamInstance.stream, setLocalText)
|
||||||
|
setLocalText(finalText)
|
||||||
|
updateMessage(streamInfo.conversationId, streamInfo.messageId, {
|
||||||
|
content: finalText,
|
||||||
|
stream: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
deleteStreamById(streamInfo.conversationId)
|
||||||
|
} else {
|
||||||
|
setLocalText('')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Markdown
|
||||||
|
class={`prose prose-neutral dark:prose-invert fg-base! max-w-3xl ${props.class ?? ''}`}
|
||||||
|
text={localText()}
|
||||||
|
showRawCode={props.showRawCode}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
11
src/components/client-only/BuildStores.tsx
Normal file
11
src/components/client-only/BuildStores.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { createStores, rebuildStores } from '@/stores/storage/db'
|
||||||
|
|
||||||
|
const buildStores = async() => {
|
||||||
|
await createStores()
|
||||||
|
await rebuildStores()
|
||||||
|
}
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
buildStores()
|
||||||
|
return null
|
||||||
|
}
|
84
src/components/conversations/ConversationEdit.tsx
Normal file
84
src/components/conversations/ConversationEdit.tsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import { Show, createSignal, onMount } from 'solid-js'
|
||||||
|
import { useStore } from '@nanostores/solid'
|
||||||
|
import BotSelect from '@/components/ui/BotSelect'
|
||||||
|
import { getBotMetaById } from '@/stores/provider'
|
||||||
|
import { emojiPickerCurrentPick, showEmojiPickerModal } from '@/stores/ui'
|
||||||
|
import { useI18n } from '@/hooks'
|
||||||
|
import type { Conversation } from '@/types/conversation'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
conversation: Conversation
|
||||||
|
handleChange: (payload: Partial<Conversation>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default (props: Props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const [providerBot, setProviderBot] = createSignal(props.conversation.bot || '')
|
||||||
|
const $emojiPickerCurrentPick = useStore(emojiPickerCurrentPick)
|
||||||
|
const botMeta = () => getBotMetaById(providerBot()) || null
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
emojiPickerCurrentPick.set(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleProviderBotChange = (e: string) => {
|
||||||
|
setProviderBot(e)
|
||||||
|
const payload: Partial<Conversation> = { bot: e }
|
||||||
|
if (botMeta()?.type === 'image_generation') {
|
||||||
|
payload.systemInfo = undefined
|
||||||
|
payload.mockMessages = undefined
|
||||||
|
}
|
||||||
|
props.handleChange(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenIconSelector = () => {
|
||||||
|
showEmojiPickerModal.set(true)
|
||||||
|
emojiPickerCurrentPick.listen((emoji) => {
|
||||||
|
props.handleChange({ icon: emoji })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenMockMessages = () => {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
class="fcc w-16 h-16 text-10 border border-base rounded-xl border-dashed hv-base"
|
||||||
|
onClick={handleOpenIconSelector}
|
||||||
|
>
|
||||||
|
{$emojiPickerCurrentPick() || props.conversation.icon}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="font-semibold mr-12 mb-3 px-1 truncate outline-0 bg-transparent placeholder:op-40"
|
||||||
|
placeholder={t('conversations.untitled')}
|
||||||
|
value={props.conversation.name}
|
||||||
|
onBlur={e => props.handleChange({ name: e.currentTarget.value })}
|
||||||
|
/>
|
||||||
|
<BotSelect value={props.conversation.bot} onChange={handleProviderBotChange} />
|
||||||
|
<Show when={botMeta()?.type !== 'image_generation'}>
|
||||||
|
<div class="py-1 border border-base rounded-lg text-sm">
|
||||||
|
<div class="px-4 py-2">
|
||||||
|
<h3 class="op-80 shrink-0">System Info</h3>
|
||||||
|
<textarea
|
||||||
|
value={props.conversation.systemInfo || ''}
|
||||||
|
rows="4"
|
||||||
|
class="input-base mt-2 w-full"
|
||||||
|
placeholder="You are a helpful assistant, answer as concisely as possible..."
|
||||||
|
onBlur={e => props.handleChange({ systemInfo: e.currentTarget.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* <div class="fi justify-between gap-10 pl-4 pr-2 h-10">
|
||||||
|
<h3 class="op-80 shrink-0">Mock Messages</h3>
|
||||||
|
<div class="flex-1 fi justify-end overflow-hidden px-2 py-1 cursor-pointer" onClick={handleOpenMockMessages}>
|
||||||
|
<p class="text-xs op-50 truncate">2 messages</p>
|
||||||
|
<div i-carbon-chevron-right class="shrink-0" />
|
||||||
|
</div>
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
37
src/components/conversations/ConversationEditModal.tsx
Normal file
37
src/components/conversations/ConversationEditModal.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { useStore } from '@nanostores/solid'
|
||||||
|
import { useI18n } from '@/hooks'
|
||||||
|
import { currentConversation, updateConversationById } from '@/stores/conversation'
|
||||||
|
import { showConversationEditModal } from '@/stores/ui'
|
||||||
|
import ConversationEdit from './ConversationEdit'
|
||||||
|
import type { Conversation } from '@/types/conversation'
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const $currentConversation = useStore(currentConversation)
|
||||||
|
let modifiedConversationPayload: Partial<Conversation> = {}
|
||||||
|
|
||||||
|
const handleButtonClick = () => {
|
||||||
|
if (Object.keys(modifiedConversationPayload).length)
|
||||||
|
updateConversationById($currentConversation()!.id, modifiedConversationPayload)
|
||||||
|
showConversationEditModal.set(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChange = (payload: Partial<Conversation>) => {
|
||||||
|
modifiedConversationPayload = {
|
||||||
|
...modifiedConversationPayload,
|
||||||
|
...payload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="p-6">
|
||||||
|
<main class="flex flex-col gap-3 mt-3">
|
||||||
|
<ConversationEdit
|
||||||
|
conversation={$currentConversation()!}
|
||||||
|
handleChange={handleChange}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
<div class="fcc px-2 py-2 bg-darker border border-base mt-4 hv-base hover:border-base-100" onClick={handleButtonClick}>{t('settings.save')}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
36
src/components/conversations/ConversationSidebar.tsx
Normal file
36
src/components/conversations/ConversationSidebar.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { For } from 'solid-js'
|
||||||
|
import { useStore } from '@nanostores/solid'
|
||||||
|
import { useI18n } from '@/hooks'
|
||||||
|
import { conversationMapSortList } from '@/stores/conversation'
|
||||||
|
import ConversationSidebarItem from './ConversationSidebarItem'
|
||||||
|
import ConversationSidebarAdd from './ConversationSidebarAdd'
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const $conversationMapSortList = useStore(conversationMapSortList)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="h-full flex flex-col bg-sidebar">
|
||||||
|
<header class="h-14 fi justify-between px-4 text-xs uppercase">
|
||||||
|
<p class="px-2">{t('conversations.title')}</p>
|
||||||
|
<div class="fi gap-1">
|
||||||
|
{/* <Button
|
||||||
|
icon="i-carbon-search"
|
||||||
|
onClick={() => {}}
|
||||||
|
size="sm"
|
||||||
|
/> */}
|
||||||
|
<ConversationSidebarAdd />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="flex-1 overflow-auto">
|
||||||
|
<div class="px-2">
|
||||||
|
<For each={$conversationMapSortList()}>
|
||||||
|
{instance => (
|
||||||
|
<ConversationSidebarItem instance={instance} />
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
20
src/components/conversations/ConversationSidebarAdd.tsx
Normal file
20
src/components/conversations/ConversationSidebarAdd.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { useI18n } from '@/hooks'
|
||||||
|
import { addConversation } from '@/stores/conversation'
|
||||||
|
import Button from '../ui/Button'
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const handleAdd = () => {
|
||||||
|
addConversation()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
icon="i-carbon-add"
|
||||||
|
onClick={handleAdd}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{t('conversations.add')}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
50
src/components/conversations/ConversationSidebarItem.tsx
Normal file
50
src/components/conversations/ConversationSidebarItem.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { useStore } from '@nanostores/solid'
|
||||||
|
import { currentConversationId, deleteConversationById } from '@/stores/conversation'
|
||||||
|
import { showConversationSidebar } from '@/stores/ui'
|
||||||
|
import { useI18n } from '@/hooks'
|
||||||
|
import type { Conversation } from '@/types/conversation'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
instance: Omit<Conversation, 'messages'> & {
|
||||||
|
current?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ({ instance }: Props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const $currentConversationId = useStore(currentConversationId)
|
||||||
|
const isTouchDevice = 'ontouchstart' in document.documentElement || navigator.maxTouchPoints > 0
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
currentConversationId.set(instance.id)
|
||||||
|
showConversationSidebar.set(false)
|
||||||
|
}
|
||||||
|
const handleDelete = (e: MouseEvent, conversationId: string) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
currentConversationId.set('')
|
||||||
|
deleteConversationById(conversationId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={[
|
||||||
|
'group fi h-10 my-0.5 px-2 gap-2 hv-base rounded-md',
|
||||||
|
instance.id === $currentConversationId() ? 'bg-base-200' : '',
|
||||||
|
].join(' ')}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<div class="fcc w-8 h-8 rounded-full text-xl shrink-0">
|
||||||
|
{instance.icon ? instance.icon : <div class="text-base i-carbon-chat" />}
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 truncate text-sm">{ instance.name || t('conversations.untitled') }</div>
|
||||||
|
<div class={isTouchDevice ? '' : 'hidden group-hover:block'}>
|
||||||
|
<div
|
||||||
|
class="inline-flex p-2 items-center gap-1 rounded-md hv-base"
|
||||||
|
onClick={e => handleDelete(e, instance.id)}
|
||||||
|
>
|
||||||
|
<div class="i-carbon-close" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
26
src/components/header/ConversationHeaderInfo.tsx
Normal file
26
src/components/header/ConversationHeaderInfo.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { Show } from 'solid-js'
|
||||||
|
import { useStore } from '@nanostores/solid'
|
||||||
|
import { conversationMap, currentConversationId } from '@/stores/conversation'
|
||||||
|
import { useI18n } from '@/hooks'
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const $conversationMap = useStore(conversationMap)
|
||||||
|
const $currentConversationId = useStore(currentConversationId)
|
||||||
|
const currentConversation = () => {
|
||||||
|
return $conversationMap()[$currentConversationId()]
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="fi gap-1 max-w-40vw px-2 overflow-hidden text-sm">
|
||||||
|
<Show when={currentConversation()}>
|
||||||
|
<Show when={currentConversation().icon}>
|
||||||
|
<div class="fcc -ml-2 w-8 h-8 rounded-full text-xl shrink-0 hidden md:flex">{currentConversation().icon}</div>
|
||||||
|
</Show>
|
||||||
|
<div class="truncate">
|
||||||
|
{currentConversation() ? (currentConversation().name || t('conversations.untitled')) : ''}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
38
src/components/header/ConversationHeaderShare.tsx
Normal file
38
src/components/header/ConversationHeaderShare.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { useStore } from '@nanostores/solid'
|
||||||
|
import { currentConversationId, conversationMap } from '@/stores/conversation'
|
||||||
|
import type { Conversation } from '@/types/conversation'
|
||||||
|
import { fetchData } from '../../http/api'
|
||||||
|
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
const $currentConversationId = useStore(currentConversationId)
|
||||||
|
const $conversationMap = useStore(conversationMap)
|
||||||
|
|
||||||
|
const currentConversation = () => {
|
||||||
|
return $conversationMap()[$currentConversationId()]
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleShareMessage = async (conversation: Conversation) => {
|
||||||
|
var conversation = currentConversation();
|
||||||
|
fetchData({id:conversation.id, title: conversation.name }, function(data) {
|
||||||
|
if(data.code==200){
|
||||||
|
// window.location.href = data.url;
|
||||||
|
window.open(data.url)
|
||||||
|
}else{
|
||||||
|
alert(data.message);
|
||||||
|
}
|
||||||
|
}, '/chatgptApi/createShare', 'POST');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{ $currentConversationId() && (
|
||||||
|
<div class="fcc p-2 rounded-md text-xl hv-foreground" onClick={() => { handleShareMessage(true) }} >
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-3 h-3">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M7.217 10.907a2.25 2.25 0 100 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186l9.566-5.314m-9.566 7.5l9.566 5.314m0 0a2.25 2.25 0 103.935 2.186 2.25 2.25 0 00-3.935-2.186zm0-12.814a2.25 2.25 0 103.933-2.185 2.25 2.25 0 00-3.933 2.185z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
34
src/components/header/ConversationMessageClearButton.tsx
Normal file
34
src/components/header/ConversationMessageClearButton.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { useStore } from '@nanostores/solid'
|
||||||
|
import { currentConversationId } from '@/stores/conversation'
|
||||||
|
import {
|
||||||
|
scrollController,
|
||||||
|
showConfirmModal,
|
||||||
|
} from '@/stores/ui'
|
||||||
|
import { clearMessagesByConversationId } from '@/stores/messages'
|
||||||
|
import { useI18n } from '@/hooks'
|
||||||
|
import ConfirmModal from '../ui/ConfirmModal'
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
const $currentConversationId = useStore(currentConversationId)
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const handleClearMessage = () => {
|
||||||
|
clearMessagesByConversationId($currentConversationId())
|
||||||
|
scrollController().scrollToBottom()
|
||||||
|
showConfirmModal.set(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{$currentConversationId() && (
|
||||||
|
<div
|
||||||
|
class="fcc p-2 rounded-md text-xl hv-foreground"
|
||||||
|
onClick={() => { showConfirmModal.set(true) }}
|
||||||
|
>
|
||||||
|
<div i-carbon-clean />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ConfirmModal title={t('conversations.confirm.title')} description={t('conversations.confirm.desc')} onConfirm={handleClearMessage} onCancel={() => { showConfirmModal.set(false) }} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
39
src/components/header/Header.tsx
Normal file
39
src/components/header/Header.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { onMount } from 'solid-js'
|
||||||
|
import { scrollController, showConversationSidebar, showSettingsSidebar } from '@/stores/ui'
|
||||||
|
import { useLargeScreen } from '@/hooks'
|
||||||
|
import ConversationHeaderInfo from './ConversationHeaderInfo'
|
||||||
|
import ConversationMessageClearButton from './ConversationMessageClearButton'
|
||||||
|
import ConversationHeaderShare from './ConversationHeaderShare'
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
onMount(() => {
|
||||||
|
useLargeScreen(() => {
|
||||||
|
// bug: when click the setting btn, toggle moible or PC mode, the sidebar will not close
|
||||||
|
showConversationSidebar.set(false)
|
||||||
|
showSettingsSidebar.set(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<header onDblClick={scrollController().scrollToTop} class="shrink-0 absolute top-0 left-0 right-0 fi justify-between border-b border-base h-14 px-4">
|
||||||
|
<div class="fi overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="fcc p-2 rounded-md text-xl hv-foreground md:hidden"
|
||||||
|
onClick={() => showConversationSidebar.set(true)}
|
||||||
|
>
|
||||||
|
<div i-carbon-menu />
|
||||||
|
</div>
|
||||||
|
<ConversationHeaderInfo />
|
||||||
|
</div>
|
||||||
|
<div class="fi gap-1 overflow-hidden">
|
||||||
|
<ConversationHeaderShare />
|
||||||
|
<ConversationMessageClearButton />
|
||||||
|
<div
|
||||||
|
class="fcc p-2 rounded-md text-xl hv-foreground lg:hidden"
|
||||||
|
onClick={() => showSettingsSidebar.set(true)}
|
||||||
|
>
|
||||||
|
<div i-carbon-settings />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
67
src/components/main/Continuous.tsx
Normal file
67
src/components/main/Continuous.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { For, Show, createEffect, createSignal, on } from 'solid-js'
|
||||||
|
import { useStore } from '@nanostores/solid'
|
||||||
|
import { createScrollPosition } from '@solid-primitives/scroll'
|
||||||
|
import { leading, throttle } from '@solid-primitives/scheduled'
|
||||||
|
import { isSendBoxFocus } from '@/stores/ui'
|
||||||
|
import MessageItem from './MessageItem'
|
||||||
|
import type { Accessor } from 'solid-js'
|
||||||
|
import type { MessageInstance } from '@/types/message'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
conversationId: string
|
||||||
|
messages: Accessor<MessageInstance[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default (props: Props) => {
|
||||||
|
let scrollRef: HTMLDivElement
|
||||||
|
const $isSendBoxFocus = useStore(isSendBoxFocus)
|
||||||
|
const [isScrollBottom, setIsScrollBottom] = createSignal(false)
|
||||||
|
const scroll = createScrollPosition(() => scrollRef)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
setIsScrollBottom(scroll.y + scrollRef.clientHeight >= scrollRef.scrollHeight - 100)
|
||||||
|
})
|
||||||
|
createEffect(on(() => props.conversationId, () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
instantScrollToBottomThrottle(scrollRef)
|
||||||
|
}, 0)
|
||||||
|
}))
|
||||||
|
|
||||||
|
const instantScrollToBottomThrottle = leading(throttle, (element: HTMLDivElement) => {
|
||||||
|
isScrollBottom() && element.scrollTo({ top: element.scrollHeight })
|
||||||
|
}, 250)
|
||||||
|
|
||||||
|
const handleStreamableTextUpdate = () => {
|
||||||
|
instantScrollToBottomThrottle(scrollRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div class="scroll-list relative flex flex-col h-full overflow-y-scroll" ref={scrollRef!}>
|
||||||
|
<For each={props.messages()}>
|
||||||
|
{(message, index) => (
|
||||||
|
<div class="border-b border-base">
|
||||||
|
<MessageItem
|
||||||
|
conversationId={props.conversationId}
|
||||||
|
message={message}
|
||||||
|
handleStreaming={handleStreamableTextUpdate}
|
||||||
|
index={index()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
<Show when={!isScrollBottom() && !$isSendBoxFocus()}>
|
||||||
|
<div
|
||||||
|
class="absolute bottom-0 left-0 right-0 border-t border-base bg-blur hv-base"
|
||||||
|
onClick={() => scrollRef!.scrollTo({ top: scrollRef.scrollHeight, behavior: 'smooth' })}
|
||||||
|
>
|
||||||
|
<div class="fcc h-8 max-w-base text-xs op-50 gap-1">
|
||||||
|
<div>Scroll to bottom</div>
|
||||||
|
<div i-carbon-arrow-down />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
73
src/components/main/Conversation.tsx
Normal file
73
src/components/main/Conversation.tsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { Match, Switch, createEffect,onMount } from 'solid-js'
|
||||||
|
import { useStore } from '@nanostores/solid'
|
||||||
|
import { conversationMap, currentConversationId } from '@/stores/conversation'
|
||||||
|
import { conversationMessagesMap } from '@/stores/messages'
|
||||||
|
import { loadingStateMap, streamsMap } from '@/stores/streams'
|
||||||
|
import { getBotMetaById } from '@/stores/provider'
|
||||||
|
import { useI18n } from '@/hooks'
|
||||||
|
import ConversationEmpty from './ConversationEmpty'
|
||||||
|
import Welcome from './Welcome'
|
||||||
|
import Continuous from './Continuous'
|
||||||
|
import Single from './Single'
|
||||||
|
import Image from './Image'
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const $conversationMap = useStore(conversationMap)
|
||||||
|
const $conversationMessagesMap = useStore(conversationMessagesMap)
|
||||||
|
const $currentConversationId = useStore(currentConversationId)
|
||||||
|
const $streamsMap = useStore(streamsMap)
|
||||||
|
const $loadingStateMap = useStore(loadingStateMap)
|
||||||
|
|
||||||
|
const currentConversation = () => {
|
||||||
|
return $conversationMap()[$currentConversationId()]
|
||||||
|
}
|
||||||
|
const currentBot = () => {
|
||||||
|
return getBotMetaById(currentConversation()?.bot)
|
||||||
|
}
|
||||||
|
const currentConversationMessages = () => {
|
||||||
|
return $conversationMessagesMap()[$currentConversationId()] || []
|
||||||
|
}
|
||||||
|
// const isStreaming = () => !!$streamsMap()[$currentConversationId()]
|
||||||
|
// const isLoading = () => !!$loadingStateMap()[$currentConversationId()]
|
||||||
|
createEffect(() => {
|
||||||
|
const conversation = currentConversation()
|
||||||
|
document.title = conversation ? `${(conversation.name || t('conversations.untitled'))} - Ansnid` : 'Ansnid'
|
||||||
|
const link = document.querySelector("link[rel~='icon']") as HTMLLinkElement
|
||||||
|
if (link) {
|
||||||
|
const conversationIcon = conversation?.icon ? `data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>${conversation.icon}</text></svg>` : null
|
||||||
|
link.setAttribute('href', conversationIcon || '/logo.svg')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Switch
|
||||||
|
fallback={(
|
||||||
|
<Welcome />
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Match when={$currentConversationId() && !currentConversationMessages().length}>
|
||||||
|
<ConversationEmpty conversation={currentConversation()} />
|
||||||
|
</Match>
|
||||||
|
<Match when={currentBot()?.type === 'chat_continuous'}>
|
||||||
|
<Continuous
|
||||||
|
conversationId={$currentConversationId()}
|
||||||
|
messages={currentConversationMessages}
|
||||||
|
/>
|
||||||
|
</Match>
|
||||||
|
<Match when={currentBot()?.type === 'chat_single'}>
|
||||||
|
<Single
|
||||||
|
conversationId={$currentConversationId()}
|
||||||
|
messages={currentConversationMessages}
|
||||||
|
/>
|
||||||
|
</Match>
|
||||||
|
<Match when={currentBot()?.type === 'image_generation'}>
|
||||||
|
<Image
|
||||||
|
// conversationId={$currentConversationId()}
|
||||||
|
messages={currentConversationMessages}
|
||||||
|
// fetching={isLoading() || !isStreaming()}
|
||||||
|
/>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
)
|
||||||
|
}
|
29
src/components/main/ConversationEmpty.tsx
Normal file
29
src/components/main/ConversationEmpty.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { showConversationEditModal } from '@/stores/ui'
|
||||||
|
import { getBotMetaById } from '@/stores/provider'
|
||||||
|
import Button from '../ui/Button'
|
||||||
|
import type { Conversation } from '@/types/conversation'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
conversation: Conversation
|
||||||
|
}
|
||||||
|
|
||||||
|
export default (props: Props) => {
|
||||||
|
const botMeta = () => getBotMetaById(props.conversation.bot) || null
|
||||||
|
return (
|
||||||
|
<div class="fi flex-col h-full px-6 py-8 overflow-auto">
|
||||||
|
<Button
|
||||||
|
icon="i-carbon-settings-adjust text-sm"
|
||||||
|
onClick={() => showConversationEditModal.set(true)}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<div class="inline-flex items-center gap-1">
|
||||||
|
{botMeta().provider.name} / {botMeta().label}
|
||||||
|
{props.conversation.systemInfo && (
|
||||||
|
<div class="text-xs px-1 border border-base-100 rounded-md op-40">System Info</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
35
src/components/main/Image.tsx
Normal file
35
src/components/main/Image.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { Show } from 'solid-js'
|
||||||
|
import StreamableText from '../StreamableText'
|
||||||
|
import type { Accessor } from 'solid-js'
|
||||||
|
import type { MessageInstance } from '@/types/message'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
// conversationId: string
|
||||||
|
messages: Accessor<MessageInstance[]>
|
||||||
|
// fetching: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default (props: Props) => {
|
||||||
|
const messageInput = () => props.messages().length > 0 ? props.messages()[0] : null
|
||||||
|
const messageOutput = () => props.messages().length > 1 ? props.messages()[1] : null
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col h-full">
|
||||||
|
<div class="min-h-16 max-h-40 fi px-6 py-4 border-b border-base break-words overflow-y-scroll">
|
||||||
|
<StreamableText
|
||||||
|
class="w-full"
|
||||||
|
text={messageInput()?.content || ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 fcc overflow-y-auto px-6">
|
||||||
|
<Show when={messageOutput()?.content}>
|
||||||
|
<img
|
||||||
|
class="w-full max-w-[400px] aspect-1"
|
||||||
|
src={messageOutput()?.content}
|
||||||
|
alt={messageInput()?.content || ''}
|
||||||
|
onError={e => e.currentTarget.classList.add('hidden')}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
163
src/components/main/MessageItem.tsx
Normal file
163
src/components/main/MessageItem.tsx
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
import { For, Show } from 'solid-js/web'
|
||||||
|
import { createSignal } from 'solid-js'
|
||||||
|
import { useStore } from '@nanostores/solid'
|
||||||
|
import { useClipboardCopy } from '@/hooks'
|
||||||
|
import { deleteMessageByConversationId, spliceMessageByConversationId, spliceUpdateMessageByConversationId } from '@/stores/messages'
|
||||||
|
import { conversationMap } from '@/stores/conversation'
|
||||||
|
import { handlePrompt } from '@/logics/conversation'
|
||||||
|
import { scrollController } from '@/stores/ui'
|
||||||
|
import { globalAbortController } from '@/stores/settings'
|
||||||
|
import StreamableText from '../StreamableText'
|
||||||
|
import { DropDownMenu, Tooltip } from '../ui/base'
|
||||||
|
import Button from '../ui/Button'
|
||||||
|
import type { MenuItem } from '../ui/base'
|
||||||
|
import type { MessageInstance } from '@/types/message'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
conversationId: string
|
||||||
|
message: MessageInstance
|
||||||
|
index: number
|
||||||
|
handleStreaming?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default (props: Props) => {
|
||||||
|
let inputRef: HTMLTextAreaElement
|
||||||
|
const $conversationMap = useStore(conversationMap)
|
||||||
|
|
||||||
|
const [showRawCode, setShowRawCode] = createSignal(false)
|
||||||
|
const [copied, setCopied] = createSignal(false)
|
||||||
|
const [isEditing, setIsEditing] = createSignal(false)
|
||||||
|
const [inputPrompt, setInputPrompt] = createSignal(props.message.content)
|
||||||
|
|
||||||
|
const currentConversation = () => {
|
||||||
|
return $conversationMap()[props.conversationId]
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopyMessageItem = () => {
|
||||||
|
const [Iscopied, copy] = useClipboardCopy(props.message.content)
|
||||||
|
copy()
|
||||||
|
setCopied(Iscopied())
|
||||||
|
setTimeout(() => setCopied(false), 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteMessageItem = () => {
|
||||||
|
deleteMessageByConversationId(props.conversationId, props.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRetryMessageItem = () => {
|
||||||
|
const controller = new AbortController()
|
||||||
|
globalAbortController.set(controller)
|
||||||
|
spliceMessageByConversationId(props.conversationId, props.message)
|
||||||
|
handlePrompt(currentConversation(), '', controller.signal)
|
||||||
|
// TODO: scrollController seems not working
|
||||||
|
scrollController().scrollToBottom()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditMessageItem = () => {
|
||||||
|
setIsEditing(true)
|
||||||
|
inputRef.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSend = () => {
|
||||||
|
if (!inputRef.value)
|
||||||
|
return
|
||||||
|
const controller = new AbortController()
|
||||||
|
const currentMessage: MessageInstance = {
|
||||||
|
...props.message,
|
||||||
|
content: inputPrompt(),
|
||||||
|
}
|
||||||
|
|
||||||
|
globalAbortController.set(controller)
|
||||||
|
spliceUpdateMessageByConversationId(props.conversationId, currentMessage)
|
||||||
|
setIsEditing(false)
|
||||||
|
handlePrompt(currentConversation(), '', controller.signal)
|
||||||
|
scrollController().scrollToBottom()
|
||||||
|
}
|
||||||
|
|
||||||
|
const [menuList, setMenuList] = createSignal<MenuItem[]>([
|
||||||
|
{ id: 'copy', label: 'Copy message', icon: 'i-carbon-copy', role: 'all', action: handleCopyMessageItem },
|
||||||
|
{ id: 'retry', label: 'Retry send', icon: 'i-carbon:restart', role: 'all', action: handleRetryMessageItem },
|
||||||
|
// TODO: Share message
|
||||||
|
// { id: 'share', label: 'Share message', icon: 'i-carbon:share' },
|
||||||
|
{ id: 'edit', label: 'Edit message', icon: 'i-carbon:edit', role: 'user', action: handleEditMessageItem },
|
||||||
|
{ id: 'delete', label: 'Delete message', icon: 'i-carbon-trash-can', role: 'all', action: handleDeleteMessageItem },
|
||||||
|
{ id: 'raw', label: 'Show raw code', icon: 'i-carbon-code', role: 'system', action: () => setShowRawCode(!showRawCode()) },
|
||||||
|
])
|
||||||
|
|
||||||
|
if (props.message.role === 'user')
|
||||||
|
setMenuList(menuList().filter(item => ['all', 'user'].includes(item.role!)))
|
||||||
|
else
|
||||||
|
setMenuList(menuList().filter(item => ['all', 'system'].includes(item.role!)))
|
||||||
|
|
||||||
|
const roleClass = {
|
||||||
|
system: 'bg-gradient-to-b from-gray-300 via-gray-200 to-gray-300',
|
||||||
|
user: 'bg-gradient-to-b from-gray-300 via-gray-200 to-gray-300',
|
||||||
|
assistant: 'bg-gradient-to-b from-[#fccb90] to-[#d57eeb]',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="p-6 break-words group relative"
|
||||||
|
classList={{
|
||||||
|
'bg-base-100': props.message.role === 'user',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="max-w-base flex gap-4 overflow-hidden">
|
||||||
|
<div class={`shrink-0 w-7 h-7 rounded-md op-80 ${roleClass[props.message.role]}`} />
|
||||||
|
<div id="menuList-wrapper" class={`sm:hidden block absolute bottom-2 right-4 z-10 op-70 cursor-pointer ${isEditing() && '!hidden'}`}>
|
||||||
|
<DropDownMenu menuList={menuList()}>
|
||||||
|
<div class="text-xl i-carbon:overflow-menu-horizontal" />
|
||||||
|
</DropDownMenu>
|
||||||
|
</div>
|
||||||
|
<div class={`hidden sm:block absolute right-6 -top-4 ${!props.index && 'top-0'} ${isEditing() && '!hidden'}`}>
|
||||||
|
<div class="op-0 group-hover:op-80 fcc space-x-2 !bg-base px-2 py-1 rounded-md border border-base transition-opacity">
|
||||||
|
<For each={menuList()}>
|
||||||
|
{item => (
|
||||||
|
<Tooltip tip={item.label} id={item.id} handleChildClick={item.action}>
|
||||||
|
{
|
||||||
|
item.id === 'copy'
|
||||||
|
? <div class={`menu-icon ${copied() ? 'i-carbon-checkmark !text-emerald-400' : 'i-carbon-copy'}`} />
|
||||||
|
: <div class={`${item.icon} menu-icon`} />
|
||||||
|
}
|
||||||
|
</Tooltip>)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<Show when={isEditing()} >
|
||||||
|
<textarea
|
||||||
|
ref={inputRef!}
|
||||||
|
value={inputPrompt()}
|
||||||
|
autocomplete="off"
|
||||||
|
onInput={() => { setInputPrompt(inputRef.value) }}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
e.key === 'Enter' && !e.isComposing && !e.shiftKey && handleSend()
|
||||||
|
}}
|
||||||
|
class="op-70 bg-darker py-4 px-[calc(max(1.5rem,(100%-48rem)/2))] w-full inset-0 scroll-pa-4 input-base rounded-md"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex justify-end space-x-2 mt-1">
|
||||||
|
<Button size="sm" onClick={() => setIsEditing(false)}>Cancel</Button>
|
||||||
|
<Button size="sm" onClick={() => handleSend()}>Submit</Button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={!isEditing()}>
|
||||||
|
<StreamableText
|
||||||
|
text={props.message.content}
|
||||||
|
streamInfo={props.message.stream
|
||||||
|
? () => ({
|
||||||
|
conversationId: props.conversationId,
|
||||||
|
messageId: props.message.id || '',
|
||||||
|
handleStreaming: props.handleStreaming,
|
||||||
|
})
|
||||||
|
: undefined}
|
||||||
|
showRawCode={showRawCode()}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
45
src/components/main/Single.tsx
Normal file
45
src/components/main/Single.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { leading, throttle } from '@solid-primitives/scheduled'
|
||||||
|
import StreamableText from '../StreamableText'
|
||||||
|
import type { Accessor } from 'solid-js'
|
||||||
|
import type { MessageInstance } from '@/types/message'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
conversationId: string
|
||||||
|
messages: Accessor<MessageInstance[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ({ conversationId, messages }: Props) => {
|
||||||
|
let scrollRef: HTMLDivElement
|
||||||
|
const messageInput = () => messages().length > 0 ? messages()[0] : null
|
||||||
|
const messageOutput = () => messages().length > 1 ? messages()[1] : null
|
||||||
|
|
||||||
|
const instantScrollToBottomThrottle = leading(throttle, (element: HTMLDivElement) => element.scrollTo({ top: element.scrollHeight }), 250)
|
||||||
|
|
||||||
|
const handleStreamableTextUpdate = () => {
|
||||||
|
instantScrollToBottomThrottle(scrollRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col h-full">
|
||||||
|
<div class="flex-[1] border-b border-base p-6 break-words overflow-y-scroll">
|
||||||
|
<StreamableText
|
||||||
|
class="mx-auto"
|
||||||
|
text={messageInput()?.content || ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="scroll-list flex-[2] p-6 break-words overflow-y-scroll" ref={scrollRef!}>
|
||||||
|
<StreamableText
|
||||||
|
class="mx-auto"
|
||||||
|
text={messageOutput()?.content || ''}
|
||||||
|
streamInfo={messageOutput()?.stream
|
||||||
|
? () => ({
|
||||||
|
conversationId,
|
||||||
|
messageId: messageOutput()?.id || '',
|
||||||
|
handleStreaming: handleStreamableTextUpdate,
|
||||||
|
})
|
||||||
|
: undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
39
src/components/main/Welcome.tsx
Normal file
39
src/components/main/Welcome.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { For, Show } from 'solid-js'
|
||||||
|
import { useStore } from '@nanostores/solid'
|
||||||
|
import { useI18n } from '@/hooks'
|
||||||
|
import { addConversation, conversationMapSortList, currentConversationId } from '@/stores/conversation'
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const $conversationMapSortList = useStore(conversationMapSortList)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="fcc h-full">
|
||||||
|
<div class="flex flex-col gap-4 w-full max-w-md mx-12 sm:mx-18 overflow-hidden">
|
||||||
|
<div class="px-6 py-4 bg-base-100 border border-base rounded-lg">
|
||||||
|
<h2 class="text-xs op-30 uppercase my-2">{t('conversations.recent')}</h2>
|
||||||
|
<div class="flex flex-col items-start">
|
||||||
|
<For each={$conversationMapSortList().slice(0, 3)}>
|
||||||
|
{instance => (
|
||||||
|
<div class="fi gap-2 h-8 max-w-full hv-foreground" onClick={() => currentConversationId.set(instance.id)}>
|
||||||
|
{instance.icon ? instance.icon : <div class="text-sm i-carbon-chat" />}
|
||||||
|
<div class="flex-1 text-sm truncate">{instance.name || t('conversations.untitled')}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<Show when={!$conversationMapSortList().length}>
|
||||||
|
<div class="fi gap-2 h-8 text-sm op-20">{t('conversations.noRecent')}</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="fcc gap-2 p-6 bg-base-100 hv-base border border-base rounded-lg"
|
||||||
|
onClick={() => addConversation()}
|
||||||
|
>
|
||||||
|
<div class="i-carbon-add" />
|
||||||
|
<div class="flex-1 text-sm truncate">{t('conversations.add')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
59
src/components/settings/AppGeneralSettings.tsx
Normal file
59
src/components/settings/AppGeneralSettings.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { For } from 'solid-js'
|
||||||
|
import { localesOptions } from '@/locale'
|
||||||
|
import { useI18n } from '@/hooks'
|
||||||
|
import SettingsUIComponent from './SettingsUIComponent'
|
||||||
|
import type { Accessor } from 'solid-js'
|
||||||
|
import type { GeneralSettings } from '@/types/app'
|
||||||
|
import type { SettingsUI } from '@/types/provider'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
settingsValue: Accessor<GeneralSettings>
|
||||||
|
updateSettings: (v: Partial<GeneralSettings>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default (props: Props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const settingsUIList = () => ([
|
||||||
|
// {
|
||||||
|
// key: 'requestWithBackend',
|
||||||
|
// name: t('settings.general.requestWithBackend'),
|
||||||
|
// type: 'toggle',
|
||||||
|
// default: false,
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
key: 'locale',
|
||||||
|
name: t('settings.general.locale'),
|
||||||
|
type: 'select',
|
||||||
|
default: 'zhCN',
|
||||||
|
options: localesOptions,
|
||||||
|
},
|
||||||
|
] as SettingsUI[])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="px-4 py-3 transition-colors border-b border-base">
|
||||||
|
<h3 class="fi gap-2">
|
||||||
|
<div class="flex-1 fi gap-1.5 overflow-hidden">
|
||||||
|
<div class="i-carbon-settings" />
|
||||||
|
<div class="flex-1 text-sm truncate">{t('settings.general.title')}</div>
|
||||||
|
</div>
|
||||||
|
</h3>
|
||||||
|
<div class="mt-2 flex flex-col">
|
||||||
|
<For each={settingsUIList()}>
|
||||||
|
{(item) => {
|
||||||
|
return (
|
||||||
|
<SettingsUIComponent
|
||||||
|
settings={item}
|
||||||
|
editing={() => true}
|
||||||
|
value={() => props.settingsValue()[item.key as keyof GeneralSettings] || item.default || ''}
|
||||||
|
setValue={(v) => {
|
||||||
|
props.updateSettings({ [item.key]: v })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
83
src/components/settings/ProviderGlobalSettings.tsx
Normal file
83
src/components/settings/ProviderGlobalSettings.tsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { For, createSignal } from 'solid-js'
|
||||||
|
import SettingsUIComponent from './SettingsUIComponent'
|
||||||
|
import type { Accessor } from 'solid-js'
|
||||||
|
import type { SettingsPayload, SettingsUI } from '@/types/provider'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
config: {
|
||||||
|
id: string
|
||||||
|
icon?: string
|
||||||
|
name: string
|
||||||
|
settingsUI?: SettingsUI[]
|
||||||
|
}
|
||||||
|
settingsValue: Accessor<SettingsPayload>
|
||||||
|
setSettings: (v: SettingsPayload) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ({ config, settingsValue, setSettings }: Props) => {
|
||||||
|
const [editing, setEditing] = createSignal(false)
|
||||||
|
const [editFormData, setEditFormData] = createSignal<SettingsPayload>({})
|
||||||
|
const formData = () => ({
|
||||||
|
...settingsValue(),
|
||||||
|
...editFormData(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleDismiss = () => {
|
||||||
|
setEditFormData({})
|
||||||
|
setEditing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
setSettings(formData())
|
||||||
|
setEditing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.settingsUI) return null
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="px-4 py-3 border transition-colors"
|
||||||
|
classList={{
|
||||||
|
'border border-amber/50 bg-amber/2': editing(),
|
||||||
|
'border border-b-base border-l-transparent border-r-transparent border-t-transparent': !editing(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 class="fi gap-2">
|
||||||
|
<div class="flex-1 fi gap-1.5 overflow-hidden">
|
||||||
|
{config.icon && <div class={config.icon} />}
|
||||||
|
<div class="flex-1 text-sm truncate">{config.name}</div>
|
||||||
|
</div>
|
||||||
|
{!editing() && (
|
||||||
|
<div onClick={() => setEditing(true)} class="p-1 inline-flex items-center rounded-md hv-base hv-foreground">
|
||||||
|
<div class="i-carbon-edit" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{editing() && (
|
||||||
|
<>
|
||||||
|
<div onClick={handleDismiss} class="p-1 inline-flex items-center rounded-md hv-base hv-foreground">
|
||||||
|
<div class="i-carbon-close" />
|
||||||
|
</div>
|
||||||
|
<div onClick={handleClick} class="p-1 inline-flex items-center rounded-md hv-base hv-foreground">
|
||||||
|
<div class="i-carbon-checkmark" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<div class="mt-2 flex flex-col">
|
||||||
|
<For each={config.settingsUI}>
|
||||||
|
{(item) => {
|
||||||
|
return (
|
||||||
|
<SettingsUIComponent
|
||||||
|
settings={item}
|
||||||
|
editing={editing}
|
||||||
|
value={() => formData()[item.key]}
|
||||||
|
setValue={(v) => {
|
||||||
|
setEditFormData({ ...formData(), [item.key]: v })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
49
src/components/settings/SettingsSidebar.tsx
Normal file
49
src/components/settings/SettingsSidebar.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { For } from 'solid-js'
|
||||||
|
import { useStore } from '@nanostores/solid'
|
||||||
|
import { useI18n } from '@/hooks'
|
||||||
|
import { platformSettingsUIList } from '@/stores/provider'
|
||||||
|
import { providerSettingsMap, setSettingsByProviderId, updateGeneralSettings } from '@/stores/settings'
|
||||||
|
import ThemeToggle from '../ui/ThemeToggle'
|
||||||
|
import ProviderGlobalSettings from './ProviderGlobalSettings'
|
||||||
|
import AppGeneralSettings from './AppGeneralSettings'
|
||||||
|
import type { GeneralSettings } from '@/types/app'
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const $providerSettingsMap = useStore(providerSettingsMap)
|
||||||
|
// bug: someTimes providerSettingsMap() is {}
|
||||||
|
const generalSettings = () => {
|
||||||
|
return ($providerSettingsMap().general || {}) as unknown as GeneralSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="h-full flex flex-col bg-sidebar">
|
||||||
|
<header class="h-14 fi border-b border-base px-4 text-xs uppercase">
|
||||||
|
{t('settings.title')}
|
||||||
|
</header>
|
||||||
|
<main class="flex-1 overflow-auto">
|
||||||
|
<AppGeneralSettings
|
||||||
|
settingsValue={() => generalSettings()}
|
||||||
|
updateSettings={updateGeneralSettings}
|
||||||
|
/>
|
||||||
|
<For each={platformSettingsUIList}>
|
||||||
|
{item => (
|
||||||
|
<ProviderGlobalSettings
|
||||||
|
config={item}
|
||||||
|
settingsValue={() => $providerSettingsMap()[item.id]}
|
||||||
|
setSettings={v => setSettingsByProviderId(item.id, v)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</main>
|
||||||
|
<footer class="h-14 fi justify-between px-3">
|
||||||
|
<ThemeToggle />
|
||||||
|
<div text-xs op-40 px-2>
|
||||||
|
<a href="https://Ansnid.com" target="_blank" rel="noreferrer" class="hv-foreground">
|
||||||
|
Ansnid.Com
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
69
src/components/settings/SettingsUIComponent.tsx
Normal file
69
src/components/settings/SettingsUIComponent.tsx
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { Match, Switch } from 'solid-js'
|
||||||
|
import SettingsApiKey from '../ui/SettingsApiKey'
|
||||||
|
import SettingsInput from '../ui/SettingsInput'
|
||||||
|
import SettingsSlider from '../ui/SettingsSlider'
|
||||||
|
import SettingsSelect from '../ui/SettingsSelect'
|
||||||
|
import SettingsToggle from '../ui/SettingsToggle'
|
||||||
|
import type { Accessor } from 'solid-js'
|
||||||
|
import type { SettingsUI } from '@/types/provider'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
settings: SettingsUI
|
||||||
|
editing: Accessor<boolean>
|
||||||
|
value: Accessor<string | number | boolean>
|
||||||
|
setValue: (v: string | number | boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ({ settings, editing, value, setValue }: Props) => {
|
||||||
|
if (!settings.name || !settings.type) return null
|
||||||
|
return (
|
||||||
|
<div class="my-2">
|
||||||
|
<div class="text-xs op-50">{settings.name}</div>
|
||||||
|
{editing() && settings.description && <div class="mt-1 text-xs op-30">{settings.description}</div>}
|
||||||
|
<div class="mt-1 text-sm">
|
||||||
|
<Switch>
|
||||||
|
<Match when={settings.type === 'api-key'}>
|
||||||
|
<SettingsApiKey
|
||||||
|
settings={settings}
|
||||||
|
editing={editing}
|
||||||
|
value={value as Accessor<string>}
|
||||||
|
setValue={setValue}
|
||||||
|
/>
|
||||||
|
</Match>
|
||||||
|
<Match when={settings.type === 'input'}>
|
||||||
|
<SettingsInput
|
||||||
|
settings={settings}
|
||||||
|
editing={editing}
|
||||||
|
value={value as Accessor<string>}
|
||||||
|
setValue={setValue}
|
||||||
|
/>
|
||||||
|
</Match>
|
||||||
|
<Match when={settings.type === 'select'}>
|
||||||
|
<SettingsSelect
|
||||||
|
settings={settings}
|
||||||
|
editing={editing}
|
||||||
|
value={value as Accessor<string>}
|
||||||
|
setValue={setValue}
|
||||||
|
/>
|
||||||
|
</Match>
|
||||||
|
<Match when={settings.type === 'slider'}>
|
||||||
|
<SettingsSlider
|
||||||
|
settings={settings}
|
||||||
|
editing={editing}
|
||||||
|
value={value as Accessor<number>}
|
||||||
|
setValue={setValue}
|
||||||
|
/>
|
||||||
|
</Match>
|
||||||
|
<Match when={settings.type === 'toggle'}>
|
||||||
|
<SettingsToggle
|
||||||
|
settings={settings}
|
||||||
|
editing={editing}
|
||||||
|
value={value as Accessor<boolean>}
|
||||||
|
setValue={setValue}
|
||||||
|
/>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
54
src/components/share/Conversation.tsx
Normal file
54
src/components/share/Conversation.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
|
||||||
|
import { For } from 'solid-js'
|
||||||
|
import MessageItem from './../main/MessageItem'
|
||||||
|
import Banner from './banner'
|
||||||
|
import './style.css'
|
||||||
|
import { createEffect, createSignal, useEffect,onMount } from 'solid-js'
|
||||||
|
import { fetchData } from '../../http/api'
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
const [views, setViews] = createSignal(0)
|
||||||
|
const [url, setUrl] = createSignal('')
|
||||||
|
const [title, setTitle] = createSignal('Ansnid')
|
||||||
|
const [items, setItems] = createSignal([])
|
||||||
|
|
||||||
|
// localStorage.setItem('theme', 'light');
|
||||||
|
document.documentElement.classList.toggle('dark', false)
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
fetchData(null, function(data){
|
||||||
|
|
||||||
|
setViews(data.data.views);
|
||||||
|
setUrl(data.data.url);
|
||||||
|
setItems(data.data.items);
|
||||||
|
setTitle(data.data.title);
|
||||||
|
document.title = data.data.title ? `${(data.data.title || t('conversations.untitled'))} - Ansnid` : 'Ansnid'
|
||||||
|
|
||||||
|
}, "/chatgptApi/conversations?url="+encodeURIComponent(window.location.href))
|
||||||
|
|
||||||
|
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col h-full">
|
||||||
|
<div style="text-align: center; padding: 0.75rem;">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
<div class="scroll-list relative flex flex-col h-full overflow-y-scroll">
|
||||||
|
<For each={items()}>
|
||||||
|
{(message, index) => (
|
||||||
|
<div class="border-b border-lighter">
|
||||||
|
<MessageItem
|
||||||
|
message={message}
|
||||||
|
index={index()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
<Banner views={views} url={url} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
59
src/components/share/banner.tsx
Normal file
59
src/components/share/banner.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
export default function Banner({ views, url }: { views: number }) {
|
||||||
|
|
||||||
|
const copyToClipboard = (text) => {
|
||||||
|
var tempInput = document.createElement("input");
|
||||||
|
tempInput.value = text;
|
||||||
|
document.body.appendChild(tempInput);
|
||||||
|
tempInput.select();
|
||||||
|
document.execCommand("copy");
|
||||||
|
document.body.removeChild(tempInput);
|
||||||
|
|
||||||
|
alert('已复制:'+text);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="z-10 fixed bottom-10 inset-x-0 mx-auto max-w-fit rounded-lg px-3 py-2 bg-white border border-gray-100 shadow-md flex justify-between space-x-2 items-center"
|
||||||
|
initial={{ opacity: 0, y: 100 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 100 }}
|
||||||
|
>
|
||||||
|
<div className="w-40 flex flex-col items-center justify-center">
|
||||||
|
<a
|
||||||
|
href="https://ansnid.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex space-x-2 items-center justify-center font-medium text-gray-600 px-4 py-1.5 rounded-md hover:bg-gray-100 active:bg-gray-200 transition-all"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
alt="Ansnid.Com logo"
|
||||||
|
src="/pwa-192.png"
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
className="rounded-sm"
|
||||||
|
/>
|
||||||
|
<p>Ansnid.Com</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="border-l border-gray-200 h-12 w-1" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
copyToClipboard(url())
|
||||||
|
}
|
||||||
|
className="p-2 flex flex-col space-y-1 items-center rounded-md w-12 hover:bg-gray-100 active:bg-gray-200 transition-all"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4 text-gray-600"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||||
|
<p className="text-center text-gray-600 text-sm">Copy</p>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="cursor-default p-2 flex flex-col space-y-1 items-center rounded-md w-12 hover:bg-gray-100 active:bg-gray-200 transition-all">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4 text-gray-600"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"></path><circle cx="12" cy="12" r="3"></circle></svg>
|
||||||
|
<p className="text-center text-gray-600 text-sm">
|
||||||
|
{views}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
17
src/components/share/style.css
Normal file
17
src/components/share/style.css
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
|
||||||
|
.ansnidshare .ansnid_retry{
|
||||||
|
display: none !important;
|
||||||
|
/* margin: 0px !important;*/
|
||||||
|
}
|
||||||
|
.ansnidshare .ansnid_edit{
|
||||||
|
display: none !important;
|
||||||
|
/* margin: 0px !important;*/
|
||||||
|
}
|
||||||
|
.ansnidshare .ansnid_delete{
|
||||||
|
display: none !important;
|
||||||
|
/* margin: 0px !important;*/
|
||||||
|
}
|
||||||
|
|
||||||
|
.ansnidshare .ansnid_copy{
|
||||||
|
/* margin: 0px !important;*/
|
||||||
|
}
|
37
src/components/ui/BotSelect.tsx
Normal file
37
src/components/ui/BotSelect.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { onMount } from 'solid-js'
|
||||||
|
import { botMetaList } from '@/stores/provider'
|
||||||
|
import { Select } from '../ui/base'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default (props: Props) => {
|
||||||
|
onMount(() => {
|
||||||
|
if (!props.value && props.onChange)
|
||||||
|
props.onChange(botMetaList[0].value)
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={props.value}
|
||||||
|
onChange={props.onChange}
|
||||||
|
options={botMetaList}
|
||||||
|
selectedComponent={item => (
|
||||||
|
<div class="fi gap-2">
|
||||||
|
{item.provider.icon && <div class={item.provider.icon} />}
|
||||||
|
<div>{item.provider.name} / {item.label}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
itemComponent={(item, isSelected) => (
|
||||||
|
<div class="fi gap-2 w-full px-2 py-1 border-b border-b-base hv-base">
|
||||||
|
{item.provider.icon && <div class={item.provider.icon} />}
|
||||||
|
<div class="flex-1">{item.provider?.name} / {item.label}</div>
|
||||||
|
{isSelected && (
|
||||||
|
<div i-carbon-checkmark />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
51
src/components/ui/Button.tsx
Normal file
51
src/components/ui/Button.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { Show } from 'solid-js'
|
||||||
|
import type { JSXElement } from 'solid-js'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
icon?: string
|
||||||
|
text?: string
|
||||||
|
size?: 'sm' | 'md' | 'lg'
|
||||||
|
variant?: 'normal' | 'primary' | 'ghost'
|
||||||
|
prefix?: JSXElement
|
||||||
|
children?: JSXElement
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default (props: Props) => {
|
||||||
|
const content = props.text || props.children
|
||||||
|
const buttonSizeClass = () => ({
|
||||||
|
sm: content || props.prefix ? 'px-2 h-7.5 text-xs gap-1' : 'w-7.5 h-7.5 text-xs',
|
||||||
|
md: content || props.prefix ? 'px-3 h-10 text-sm gap-1.5' : 'w-10 h-10 text-sm',
|
||||||
|
lg: content || props.prefix ? 'px-3 h-10 text-sm gap-1.5' : 'w-10 h-10 text-sm',
|
||||||
|
}[props.size || 'md'])
|
||||||
|
const buttonVariantClass = () => ({
|
||||||
|
normal: 'bg-base-100 border border-base hover:(bg-base-200 border-base-100)',
|
||||||
|
primary: 'bg-teal-600 dark:bg-teal-700 text-white border border-transparent hover:(bg-teal-700 dark:bg-teal-800)',
|
||||||
|
ghost: 'bg-transparent border border-base hover:(bg-base-100 border-base-100)',
|
||||||
|
}[props.variant || 'normal'])
|
||||||
|
const iconSizeClass = () => ({
|
||||||
|
sm: 'text-base',
|
||||||
|
md: 'text-lg',
|
||||||
|
lg: 'text-lg',
|
||||||
|
}[props.size || 'md'])
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={[
|
||||||
|
'fcc rounded-md cursor-pointer transition-colors',
|
||||||
|
buttonVariantClass(),
|
||||||
|
buttonSizeClass(),
|
||||||
|
].join(' ')}
|
||||||
|
onClick={props.onClick}
|
||||||
|
>
|
||||||
|
<Show when={props.prefix}>
|
||||||
|
<div>{props.prefix}</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={props.icon}>
|
||||||
|
<div class={`${iconSizeClass()} ${props.icon}`} />
|
||||||
|
</Show>
|
||||||
|
<Show when={content}>
|
||||||
|
<div>{content}</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
29
src/components/ui/ConfirmModal.tsx
Normal file
29
src/components/ui/ConfirmModal.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import {
|
||||||
|
showConfirmModal,
|
||||||
|
} from '@/stores/ui'
|
||||||
|
import { useI18n } from '@/hooks'
|
||||||
|
import Modal from './Modal'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
onConfirm: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default (props: Props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
return (
|
||||||
|
<Modal bindValue={showConfirmModal} direction="bottom" closeBtnClass="hidden" >
|
||||||
|
<div class="max-h-[70vh] w-full">
|
||||||
|
<div class="grid w-full max-w-lg scale-100 gap-4 border-base sm:border bg-white dark:bg-zinc-900/90 dark:backdrop-blur-lg p-6 opacity-100 shadow-lg sm:rounded-lg md:w-full">
|
||||||
|
<div class="flex flex-col space-y-2 text-center sm:text-left"><h2 id="radix-:rl:" class="text-lg font-semibold">{props.title}</h2><p id="radix-:rm:" class="text-sm text-muted-foreground">{props.description}</p></div>
|
||||||
|
<div class="flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
|
||||||
|
<button class="button" onClick={() => props.onCancel()}>{t('conversations.confirm.cancel')}</button>
|
||||||
|
<button class="emerald-button" onClick={() => props.onConfirm()}>{t('conversations.confirm.btn')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
44
src/components/ui/EmojiPickerModal.tsx
Normal file
44
src/components/ui/EmojiPickerModal.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { Suspense, createSignal } from 'solid-js'
|
||||||
|
import { EmojiPicker } from 'solid-emoji-picker'
|
||||||
|
import { emojiPickerCurrentPick, showEmojiPickerModal } from '@/stores/ui'
|
||||||
|
import type { Emoji } from 'solid-emoji-picker'
|
||||||
|
import '@/assets/emoji-picker.css'
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
const [search, setSearch] = createSignal('')
|
||||||
|
|
||||||
|
const emojiFilter = (emoji: Emoji) => {
|
||||||
|
if (parseFloat(emoji.emoji_version) > 14)
|
||||||
|
return false
|
||||||
|
return emoji.name.includes(search())
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEmojiPick = (emoji: Emoji) => {
|
||||||
|
emojiPickerCurrentPick.set(emoji.emoji)
|
||||||
|
showEmojiPickerModal.set(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="fi mr-12">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="w-full px-2 py-1 border border-base input-base focus:border-base-100"
|
||||||
|
placeholder="Search an emoji."
|
||||||
|
value={search()}
|
||||||
|
onInput={(e) => {
|
||||||
|
setSearch(e.currentTarget.value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 -mx-1 h-[16rem] overflow-auto">
|
||||||
|
<Suspense fallback={<div class="mt-[8rem] mx-auto fcc text-base i-carbon:circle-solid text-slate-400 animate-ping" />}>
|
||||||
|
<EmojiPicker
|
||||||
|
filter={emojiFilter}
|
||||||
|
onEmojiClick={handleEmojiPick}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
58
src/components/ui/Modal.tsx
Normal file
58
src/components/ui/Modal.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import * as dialog from '@zag-js/dialog'
|
||||||
|
import { normalizeProps, useMachine } from '@zag-js/solid'
|
||||||
|
import { Show, createMemo, createUniqueId } from 'solid-js'
|
||||||
|
import { Transition } from 'solid-transition-group'
|
||||||
|
import { Portal } from 'solid-js/web'
|
||||||
|
import type { JSXElement } from 'solid-js'
|
||||||
|
import type { WritableAtom } from 'nanostores'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
bindValue: WritableAtom<boolean>
|
||||||
|
direction: 'top' | 'bottom' | 'left' | 'right'
|
||||||
|
children: JSXElement
|
||||||
|
closeBtnClass?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default (props: Props) => {
|
||||||
|
const [state, send] = useMachine(dialog.machine({
|
||||||
|
id: createUniqueId(),
|
||||||
|
// TODO: set it to true will cause the modal closes exceptionally
|
||||||
|
// https://github.com/chakra-ui/zag/issues/596
|
||||||
|
closeOnOutsideClick: false,
|
||||||
|
}))
|
||||||
|
const api = createMemo(() => dialog.connect(state, send, normalizeProps))
|
||||||
|
|
||||||
|
const containerBaseClass = {
|
||||||
|
top: 'absolute top-0 left-0 right-0 border-b rounded-b-xl sm:(relative w-[400px] max-h-[60vh] border rounded-lg)',
|
||||||
|
bottom: 'absolute bottom-0 left-0 right-0 border-t rounded-t-xl pb-[env(safe-area-inset-bottom)] sm:(relative w-[400px] max-h-[60vh] pb-0 border rounded-lg)',
|
||||||
|
left: 'absolute top-0 left-0 bottom-0 border-r pb-[env(safe-area-inset-bottom)]',
|
||||||
|
right: 'absolute top-0 right-0 bottom-0 border-l pb-[env(safe-area-inset-bottom)]',
|
||||||
|
}[props.direction]
|
||||||
|
|
||||||
|
props.bindValue.subscribe((show) => {
|
||||||
|
if (show)
|
||||||
|
api().open()
|
||||||
|
else
|
||||||
|
api().close()
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Transition name={`slide-${props.direction}`}>
|
||||||
|
<Show when={api().isOpen}>
|
||||||
|
<div class="fixed inset-0 z-20 fcc">
|
||||||
|
<Portal>
|
||||||
|
<div {...api().backdropProps} class="fixed inset-0 bg-base opacity-60 pointer-events-auto" onclick={() => api().close()} />
|
||||||
|
</Portal>
|
||||||
|
<div {...api().containerProps}>
|
||||||
|
<div {...api().contentProps} class={`bg-modal transition-transform ease-out max-w-screen max-h-screen overflow-auto border-base shadow-lg ring-0 outline-none ${containerBaseClass}`}>
|
||||||
|
<button {...api().closeTriggerProps} class={`absolute p-1 rounded-md top-4 right-4 hv-base hv-foreground ${props.closeBtnClass || ''}`}>
|
||||||
|
<div i-carbon-close class="text-xl" />
|
||||||
|
</button>
|
||||||
|
{ props.children }
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</Transition>
|
||||||
|
)
|
||||||
|
}
|
72
src/components/ui/SettingsApiKey.tsx
Normal file
72
src/components/ui/SettingsApiKey.tsx
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { createSignal } from 'solid-js'
|
||||||
|
import { Show } from 'solid-js/web'
|
||||||
|
import { useClipboardCopy } from '@/hooks'
|
||||||
|
import SettingsNotDefined from './SettingsNotDefined'
|
||||||
|
import type { Accessor } from 'solid-js'
|
||||||
|
import type { SettingsUI } from '@/types/provider'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
settings: SettingsUI
|
||||||
|
editing: Accessor<boolean>
|
||||||
|
value: Accessor<string>
|
||||||
|
setValue: (v: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ({ settings, editing, value, setValue }: Props) => {
|
||||||
|
if (!settings.name || !settings.type) return null
|
||||||
|
const [isOpen, setIsOpen] = createSignal(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{editing() && (
|
||||||
|
<div class="fcc relative border border-base focus-within:border-base-100 transition-colors-200">
|
||||||
|
<input
|
||||||
|
type={isOpen() ? 'text' : 'password'}
|
||||||
|
value={value() || ''}
|
||||||
|
class="w-full mt-1 bg-transparent pl-2 py-1 pr-8 input-base focus:border-base-100"
|
||||||
|
onChange={e => setValue(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<Show when={value()}>
|
||||||
|
<div class="absolute top-0 right-0 bottom-0 fcc p-1 w-8 box-border bg-transparent cursor-pointer" onClick={() => { setIsOpen(!isOpen()) }}>
|
||||||
|
<div class={`${isOpen() ? 'i-carbon-view' : 'i-carbon-view-off'} text-sm`} />
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!editing() && value() && (
|
||||||
|
<ApiKeyMaskText key={value} />
|
||||||
|
)}
|
||||||
|
{!editing() && !value() && (
|
||||||
|
<SettingsNotDefined />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// const Usage = () => {
|
||||||
|
// return (
|
||||||
|
// <div class="relative h-1 w-[60px] bg-darker rounded-full overflow-hidden">
|
||||||
|
// <div class="absolute top-0 bottom-0 left-0 w-[70%] bg-emerald-600 bg-op-60 rounded-full" />
|
||||||
|
// </div>
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
|
||||||
|
const ApiKeyMaskText = (props: {
|
||||||
|
key: Accessor<string>
|
||||||
|
}) => {
|
||||||
|
const [copied, copy] = useClipboardCopy(props.key())
|
||||||
|
|
||||||
|
if (!props.key)
|
||||||
|
return <div>unknown</div>
|
||||||
|
return (
|
||||||
|
<div class="fi">
|
||||||
|
<div>{props.key().slice(0, 3)}</div>
|
||||||
|
<div>****</div>
|
||||||
|
<div>{props.key().slice(-4)}</div>
|
||||||
|
<div
|
||||||
|
class={`${copied() ? 'i-carbon:checkmark text-emerald-400 text-xl' : 'i-carbon-copy'} text-sm cursor-pointer ml-1`}
|
||||||
|
onClick={() => copy()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
32
src/components/ui/SettingsInput.tsx
Normal file
32
src/components/ui/SettingsInput.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import SettingsNotDefined from './SettingsNotDefined'
|
||||||
|
import type { SettingsUI } from '@/types/provider'
|
||||||
|
import type { Accessor } from 'solid-js'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
settings: SettingsUI
|
||||||
|
editing: Accessor<boolean>
|
||||||
|
value: Accessor<string>
|
||||||
|
setValue: (v: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ({ settings, editing, value, setValue }: Props) => {
|
||||||
|
if (!settings.name || !settings.type) return null
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{editing() && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value()}
|
||||||
|
class="w-full mt-1 bg-transparent border border-base px-2 py-1 focus:border-base-100 transition-colors-200"
|
||||||
|
onChange={e => setValue(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!editing() && value() && (
|
||||||
|
<div class="truncate">{value()}</div>
|
||||||
|
)}
|
||||||
|
{!editing() && !value() && (
|
||||||
|
<SettingsNotDefined />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
5
src/components/ui/SettingsNotDefined.tsx
Normal file
5
src/components/ui/SettingsNotDefined.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export default () => {
|
||||||
|
return (
|
||||||
|
<div op-25>Not Defined</div>
|
||||||
|
)
|
||||||
|
}
|
30
src/components/ui/SettingsSelect.tsx
Normal file
30
src/components/ui/SettingsSelect.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { Select } from '../ui/base'
|
||||||
|
import SettingsNotDefined from './SettingsNotDefined'
|
||||||
|
import type { SettingsUI, SettingsUISelect } from '@/types/provider'
|
||||||
|
import type { Accessor } from 'solid-js'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
settings: SettingsUI
|
||||||
|
editing: Accessor<boolean>
|
||||||
|
value: Accessor<string>
|
||||||
|
setValue: (v: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ({ settings, editing, value, setValue }: Props) => {
|
||||||
|
if (!settings.name || !settings.type) return null
|
||||||
|
const selectSettings = settings as SettingsUISelect
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{editing() && (
|
||||||
|
<Select value={value()} onChange={setValue} options={selectSettings.options} />
|
||||||
|
)}
|
||||||
|
{!editing() && value() && (
|
||||||
|
<div>{value()}</div>
|
||||||
|
)}
|
||||||
|
{!editing() && !value() && (
|
||||||
|
<SettingsNotDefined />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
36
src/components/ui/SettingsSlider.tsx
Normal file
36
src/components/ui/SettingsSlider.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { Slider } from '../ui/base'
|
||||||
|
import SettingsNotDefined from './SettingsNotDefined'
|
||||||
|
import type { SettingsUI, SettingsUISlider } from '@/types/provider'
|
||||||
|
import type { Accessor } from 'solid-js'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
settings: SettingsUI
|
||||||
|
editing: Accessor<boolean>
|
||||||
|
value: Accessor<number>
|
||||||
|
setValue: (v: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ({ settings, editing, value, setValue }: Props) => {
|
||||||
|
if (!settings.name || !settings.type) return null
|
||||||
|
const sliderSettings = settings as SettingsUISlider
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{editing() && (
|
||||||
|
<Slider
|
||||||
|
setValue={setValue}
|
||||||
|
max={sliderSettings.max}
|
||||||
|
value={value}
|
||||||
|
min={sliderSettings.min}
|
||||||
|
step={sliderSettings.step}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!editing() && value() && (
|
||||||
|
<div>{value()}</div>
|
||||||
|
)}
|
||||||
|
{!editing() && !value() && (
|
||||||
|
<SettingsNotDefined />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
25
src/components/ui/SettingsToggle.tsx
Normal file
25
src/components/ui/SettingsToggle.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { Toggle } from '../ui/base'
|
||||||
|
import type { SettingsUI } from '@/types/provider'
|
||||||
|
import type { Accessor } from 'solid-js'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
settings: SettingsUI
|
||||||
|
editing: Accessor<boolean>
|
||||||
|
value: Accessor<boolean>
|
||||||
|
setValue: (v: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default (props: Props) => {
|
||||||
|
if (!props.settings.name || !props.settings.type) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{props.editing() && (
|
||||||
|
<Toggle value={props.value} setValue={props.setValue} />
|
||||||
|
)}
|
||||||
|
{!props.editing() && (
|
||||||
|
<div>{props.value() ? 'Yes' : 'No'}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
26
src/components/ui/Sidebar.tsx
Normal file
26
src/components/ui/Sidebar.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import type { JSXElement } from 'solid-js'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
direction: 'left' | 'right'
|
||||||
|
class?: string
|
||||||
|
children: JSXElement
|
||||||
|
}
|
||||||
|
|
||||||
|
export default (props: Props) => {
|
||||||
|
const containerBaseClass = {
|
||||||
|
left: 'w-[260px] h-100dvh border-r',
|
||||||
|
right: 'w-[300px] h-100dvh border-l',
|
||||||
|
}[props.direction]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
class={[
|
||||||
|
'border-base overflow-hidden shrink-0',
|
||||||
|
containerBaseClass,
|
||||||
|
props.class || '',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{ props.children }
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
33
src/components/ui/ThemeToggle.tsx
Normal file
33
src/components/ui/ThemeToggle.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { Show, onMount } from 'solid-js'
|
||||||
|
import { useDark, useDisableTransition } from '@/hooks'
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
const [isDark, setIsDark] = useDark()
|
||||||
|
const { disableTransition, removeDisableTransition } = useDisableTransition()
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', isDark() ? '#222222' : '#fafafa')
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleDarkChanged = () => {
|
||||||
|
disableTransition()
|
||||||
|
const dark = !isDark()
|
||||||
|
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', dark ? '#222222' : '#fafafa')
|
||||||
|
setIsDark(dark)
|
||||||
|
removeDisableTransition()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="fi p-2 rounded-md cursor-pointer text-lg hv-base hv-foreground"
|
||||||
|
onClick={handleDarkChanged}
|
||||||
|
>
|
||||||
|
<Show when={isDark()} >
|
||||||
|
<div i-carbon-moon />
|
||||||
|
</Show>
|
||||||
|
<Show when={!isDark()}>
|
||||||
|
<div i-carbon-light />
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
67
src/components/ui/base/DropdownMenu.tsx
Normal file
67
src/components/ui/base/DropdownMenu.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import * as menu from '@zag-js/menu'
|
||||||
|
import { normalizeProps, useMachine } from '@zag-js/solid'
|
||||||
|
import { Show, children, createEffect, createMemo, createUniqueId } from 'solid-js'
|
||||||
|
import { Dynamic, For, Portal, spread } from 'solid-js/web'
|
||||||
|
import type { JSX, JSXElement } from 'solid-js'
|
||||||
|
|
||||||
|
export interface MenuItem {
|
||||||
|
id: string
|
||||||
|
label: string | JSXElement
|
||||||
|
icon?: string
|
||||||
|
children?: MenuItem[]
|
||||||
|
role?: string
|
||||||
|
action?: (params?: any) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: JSX.Element
|
||||||
|
menuList: MenuItem[]
|
||||||
|
close?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DropDownMenu = (props: Props) => {
|
||||||
|
const [state, send] = useMachine(
|
||||||
|
menu.machine({
|
||||||
|
id: createUniqueId(),
|
||||||
|
onSelect(details) {
|
||||||
|
if (details.value) {
|
||||||
|
const currentAction = props.menuList.find(item => item.id === details.value)?.action
|
||||||
|
if (typeof currentAction === 'function')
|
||||||
|
currentAction()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const api = createMemo(() => menu.connect(state, send, normalizeProps))
|
||||||
|
|
||||||
|
const resolvedChild = () => {
|
||||||
|
const child = children(() => props.children)
|
||||||
|
createEffect(() => {
|
||||||
|
spread(child() as Element, { ...api().triggerProps })
|
||||||
|
})
|
||||||
|
return child
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Dynamic component={resolvedChild} />
|
||||||
|
<Portal>
|
||||||
|
<Show when={props.children}>
|
||||||
|
<div {...api().positionerProps}>
|
||||||
|
<div {...api().contentProps} class="bg-base text-sm border border-base rounded-md outline-none overflow-hidden shadow-md">
|
||||||
|
<For each={props.menuList}>
|
||||||
|
{item => (
|
||||||
|
<div class={`px-3 py-2 flex items-center space-x-2 ansnid_${item.id} hv-base`} {...api().getItemProps({ id: item.id })}>
|
||||||
|
{item.icon && <div class={item.icon} />}
|
||||||
|
<div>{item.label}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</Portal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
97
src/components/ui/base/Select.tsx
Normal file
97
src/components/ui/base/Select.tsx
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import { createEffect, createMemo, createSignal, createUniqueId, mergeProps, on } from 'solid-js'
|
||||||
|
import * as select from '@zag-js/select'
|
||||||
|
import { normalizeProps, useMachine } from '@zag-js/solid'
|
||||||
|
import type { JSXElement } from 'solid-js'
|
||||||
|
import type { SelectOptionType } from '@/types/provider'
|
||||||
|
|
||||||
|
interface Props<T> {
|
||||||
|
options: T[]
|
||||||
|
value: string
|
||||||
|
onChange: (v: string) => void
|
||||||
|
placeholder?: string
|
||||||
|
readonly?: boolean
|
||||||
|
selectedComponent?: (item: T) => JSXElement
|
||||||
|
itemComponent?: (item: T, isSelected: boolean) => JSXElement
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Select = <T extends SelectOptionType>(inputProps: Props<T>) => {
|
||||||
|
const [selectedItem, setSelectedItem] = createSignal<T | null>(null)
|
||||||
|
const props = mergeProps({
|
||||||
|
placeholder: 'Select option',
|
||||||
|
}, inputProps)
|
||||||
|
const [state, send] = useMachine(select.machine({
|
||||||
|
id: createUniqueId(),
|
||||||
|
selectedOption: props.options.find(o => o.value === props.value),
|
||||||
|
readOnly: props.readonly,
|
||||||
|
onChange: (detail) => {
|
||||||
|
console.log('trigger')
|
||||||
|
if (detail) {
|
||||||
|
setSelectedItem(props.options.find(o => o.value === detail.value))
|
||||||
|
props.onChange(detail.value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const api = createMemo(() => select.connect(state, send, normalizeProps))
|
||||||
|
|
||||||
|
createEffect(on(() => props.value, () => {
|
||||||
|
const option = props.options.find(o => o.value === props.value)
|
||||||
|
if (option)
|
||||||
|
setSelectedItem(option)
|
||||||
|
}))
|
||||||
|
|
||||||
|
const selectedComponent = (item: T | null) => {
|
||||||
|
if (!item) return <div>{props.placeholder}</div>
|
||||||
|
if (props.selectedComponent) return props.selectedComponent(item)
|
||||||
|
return (
|
||||||
|
<div class="fi gap-2">
|
||||||
|
{item?.icon && <div class={item.icon} />}
|
||||||
|
<div>{item.label ?? props.placeholder}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemComponent = (item: T, isSelected: boolean) => {
|
||||||
|
if (props.itemComponent) return props.itemComponent(item, isSelected)
|
||||||
|
return (
|
||||||
|
<div class="fi justify-between w-full px-2 py-1 border-b border-b-base hv-base">
|
||||||
|
<div class="fi gap-2">
|
||||||
|
{item.icon && <div class={item.icon} />}
|
||||||
|
<div>{item.label}</div>
|
||||||
|
</div>
|
||||||
|
{isSelected && (
|
||||||
|
<div i-carbon-checkmark />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
class={`fi justify-between w-full px-2 py-1 border border-base ${props.readonly ? '' : 'hv-base'}`}
|
||||||
|
{...api().triggerProps}
|
||||||
|
>
|
||||||
|
{selectedComponent(selectedItem())}
|
||||||
|
{!props.readonly && <div i-carbon-caret-down />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="w-$reference-width -mt-2 z-100 shadow-md" {...api().positionerProps}>
|
||||||
|
<ul class="bg-base" {...api().contentProps}>
|
||||||
|
{props.options.map(item => (
|
||||||
|
<li
|
||||||
|
{...api().getOptionProps({ label: item.label, value: item.value })}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedItem(item)
|
||||||
|
props.onChange(item.value)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{itemComponent(item, item.value === selectedItem()?.value)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
56
src/components/ui/base/Slider.tsx
Normal file
56
src/components/ui/base/Slider.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import * as slider from '@zag-js/slider'
|
||||||
|
import { normalizeProps, useMachine } from '@zag-js/solid'
|
||||||
|
import { createMemo, createUniqueId, mergeProps } from 'solid-js'
|
||||||
|
import type { Accessor } from 'solid-js'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: Accessor<number>
|
||||||
|
min: number
|
||||||
|
max: number
|
||||||
|
step: number
|
||||||
|
disabled?: boolean
|
||||||
|
setValue: (v: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Slider = (selectProps: Props) => {
|
||||||
|
const props = mergeProps({
|
||||||
|
min: 0,
|
||||||
|
max: 10,
|
||||||
|
step: 1,
|
||||||
|
disabled: false,
|
||||||
|
}, selectProps)
|
||||||
|
|
||||||
|
const formatSliderValue = (value: number) => {
|
||||||
|
if (!value) return 0
|
||||||
|
return Number.isInteger(value) ? value : parseFloat(value.toFixed(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
const [state, send] = useMachine(slider.machine({
|
||||||
|
id: createUniqueId(),
|
||||||
|
value: props.value(),
|
||||||
|
min: props.min,
|
||||||
|
max: props.max,
|
||||||
|
step: props.step,
|
||||||
|
disabled: props.disabled,
|
||||||
|
onChange: (details) => {
|
||||||
|
details && details.value && props.setValue(formatSliderValue(details.value))
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
const api = createMemo(() => slider.connect(state, send, normalizeProps))
|
||||||
|
return (
|
||||||
|
<div {...api().rootProps}>
|
||||||
|
<div class="text-xs op-50 fb items-center">
|
||||||
|
<div />
|
||||||
|
<output {...api().outputProps}>{formatSliderValue(api().value)}</output>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2" {...api().controlProps}>
|
||||||
|
<div {...api().trackProps}>
|
||||||
|
<div {...api().rangeProps} />
|
||||||
|
</div>
|
||||||
|
<div {...api().thumbProps}>
|
||||||
|
<input {...api().hiddenInputProps} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
37
src/components/ui/base/Toggle.tsx
Normal file
37
src/components/ui/base/Toggle.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { createEffect, createMemo, createUniqueId, mergeProps, on } from 'solid-js'
|
||||||
|
import * as zagSwitch from '@zag-js/switch'
|
||||||
|
import { normalizeProps, useMachine } from '@zag-js/solid'
|
||||||
|
import type { Accessor } from 'solid-js'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: Accessor<boolean>
|
||||||
|
setValue: (v: boolean) => void
|
||||||
|
readOnly?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Toggle = (inputProps: Props) => {
|
||||||
|
const props = mergeProps({}, inputProps)
|
||||||
|
const [state, send] = useMachine(zagSwitch.machine({
|
||||||
|
id: createUniqueId(),
|
||||||
|
readOnly: props.readOnly,
|
||||||
|
checked: props.value(),
|
||||||
|
onChange({ checked }) {
|
||||||
|
props.setValue(checked)
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const api = createMemo(() => zagSwitch.connect(state, send, normalizeProps))
|
||||||
|
|
||||||
|
createEffect(on(props.value, () => {
|
||||||
|
api().setChecked(props.value())
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label {...api().rootProps}>
|
||||||
|
<input {...api().inputProps} type="checkbox" />
|
||||||
|
<div {...api().controlProps} class="track">
|
||||||
|
<span {...api().thumbProps} />
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
50
src/components/ui/base/Tooltip.tsx
Normal file
50
src/components/ui/base/Tooltip.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import * as tooltip from '@zag-js/tooltip'
|
||||||
|
import { normalizeProps, useMachine } from '@zag-js/solid'
|
||||||
|
import { Show, children, createEffect, createMemo, createUniqueId } from 'solid-js'
|
||||||
|
import { Dynamic, spread } from 'solid-js/web'
|
||||||
|
import type { JSX, JSXElement } from 'solid-js'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string | JSXElement
|
||||||
|
tip: string | JSXElement
|
||||||
|
children: JSX.Element
|
||||||
|
openDelay?: number
|
||||||
|
closeDelay?: number
|
||||||
|
handleChildClick?: () => void
|
||||||
|
placement?: 'top' | 'bottom' | 'left' | 'right' | 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end' | 'left-start' | 'left-end' | 'right-start' | 'right-end'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Tooltip = (props: Props) => {
|
||||||
|
// TODO Official demo type error
|
||||||
|
const [state, send] = useMachine(
|
||||||
|
tooltip.machine({
|
||||||
|
id: createUniqueId(),
|
||||||
|
openDelay: props.openDelay ?? 300,
|
||||||
|
closeDelay: props.closeDelay ?? 300,
|
||||||
|
positioning: {
|
||||||
|
placement: props.placement ?? 'top',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const api = createMemo(() => tooltip.connect(state, send, normalizeProps))
|
||||||
|
|
||||||
|
const resolvedChild = () => {
|
||||||
|
const child = children(() => props.children)
|
||||||
|
createEffect(() => {
|
||||||
|
spread(child() as Element, { ...api().triggerProps, onClick: props.handleChildClick })
|
||||||
|
})
|
||||||
|
return child()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={`ansnid_${props.id}`}>
|
||||||
|
<Dynamic component={resolvedChild} />
|
||||||
|
<Show when={api().isOpen}>
|
||||||
|
<div {...api().positionerProps} class="transition-opacity duration-300">
|
||||||
|
<div {...api().contentProps} class="px-2 py-1 text-xs text-white bg-dark-600 dark-bg-zinc-900 rounded-md shadow-sm op-80">{ props.tip }</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
7
src/components/ui/base/index.ts
Normal file
7
src/components/ui/base/index.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import '@/assets/zag-components.css'
|
||||||
|
|
||||||
|
export * from './DropdownMenu'
|
||||||
|
export * from './Select'
|
||||||
|
export * from './Slider'
|
||||||
|
export * from './Tooltip'
|
||||||
|
export * from './Toggle'
|
16
src/env.d.ts
vendored
Normal file
16
src/env.d.ts
vendored
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
/// <reference types="astro/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly OPENAI_API_KEY: 'string'
|
||||||
|
readonly HTTPS_PROXY: string
|
||||||
|
readonly OPENAI_API_BASE_URL: string
|
||||||
|
readonly HEAD_SCRIPTS: string
|
||||||
|
readonly SECRET_KEY: string
|
||||||
|
readonly SITE_PASSWORD: string
|
||||||
|
readonly OPENAI_API_MODEL: string
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
8
src/hooks/index.ts
Normal file
8
src/hooks/index.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export * from './useDark'
|
||||||
|
export * from './useCopy'
|
||||||
|
export * from './useClickOutside'
|
||||||
|
export * from './useLargeScreen'
|
||||||
|
export * from './useMobileScreen'
|
||||||
|
export * from './useDepGet'
|
||||||
|
export * from './useI18n'
|
||||||
|
export * from './useDisableTransition'
|
25
src/hooks/useClickOutside.ts
Normal file
25
src/hooks/useClickOutside.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { createSignal, onCleanup } from 'solid-js'
|
||||||
|
|
||||||
|
export const useClickOutside = (ref: HTMLElement, handler: (e: MouseEvent) => any) => {
|
||||||
|
const [clickedOutside, setClickedOutside] = createSignal(false)
|
||||||
|
|
||||||
|
const handleClick = (event: MouseEvent) => {
|
||||||
|
if (ref && (ref.contains(event.target as Node) || event.composedPath().includes(ref))) {
|
||||||
|
setClickedOutside(false)
|
||||||
|
return clickedOutside()
|
||||||
|
} else {
|
||||||
|
setClickedOutside(true)
|
||||||
|
handler(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCleanup = () => {
|
||||||
|
document.removeEventListener('click', handleClick)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', handleClick)
|
||||||
|
|
||||||
|
onCleanup(handleCleanup)
|
||||||
|
|
||||||
|
return clickedOutside()
|
||||||
|
}
|
20
src/hooks/useCopy.ts
Normal file
20
src/hooks/useCopy.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { createEffect, createSignal } from 'solid-js'
|
||||||
|
import { writeClipboard } from '@solid-primitives/clipboard'
|
||||||
|
|
||||||
|
export const useClipboardCopy = (source: string, delay = 1000) => {
|
||||||
|
const [copied, setCopied] = createSignal(false)
|
||||||
|
|
||||||
|
const copy = async() => {
|
||||||
|
writeClipboard(source)
|
||||||
|
setCopied(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (copied()) {
|
||||||
|
const timer = setTimeout(() => setCopied(false), delay)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return [copied, copy] as const
|
||||||
|
}
|
29
src/hooks/useDark.ts
Normal file
29
src/hooks/useDark.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { createEffect, createSignal } from 'solid-js'
|
||||||
|
|
||||||
|
export const useDark = () => {
|
||||||
|
const [dark, setIsDark] = createSignal(false)
|
||||||
|
|
||||||
|
const listenColorSchema = () => {
|
||||||
|
const colorSchema = window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
colorSchema.addEventListener('change', () => {
|
||||||
|
document.documentElement.classList.toggle('dark', colorSchema.matches)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const theme = localStorage.getItem('theme')
|
||||||
|
if (theme) { setIsDark(theme === 'dark') } else {
|
||||||
|
const colorSchema = window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
setIsDark(colorSchema.matches)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
document.documentElement.classList.toggle('dark', dark())
|
||||||
|
localStorage.setItem('theme', dark() ? 'dark' : 'light')
|
||||||
|
}, [dark()])
|
||||||
|
|
||||||
|
listenColorSchema()
|
||||||
|
|
||||||
|
return [dark, setIsDark] as const
|
||||||
|
}
|
23
src/hooks/useDepGet.ts
Normal file
23
src/hooks/useDepGet.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
export function useDeepGet(target: any, path: string | string[], defaultValue: any) {
|
||||||
|
if (!Array.isArray(path) && typeof path !== 'string')
|
||||||
|
throw new TypeError('path must be string or array')
|
||||||
|
if (target === null)
|
||||||
|
return defaultValue
|
||||||
|
|
||||||
|
let pathArray = path
|
||||||
|
if (typeof path === 'string') {
|
||||||
|
path = path.replace(/\[(\w*)\]/g, '.$1')
|
||||||
|
path = path.startsWith('.') ? path.slice(1) : path
|
||||||
|
|
||||||
|
pathArray = path.split('.')
|
||||||
|
}
|
||||||
|
|
||||||
|
let index = 0
|
||||||
|
let levelPath: string
|
||||||
|
while (target !== null && index < pathArray.length) {
|
||||||
|
levelPath = pathArray[index++]
|
||||||
|
target = target[levelPath]
|
||||||
|
}
|
||||||
|
|
||||||
|
return index === pathArray.length ? target : defaultValue
|
||||||
|
}
|
32
src/hooks/useDisableTransition.ts
Normal file
32
src/hooks/useDisableTransition.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
export const useDisableTransition = () => {
|
||||||
|
// https://paco.me/writing/disable-theme-transitions
|
||||||
|
const css = document.createElement('style')
|
||||||
|
const disableTransition = () => {
|
||||||
|
css.type = 'text/css'
|
||||||
|
css.appendChild(
|
||||||
|
document.createTextNode(
|
||||||
|
`* {
|
||||||
|
-webkit-transition: none !important;
|
||||||
|
-moz-transition: none !important;
|
||||||
|
-o-transition: none !important;
|
||||||
|
-ms-transition: none !important;
|
||||||
|
transition: none !important;
|
||||||
|
}`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
document.head.appendChild(css)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calling getComputedStyle forces the browser to redraw
|
||||||
|
const removeDisableTransition = () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const _ = window.getComputedStyle(css).opacity
|
||||||
|
document.head.removeChild(css)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
css,
|
||||||
|
disableTransition,
|
||||||
|
removeDisableTransition,
|
||||||
|
}
|
||||||
|
}
|
41
src/hooks/useI18n.ts
Normal file
41
src/hooks/useI18n.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { createSignal } from 'solid-js'
|
||||||
|
import { zhCN } from '@/locale/lang'
|
||||||
|
import { locales } from '@/locale'
|
||||||
|
import { providerSettingsMap } from '@/stores/settings'
|
||||||
|
import { useDeepGet } from './useDepGet'
|
||||||
|
import type { Accessor } from 'solid-js'
|
||||||
|
import type { TranslatePair } from '@/locale'
|
||||||
|
import type { GeneralSettings } from '@/types/app'
|
||||||
|
|
||||||
|
const [currentLocale, setCurrentLocale] = createSignal(zhCN.locales)
|
||||||
|
|
||||||
|
export type TranslatorOption = Record<string, string | number>
|
||||||
|
export type Translator = (path: string, option?: TranslatorOption) => string
|
||||||
|
export interface I18nContext {
|
||||||
|
locale: TranslatePair
|
||||||
|
t: Translator
|
||||||
|
}
|
||||||
|
|
||||||
|
export const translate = (path: string, option: TranslatorOption | undefined) => {
|
||||||
|
return currentLocale() ? (useDeepGet(currentLocale(), path, path) as string).replace(/\{(\w+)\}/g, (_, key) => `${option?.[key] ?? `{${key}}`}`) : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildTranslator = (): Translator => (path, option) => translate(path, option)
|
||||||
|
export const buildI18nContext = (locale: Accessor<TranslatePair>): I18nContext => {
|
||||||
|
return {
|
||||||
|
locale: locale(),
|
||||||
|
t: buildTranslator(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useI18n() {
|
||||||
|
let defaultLocale = providerSettingsMap.get()?.general?.locale ?? 'zhCN'
|
||||||
|
providerSettingsMap.listen((value, changedKey) => {
|
||||||
|
const general = value[changedKey ?? 'general'] as unknown as GeneralSettings
|
||||||
|
defaultLocale = general?.locale
|
||||||
|
defaultLocale && setCurrentLocale(locales[defaultLocale as string])
|
||||||
|
})
|
||||||
|
|
||||||
|
setCurrentLocale(locales[defaultLocale as string])
|
||||||
|
return buildI18nContext(currentLocale)
|
||||||
|
}
|
22
src/hooks/useLargeScreen.ts
Normal file
22
src/hooks/useLargeScreen.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { createSignal, onCleanup, onMount } from 'solid-js'
|
||||||
|
import { throttle } from '@solid-primitives/scheduled'
|
||||||
|
|
||||||
|
export const useLargeScreen = (handler: (e: UIEvent) => void) => {
|
||||||
|
const [isLargeScreen, setIsLargeScreen] = createSignal(false)
|
||||||
|
|
||||||
|
const handleResize = throttle((e: UIEvent) => {
|
||||||
|
setIsLargeScreen(window.innerWidth > 1024)
|
||||||
|
if (window.innerWidth > 1024)
|
||||||
|
return handler(e)
|
||||||
|
}, 200)
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
window.removeEventListener('resize', handleResize)
|
||||||
|
})
|
||||||
|
|
||||||
|
return isLargeScreen
|
||||||
|
}
|
22
src/hooks/useMobileScreen.ts
Normal file
22
src/hooks/useMobileScreen.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { createSignal, onCleanup, onMount } from 'solid-js'
|
||||||
|
import { throttle } from '@solid-primitives/scheduled'
|
||||||
|
|
||||||
|
export const useMobileScreen = (handler: (e: UIEvent) => void) => {
|
||||||
|
const [isMobileScreen, setIsMobileScreen] = createSignal(false)
|
||||||
|
|
||||||
|
const handleResize = throttle((e: UIEvent) => {
|
||||||
|
setIsMobileScreen(window.innerWidth < 640)
|
||||||
|
if (window.innerWidth < 640)
|
||||||
|
return handler(e)
|
||||||
|
}, 200)
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
window.removeEventListener('resize', handleResize)
|
||||||
|
})
|
||||||
|
|
||||||
|
return isMobileScreen
|
||||||
|
}
|
62
src/http/api.ts
Normal file
62
src/http/api.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
|
||||||
|
import { db } from '../stores/storage/message'
|
||||||
|
|
||||||
|
interface Data {}
|
||||||
|
interface ResponseData {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: Array;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateSessionId() {
|
||||||
|
var characters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
|
var sessionId = '';
|
||||||
|
for (var i = 0; i < 32; i++) {
|
||||||
|
sessionId += characters.charAt(Math.floor(Math.random() * characters.length));
|
||||||
|
}
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function baseUrl(){
|
||||||
|
return 'https://x--mo.com:8888';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getToken(){
|
||||||
|
|
||||||
|
const sessionId = localStorage.getItem('AnsnidSessionId') || generateSessionId();
|
||||||
|
|
||||||
|
localStorage.setItem('AnsnidSessionId', sessionId)
|
||||||
|
|
||||||
|
console.log(sessionId)
|
||||||
|
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchData(data: Data | null, callback: (data: ResponseData) => void, url: string, method ? : string): void {
|
||||||
|
let fetchOptions = {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": getToken()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (method && method.toUpperCase() === "POST" && data) {
|
||||||
|
fetchOptions = { ...fetchOptions,
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
fetchOptions = { ...fetchOptions,
|
||||||
|
method: "GET"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
url = baseUrl() + url;
|
||||||
|
fetch(url, fetchOptions).then(response => response.json().then(data => {
|
||||||
|
callback(data);
|
||||||
|
})).catch(error => {
|
||||||
|
throw new Error('Request failed', {
|
||||||
|
cause: error?.error,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
46
src/layouts/Layout.astro
Normal file
46
src/layouts/Layout.astro
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
export interface Props {
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title,type } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" />
|
||||||
|
<link rel="mask-icon" href="/logo.svg" color="#FFFFFF" />
|
||||||
|
<meta name="theme-color" content="#101010" />
|
||||||
|
<meta name="generator" content={Astro.generator} />
|
||||||
|
<title>{title}</title>
|
||||||
|
<meta name="description" content="Get answers from AI, elegantly." />
|
||||||
|
{ import.meta.env.HEAD_SCRIPTS ? <Fragment set:html={import.meta.env.HEAD_SCRIPTS} /> : null }
|
||||||
|
<!-- netlify-disable-blocks -->
|
||||||
|
{
|
||||||
|
import.meta.env.PROD && (
|
||||||
|
<>
|
||||||
|
<script is:inline src="/registerSW.js" />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
<!-- netlify-disable-end -->
|
||||||
|
</head>
|
||||||
|
<body class={`bg-base fg-base ${type}`}>
|
||||||
|
<slot />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
const setting = localStorage.getItem('theme') || 'auto'
|
||||||
|
if (setting === 'dark' || (prefersDark && setting !== 'light'))
|
||||||
|
document.documentElement.classList.toggle('dark', true)
|
||||||
|
})()
|
||||||
|
</script>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user