first commit

This commit is contained in:
XiaoMo 2023-06-04 15:55:58 +08:00
commit dea1b6a851
136 changed files with 15516 additions and 0 deletions

6
.dockerignore Normal file
View File

@ -0,0 +1,6 @@
*.md
Dockerfile
docker-compose.yml
LICENSE
netlify.toml
vercel.json

7
.eslintignore Normal file
View File

@ -0,0 +1,7 @@
dist
public
node_modules
.netlify
.vercel
.github
.changeset

34
.eslintrc.js Normal file
View 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',
},
},
],
}

View 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

View 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
View 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.

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,4 @@
{
"recommendations": ["astro-build.astro-vscode","dbaeumer.vscode-eslint","antfu.unocss"],
"unwantedRecommendations": [],
}

11
.vscode/launch.json vendored Normal file
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,81 @@
![Banner](https://user-images.githubusercontent.com/1998168/235366625-e615e68d-592c-4f18-9c9f-1e5cd1778557.png)
# 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 modessupport `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:
![](https://cdn.staticaly.com/gh/yzh990918/static@master/20230518/image.2omctdf8bbk0.webp)
## 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 laterreference: 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!🙏
[![img](https://contrib.rocks/image?repo=anse-app/anse)](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
View File

@ -0,0 +1,84 @@
![Banner](https://user-images.githubusercontent.com/1998168/235366625-e615e68d-592c-4f18-9c9f-1e5cd1778557.png)
# 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 项目的操作页面上手动启用工作流和上游同步操作。启用后,每天都会执行自动更新:
![](https://cdn.staticaly.com/gh/yzh990918/static@master/20230518/image.2omctdf8bbk0.webp)
## 常见问题
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 部署。
## 参与贡献
这个项目的存在要感谢所有做出贡献的人。
感谢我们所有的支持者!🙏
[![img](https://contributors.nn.ci/api?repo=anse-app/anse)](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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

BIN
public/pwa-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

2
public/robots.txt Normal file
View File

@ -0,0 +1,2 @@
User-agent: *
Allow: /

16
shims.d.ts vendored Normal file
View 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 {}
}
}

View 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
View 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
View 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%;
}
}

View 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
View 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>

View 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>
)
}

View 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
View 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>
)
}

View 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>

View 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}
/>
)
}

View File

@ -0,0 +1,11 @@
import { createStores, rebuildStores } from '@/stores/storage/db'
const buildStores = async() => {
await createStores()
await rebuildStores()
}
export default () => {
buildStores()
return null
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)}
</>
)
}

View 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) }} />
</>
)
}

View 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>
)
}

View 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>
</>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
);
}

View 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;*/
}

View 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>
)}
/>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@ -0,0 +1,5 @@
export default () => {
return (
<div op-25>Not Defined</div>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
View 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
View 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'

View 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
View 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
View 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
View 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
}

View 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
View 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)
}

View 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
}

View 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
View 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
View 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