add initial complete webui, more ai commands for moderation, add api
This commit is contained in:
parent
19e794e34c
commit
173d4e7a52
112 changed files with 8176 additions and 780 deletions
7
.dockerignore
Normal file → Executable file
7
.dockerignore
Normal file → Executable file
|
@ -1,8 +1,13 @@
|
||||||
node_modules
|
node_modules
|
||||||
|
webui/node_modules
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
.git
|
.git
|
||||||
|
webui/.git
|
||||||
.gitignore
|
.gitignore
|
||||||
.env
|
webui/.gitignore
|
||||||
|
.env*
|
||||||
|
webui/.env*
|
||||||
|
webui/.next
|
||||||
*.md
|
*.md
|
||||||
!README.md
|
!README.md
|
||||||
ollama/
|
ollama/
|
||||||
|
|
0
.env.example
Normal file → Executable file
0
.env.example
Normal file → Executable file
0
.github/dependabot.yml
vendored
Normal file → Executable file
0
.github/dependabot.yml
vendored
Normal file → Executable file
0
.github/workflows/njsscan.yml
vendored
Normal file → Executable file
0
.github/workflows/njsscan.yml
vendored
Normal file → Executable file
0
.github/workflows/stale.yml
vendored
Normal file → Executable file
0
.github/workflows/stale.yml
vendored
Normal file → Executable file
0
.github/workflows/update-authors.yml
vendored
Normal file → Executable file
0
.github/workflows/update-authors.yml
vendored
Normal file → Executable file
0
.gitignore
vendored
Normal file → Executable file
0
.gitignore
vendored
Normal file → Executable file
4
.gitmodules
vendored
Normal file → Executable file
4
.gitmodules
vendored
Normal file → Executable file
|
@ -1,3 +1,3 @@
|
||||||
[submodule "src/spamwatch"]
|
[submodule "telegram/spamwatch"]
|
||||||
path = src/spamwatch
|
path = telegram/spamwatch
|
||||||
url = https://github.com/ABOCN/TelegramBot-SpamWatch
|
url = https://github.com/ABOCN/TelegramBot-SpamWatch
|
||||||
|
|
0
AUTHORS
Normal file → Executable file
0
AUTHORS
Normal file → Executable file
0
CODE_OF_CONDUCT.md
Normal file → Executable file
0
CODE_OF_CONDUCT.md
Normal file → Executable file
27
Dockerfile
Normal file → Executable file
27
Dockerfile
Normal file → Executable file
|
@ -1,18 +1,37 @@
|
||||||
FROM oven/bun
|
FROM oven/bun
|
||||||
|
|
||||||
# Install ffmpeg and other deps
|
# Install ffmpeg and other deps
|
||||||
RUN apt-get update && apt-get install -y ffmpeg git && apt-get clean && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y \
|
||||||
|
ffmpeg \
|
||||||
|
git \
|
||||||
|
supervisor \
|
||||||
|
&& apt-get clean \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
RUN bun install
|
||||||
|
|
||||||
RUN bun i
|
COPY webui/package*.json ./webui/
|
||||||
|
WORKDIR /usr/src/app/webui
|
||||||
|
RUN bun install
|
||||||
|
|
||||||
|
WORKDIR /usr/src/app
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN chmod +x /usr/src/app/src/plugins/yt-dlp/yt-dlp
|
WORKDIR /usr/src/app/webui
|
||||||
|
RUN bun run build
|
||||||
|
|
||||||
|
RUN chmod +x /usr/src/app/telegram/plugins/yt-dlp/yt-dlp
|
||||||
|
|
||||||
|
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||||
|
|
||||||
VOLUME /usr/src/app/.env
|
VOLUME /usr/src/app/.env
|
||||||
|
|
||||||
CMD ["bun", "start"]
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV BUN_LOG_LEVEL=info
|
||||||
|
|
||||||
|
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||||
|
|
0
LICENSE
Normal file → Executable file
0
LICENSE
Normal file → Executable file
63
README.md
Normal file → Executable file
63
README.md
Normal file → Executable file
|
@ -26,21 +26,6 @@ Kowalski is a a simple Telegram bot made in Node.js.
|
||||||
- High-end CPU *or* GPU (~ 6GB vRAM)
|
- High-end CPU *or* GPU (~ 6GB vRAM)
|
||||||
- If using CPU, enough RAM to load the models (~6GB w/ defaults)
|
- If using CPU, enough RAM to load the models (~6GB w/ defaults)
|
||||||
|
|
||||||
## Running locally (non-Docker setup)
|
|
||||||
|
|
||||||
First, clone the repo with Git:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone --recurse-submodules https://github.com/ABOCN/TelegramBot
|
|
||||||
```
|
|
||||||
|
|
||||||
Next, inside the repository directory, create an `.env` file with some content, which you can see the [example .env file](.env.example) to fill info with. To see the meaning of each one, see [the Functions section](#env-functions).
|
|
||||||
|
|
||||||
After editing the file, save all changes and run the bot with ``bun start``.
|
|
||||||
|
|
||||||
> [!TIP]
|
|
||||||
> To deal with dependencies, just run ``bun install`` or ``bun i`` at any moment to install all of them.
|
|
||||||
|
|
||||||
## Running with Docker
|
## Running with Docker
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
|
@ -69,14 +54,16 @@ You can also run Kowalski using Docker, which simplifies the setup process. Make
|
||||||
mv docker-compose.yml.ai.example docker-compose.yml
|
mv docker-compose.yml.ai.example docker-compose.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Make sure to setup your `.env` file first!**
|
1. **Make sure to setup your `.env` file first!**
|
||||||
|
|
||||||
|
In order to successfuly deploy Kowalski, you will need to edit both your `.env` file and enter matching values in `webui/.env`.
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> If you intend to setup AI, the defaults for Docker are already included (just uncomment) and don't need to be changed.
|
> If you intend to setup AI, the defaults for Docker are already included (just uncomment) and don't need to be changed.
|
||||||
>
|
>
|
||||||
> Further setup may be needed for GPUs. See the Ollama documentation for more.
|
> Further setup may be needed for GPUs. See the Ollama documentation for more.
|
||||||
|
|
||||||
3. **Run the container**
|
1. **Run the container**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
@ -88,13 +75,15 @@ If you prefer to use Docker directly, you can use these instructions instead.
|
||||||
|
|
||||||
1. **Make sure to setup your `.env` file first!**
|
1. **Make sure to setup your `.env` file first!**
|
||||||
|
|
||||||
2. **Build the image**
|
In order to successfuly deploy Kowalski, you will need to edit both your `.env` file and enter matching values in `webui/.env`.
|
||||||
|
|
||||||
|
1. **Build the image**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -t kowalski .
|
docker build -t kowalski .
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Run the container**
|
1. **Run the container**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d --name kowalski --restart unless-stopped -v $(pwd)/.env:/usr/src/app/.env:ro kowalski
|
docker run -d --name kowalski --restart unless-stopped -v $(pwd)/.env:/usr/src/app/.env:ro kowalski
|
||||||
|
@ -103,11 +92,36 @@ If you prefer to use Docker directly, you can use these instructions instead.
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> You must setup Ollama on your own if you would like to use AI features.
|
> You must setup Ollama on your own if you would like to use AI features.
|
||||||
|
|
||||||
|
## Running locally (non-Docker/development setup)
|
||||||
|
|
||||||
|
First, clone the repo with Git:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone --recurse-submodules https://github.com/ABOCN/TelegramBot
|
||||||
|
```
|
||||||
|
|
||||||
|
Next, inside the repository directory, create an `.env` file with some content, which you can see the [example .env file](.env.example) to fill info with. To see the meaning of each one, see [the Functions section](#env-functions).
|
||||||
|
|
||||||
|
After editing the file, save all changes and run the bot with ``bun start``.
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> To deal with dependencies, just run ``bun install`` or ``bun i`` at any moment to install all of them.
|
||||||
|
|
||||||
|
### Efficant Local (w/ Docker) Development
|
||||||
|
|
||||||
|
If you want to develop a component of Kowalski, without dealing with the headache of several terminals, we suggest you follow these guidelines:
|
||||||
|
|
||||||
|
1. If you are working on one component, run it with Bun, and Dockerize the other components.
|
||||||
|
1. Minimize the amount of non-Dockerized components to reduce headaches.
|
||||||
|
1. You will have to change your `.env` a lot. This is a common source of issues. Make sure the hostname and port are correct.
|
||||||
|
|
||||||
## .env Functions
|
## .env Functions
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> Take care of your ``.env`` file, as it is so much important and needs to be secret (like your passwords), as anyone can do whatever they want to the bot with this token!
|
> Take care of your ``.env`` file, as it is so much important and needs to be secret (like your passwords), as anyone can do whatever they want to the bot with this token!
|
||||||
|
|
||||||
|
### Bot
|
||||||
|
|
||||||
- **botSource**: Put the link to your bot source code.
|
- **botSource**: Put the link to your bot source code.
|
||||||
- **botPrivacy**: Put the link to your bot privacy policy.
|
- **botPrivacy**: Put the link to your bot privacy policy.
|
||||||
- **maxRetries**: Maximum number of retries for a failing command on Kowalski. Default is 5. If the limit is hit, the bot will crash past this number.
|
- **maxRetries**: Maximum number of retries for a failing command on Kowalski. Default is 5. If the limit is hit, the bot will crash past this number.
|
||||||
|
@ -127,16 +141,21 @@ If you prefer to use Docker directly, you can use these instructions instead.
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Further, advanced fine-tuning and configuration can be done in TypeScript with the files in the `/config` folder.
|
> Further, advanced fine-tuning and configuration can be done in TypeScript with the files in the `/config` folder.
|
||||||
|
|
||||||
|
### WebUI
|
||||||
|
|
||||||
|
- **botApiUrl**: Likely will stay the same, but changes the API that the bot exposes
|
||||||
|
- **databaseUrl**: Database server configuration (see `.env.example`)
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### YouTube Downloading
|
### YouTube Downloading
|
||||||
|
|
||||||
**Q:** I get a "Permission denied (EACCES)" error in the console when running the `/yt` command
|
**Q:** I get a "Permission denied (EACCES)" error in the console when running the `/yt` command
|
||||||
|
|
||||||
**A:** Make sure `src/plugins/yt-dlp/yt-dlp` is executable. You can do this on Linux like so:
|
**A:** Make sure `telegram/plugins/yt-dlp/yt-dlp` is executable. You can do this on Linux like so:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
chmod +x src/plugins/yt-dlp/yt-dlp
|
chmod +x telegram/plugins/yt-dlp/yt-dlp
|
||||||
```
|
```
|
||||||
|
|
||||||
### AI
|
### AI
|
||||||
|
@ -157,4 +176,4 @@ Made with [contrib.rocks](https://contrib.rocks).
|
||||||
|
|
||||||
BSD-3-Clause - 2024 Lucas Gabriel (lucmsilva).
|
BSD-3-Clause - 2024 Lucas Gabriel (lucmsilva).
|
||||||
|
|
||||||
Featuring some components under Unlicense.
|
With some components under Unlicense.
|
||||||
|
|
0
TERMS_OF_USE.md
Normal file → Executable file
0
TERMS_OF_USE.md
Normal file → Executable file
362
config/ai.ts
Normal file → Executable file
362
config/ai.ts
Normal file → Executable file
|
@ -1,4 +1,16 @@
|
||||||
import type { ModelInfo } from "../src/commands/ai"
|
export interface ModelInfo {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
descriptionEn: string;
|
||||||
|
descriptionPt: string;
|
||||||
|
models: Array<{
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
parameterSize: string;
|
||||||
|
thinking: boolean;
|
||||||
|
uncensored: boolean;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
export const defaultFlashModel = "gemma3:4b"
|
export const defaultFlashModel = "gemma3:4b"
|
||||||
export const defaultThinkingModel = "qwen3:4b"
|
export const defaultThinkingModel = "qwen3:4b"
|
||||||
|
@ -12,8 +24,20 @@ export const models: ModelInfo[] = [
|
||||||
descriptionEn: 'Gemma3n is a family of open, light on-device models for general tasks.',
|
descriptionEn: 'Gemma3n is a family of open, light on-device models for general tasks.',
|
||||||
descriptionPt: 'Gemma3n é uma família de modelos abertos, leves e para dispositivos locais, para tarefas gerais.',
|
descriptionPt: 'Gemma3n é uma família de modelos abertos, leves e para dispositivos locais, para tarefas gerais.',
|
||||||
models: [
|
models: [
|
||||||
{ name: 'gemma3n:e2b', label: 'Gemma3n e2b', parameterSize: '2B' },
|
{
|
||||||
{ name: 'gemma3n:e4b', label: 'Gemma3n e4b', parameterSize: '4B' },
|
name: 'gemma3n:e2b',
|
||||||
|
label: 'Gemma3n e2b',
|
||||||
|
parameterSize: '2B',
|
||||||
|
thinking: false,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'gemma3n:e4b',
|
||||||
|
label: 'Gemma3n e4b',
|
||||||
|
parameterSize: '4B',
|
||||||
|
thinking: false,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -22,11 +46,34 @@ export const models: ModelInfo[] = [
|
||||||
descriptionEn: 'Gemma3-abliterated is a family of open, uncensored models for general tasks.',
|
descriptionEn: 'Gemma3-abliterated is a family of open, uncensored models for general tasks.',
|
||||||
descriptionPt: 'Gemma3-abliterated é uma família de modelos abertos, não censurados, para tarefas gerais.',
|
descriptionPt: 'Gemma3-abliterated é uma família de modelos abertos, não censurados, para tarefas gerais.',
|
||||||
models: [
|
models: [
|
||||||
{ name: 'huihui_ai/gemma3-abliterated:1b', label: 'Gemma3 Uncensored 1B', parameterSize: '1B' },
|
{
|
||||||
{ name: 'huihui_ai/gemma3-abliterated:4b', label: 'Gemma3 Uncensored 4B', parameterSize: '4B' },
|
name: 'huihui_ai/gemma3-abliterated:1b',
|
||||||
{ name: 'gemma3:1b', label: 'Gemma3 1B', parameterSize: '1B' },
|
label: 'Gemma3 Uncensored 1B',
|
||||||
{ name: 'gemma3:4b', label: 'Gemma3 4B', parameterSize: '4B' },
|
parameterSize: '1B',
|
||||||
{ name: 'gemma3:12b', label: 'Gemma3 12B', parameterSize: '12B' },
|
thinking: false,
|
||||||
|
uncensored: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'huihui_ai/gemma3-abliterated:4b',
|
||||||
|
label: 'Gemma3 Uncensored 4B',
|
||||||
|
parameterSize: '4B',
|
||||||
|
thinking: false,
|
||||||
|
uncensored: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'gemma3:1b',
|
||||||
|
label: 'Gemma3 1B',
|
||||||
|
parameterSize: '1B',
|
||||||
|
thinking: false,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'gemma3:4b',
|
||||||
|
label: 'Gemma3 4B',
|
||||||
|
parameterSize: '4B',
|
||||||
|
thinking: false,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -35,14 +82,55 @@ export const models: ModelInfo[] = [
|
||||||
descriptionEn: 'Qwen3 is a multilingual reasoning model series.',
|
descriptionEn: 'Qwen3 is a multilingual reasoning model series.',
|
||||||
descriptionPt: 'Qwen3 é uma série de modelos multilingues.',
|
descriptionPt: 'Qwen3 é uma série de modelos multilingues.',
|
||||||
models: [
|
models: [
|
||||||
{ name: 'qwen3:0.6b', label: 'Qwen3 0.6B', parameterSize: '0.6B' },
|
{
|
||||||
{ name: 'qwen3:1.7b', label: 'Qwen3 1.7B', parameterSize: '1.7B' },
|
name: 'qwen3:0.6b',
|
||||||
{ name: 'qwen3:4b', label: 'Qwen3 4B', parameterSize: '4B' },
|
label: 'Qwen3 0.6B',
|
||||||
{ name: 'qwen3:8b', label: 'Qwen3 8B', parameterSize: '8B' },
|
parameterSize: '0.6B',
|
||||||
{ name: 'qwen3:14b', label: 'Qwen3 14B', parameterSize: '14B' },
|
thinking: true,
|
||||||
{ name: 'qwen3:30b', label: 'Qwen3 30B', parameterSize: '30B' },
|
uncensored: false
|
||||||
{ name: 'qwen3:32b', label: 'Qwen3 32B', parameterSize: '32B' },
|
},
|
||||||
{ name: 'qwen3:235b-a22b', label: 'Qwen3 235B A22B', parameterSize: '235B' },
|
{
|
||||||
|
name: 'qwen3:1.7b',
|
||||||
|
label: 'Qwen3 1.7B',
|
||||||
|
parameterSize: '1.7B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'qwen3:4b',
|
||||||
|
label: 'Qwen3 4B',
|
||||||
|
parameterSize: '4B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'qwen3:8b',
|
||||||
|
label: 'Qwen3 8B',
|
||||||
|
parameterSize: '8B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'qwen3:14b',
|
||||||
|
label: 'Qwen3 14B',
|
||||||
|
parameterSize: '14B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'qwen3:30b',
|
||||||
|
label: 'Qwen3 30B',
|
||||||
|
parameterSize: '30B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'qwen3:32b',
|
||||||
|
label: 'Qwen3 32B',
|
||||||
|
parameterSize: '32B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -51,14 +139,55 @@ export const models: ModelInfo[] = [
|
||||||
descriptionEn: 'Qwen3-abliterated is a multilingual reasoning model series.',
|
descriptionEn: 'Qwen3-abliterated is a multilingual reasoning model series.',
|
||||||
descriptionPt: 'Qwen3-abliterated é uma série de modelos multilingues.',
|
descriptionPt: 'Qwen3-abliterated é uma série de modelos multilingues.',
|
||||||
models: [
|
models: [
|
||||||
{ name: 'huihui_ai/qwen3-abliterated:0.6b', label: 'Qwen3 Uncensored 0.6B', parameterSize: '0.6B' },
|
{
|
||||||
{ name: 'huihui_ai/qwen3-abliterated:1.7b', label: 'Qwen3 Uncensored 1.7B', parameterSize: '1.7B' },
|
name: 'huihui_ai/qwen3-abliterated:0.6b',
|
||||||
{ name: 'huihui_ai/qwen3-abliterated:4b', label: 'Qwen3 Uncensored 4B', parameterSize: '4B' },
|
label: 'Qwen3 Uncensored 0.6B',
|
||||||
{ name: 'huihui_ai/qwen3-abliterated:8b', label: 'Qwen3 Uncensored 8B', parameterSize: '8B' },
|
parameterSize: '0.6B',
|
||||||
{ name: 'huihui_ai/qwen3-abliterated:14b', label: 'Qwen3 Uncensored 14B', parameterSize: '14B' },
|
thinking: true,
|
||||||
{ name: 'huihui_ai/qwen3-abliterated:30b', label: 'Qwen3 Uncensored 30B', parameterSize: '30B' },
|
uncensored: true
|
||||||
{ name: 'huihui_ai/qwen3-abliterated:32b', label: 'Qwen3 Uncensored 32B', parameterSize: '32B' },
|
},
|
||||||
{ name: 'huihui_ai/qwen3-abliterated:235b', label: 'Qwen3 Uncensored 235B', parameterSize: '235B' },
|
{
|
||||||
|
name: 'huihui_ai/qwen3-abliterated:1.7b',
|
||||||
|
label: 'Qwen3 Uncensored 1.7B',
|
||||||
|
parameterSize: '1.7B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'huihui_ai/qwen3-abliterated:4b',
|
||||||
|
label: 'Qwen3 Uncensored 4B',
|
||||||
|
parameterSize: '4B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'huihui_ai/qwen3-abliterated:8b',
|
||||||
|
label: 'Qwen3 Uncensored 8B',
|
||||||
|
parameterSize: '8B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'huihui_ai/qwen3-abliterated:14b',
|
||||||
|
label: 'Qwen3 Uncensored 14B',
|
||||||
|
parameterSize: '14B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'huihui_ai/qwen3-abliterated:30b',
|
||||||
|
label: 'Qwen3 Uncensored 30B',
|
||||||
|
parameterSize: '30B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'huihui_ai/qwen3-abliterated:32b',
|
||||||
|
label: 'Qwen3 Uncensored 32B',
|
||||||
|
parameterSize: '32B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: true
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -67,8 +196,20 @@ export const models: ModelInfo[] = [
|
||||||
descriptionEn: 'QwQ is the reasoning model of the Qwen series.',
|
descriptionEn: 'QwQ is the reasoning model of the Qwen series.',
|
||||||
descriptionPt: 'QwQ é o modelo de raciocínio da série Qwen.',
|
descriptionPt: 'QwQ é o modelo de raciocínio da série Qwen.',
|
||||||
models: [
|
models: [
|
||||||
{ name: 'qwq:32b', label: 'QwQ 32B', parameterSize: '32B' },
|
{
|
||||||
{ name: 'huihui_ai/qwq-abliterated:32b', label: 'QwQ Uncensored 32B', parameterSize: '32B' },
|
name: 'qwq:32b',
|
||||||
|
label: 'QwQ 32B',
|
||||||
|
parameterSize: '32B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'huihui_ai/qwq-abliterated:32b',
|
||||||
|
label: 'QwQ Uncensored 32B',
|
||||||
|
parameterSize: '32B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: true
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -77,7 +218,13 @@ export const models: ModelInfo[] = [
|
||||||
descriptionEn: 'The latest collection of multimodal models from Meta.',
|
descriptionEn: 'The latest collection of multimodal models from Meta.',
|
||||||
descriptionPt: 'A coleção mais recente de modelos multimodais da Meta.',
|
descriptionPt: 'A coleção mais recente de modelos multimodais da Meta.',
|
||||||
models: [
|
models: [
|
||||||
{ name: 'llama4:scout', label: 'Llama4 109B A17B', parameterSize: '109B' },
|
{
|
||||||
|
name: 'llama4:scout',
|
||||||
|
label: 'Llama4 109B A17B',
|
||||||
|
parameterSize: '109B',
|
||||||
|
thinking: false,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -86,14 +233,55 @@ export const models: ModelInfo[] = [
|
||||||
descriptionEn: 'DeepSeek is a research model for reasoning tasks.',
|
descriptionEn: 'DeepSeek is a research model for reasoning tasks.',
|
||||||
descriptionPt: 'DeepSeek é um modelo de pesquisa para tarefas de raciocínio.',
|
descriptionPt: 'DeepSeek é um modelo de pesquisa para tarefas de raciocínio.',
|
||||||
models: [
|
models: [
|
||||||
{ name: 'deepseek-r1:1.5b', label: 'DeepSeek 1.5B', parameterSize: '1.5B' },
|
{
|
||||||
{ name: 'deepseek-r1:7b', label: 'DeepSeek 7B', parameterSize: '7B' },
|
name: 'deepseek-r1:1.5b',
|
||||||
{ name: 'deepseek-r1:8b', label: 'DeepSeek 8B', parameterSize: '8B' },
|
label: 'DeepSeek 1.5B',
|
||||||
{ name: 'deepseek-r1:14b', label: 'DeepSeek 14B', parameterSize: '14B' },
|
parameterSize: '1.5B',
|
||||||
{ name: 'huihui_ai/deepseek-r1-abliterated:1.5b', label: 'DeepSeek Uncensored 1.5B', parameterSize: '1.5B' },
|
thinking: true,
|
||||||
{ name: 'huihui_ai/deepseek-r1-abliterated:7b', label: 'DeepSeek Uncensored 7B', parameterSize: '7B' },
|
uncensored: false
|
||||||
{ name: 'huihui_ai/deepseek-r1-abliterated:8b', label: 'DeepSeek Uncensored 8B', parameterSize: '8B' },
|
},
|
||||||
{ name: 'huihui_ai/deepseek-r1-abliterated:14b', label: 'DeepSeek Uncensored 14B', parameterSize: '14B' },
|
{
|
||||||
|
name: 'deepseek-r1:7b',
|
||||||
|
label: 'DeepSeek 7B',
|
||||||
|
parameterSize: '7B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'deepseek-r1:8b',
|
||||||
|
label: 'DeepSeek 8B',
|
||||||
|
parameterSize: '8B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'huihui_ai/deepseek-r1-abliterated:1.5b',
|
||||||
|
label: 'DeepSeek Uncensored 1.5B',
|
||||||
|
parameterSize: '1.5B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'huihui_ai/deepseek-r1-abliterated:7b',
|
||||||
|
label: 'DeepSeek Uncensored 7B',
|
||||||
|
parameterSize: '7B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'huihui_ai/deepseek-r1-abliterated:8b',
|
||||||
|
label: 'DeepSeek Uncensored 8B',
|
||||||
|
parameterSize: '8B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'huihui_ai/deepseek-r1-abliterated:14b',
|
||||||
|
label: 'DeepSeek Uncensored 14B',
|
||||||
|
parameterSize: '14B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: true
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -102,8 +290,20 @@ export const models: ModelInfo[] = [
|
||||||
descriptionEn: 'Hermes 3 is the latest version of the flagship Hermes series of LLMs by Nous Research.',
|
descriptionEn: 'Hermes 3 is the latest version of the flagship Hermes series of LLMs by Nous Research.',
|
||||||
descriptionPt: 'Hermes 3 é a versão mais recente da série Hermes de LLMs da Nous Research.',
|
descriptionPt: 'Hermes 3 é a versão mais recente da série Hermes de LLMs da Nous Research.',
|
||||||
models: [
|
models: [
|
||||||
{ name: 'hermes3:3b', label: 'Hermes3 3B', parameterSize: '3B' },
|
{
|
||||||
{ name: 'hermes3:8b', label: 'Hermes3 8B', parameterSize: '8B' },
|
name: 'hermes3:3b',
|
||||||
|
label: 'Hermes3 3B',
|
||||||
|
parameterSize: '3B',
|
||||||
|
thinking: false,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'hermes3:8b',
|
||||||
|
label: 'Hermes3 8B',
|
||||||
|
parameterSize: '8B',
|
||||||
|
thinking: false,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -112,7 +312,13 @@ export const models: ModelInfo[] = [
|
||||||
descriptionEn: 'The 7B model released by Mistral AI, updated to version 0.3.',
|
descriptionEn: 'The 7B model released by Mistral AI, updated to version 0.3.',
|
||||||
descriptionPt: 'O modelo 7B lançado pela Mistral AI, atualizado para a versão 0.3.',
|
descriptionPt: 'O modelo 7B lançado pela Mistral AI, atualizado para a versão 0.3.',
|
||||||
models: [
|
models: [
|
||||||
{ name: 'mistral:7b', label: 'Mistral 7B', parameterSize: '7B' },
|
{
|
||||||
|
name: 'mistral:7b',
|
||||||
|
label: 'Mistral 7B',
|
||||||
|
parameterSize: '7B',
|
||||||
|
thinking: false,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -121,10 +327,34 @@ export const models: ModelInfo[] = [
|
||||||
descriptionEn: 'Phi-4 is a 14B parameter, state-of-the-art open model from Microsoft. ',
|
descriptionEn: 'Phi-4 is a 14B parameter, state-of-the-art open model from Microsoft. ',
|
||||||
descriptionPt: 'Phi-4 é um modelo de 14B de última geração, aberto pela Microsoft.',
|
descriptionPt: 'Phi-4 é um modelo de 14B de última geração, aberto pela Microsoft.',
|
||||||
models: [
|
models: [
|
||||||
{ name: 'hf.co/unsloth/Phi-4-mini-reasoning-GGUF', label: 'Phi4 Mini Reasoning', parameterSize: '4B' },
|
{
|
||||||
{ name: 'phi4:14b', label: 'Phi4 14B', parameterSize: '14B' },
|
name: 'hf.co/unsloth/Phi-4-mini-reasoning-GGUF',
|
||||||
{ name: 'hf.co/unsloth/Phi-4-reasoning-plus-GGUF', label: 'Phi4 Reasoning Plus', parameterSize: '14B' },
|
label: 'Phi4 Mini Reasoning',
|
||||||
{ name: 'huihui_ai/phi4-abliterated:14b', label: 'Phi4 Uncensored 14B', parameterSize: '14B' },
|
parameterSize: '4B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'phi4:14b',
|
||||||
|
label: 'Phi4 14B',
|
||||||
|
parameterSize: '14B',
|
||||||
|
thinking: false,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'hf.co/unsloth/Phi-4-reasoning-plus-GGUF',
|
||||||
|
label: 'Phi4 Reasoning Plus',
|
||||||
|
parameterSize: '14B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'huihui_ai/phi4-abliterated:14b',
|
||||||
|
label: 'Phi4 Uncensored 14B',
|
||||||
|
parameterSize: '14B',
|
||||||
|
thinking: false,
|
||||||
|
uncensored: true
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -133,7 +363,13 @@ export const models: ModelInfo[] = [
|
||||||
descriptionEn: 'Phi-3 is a family of lightweight 3B (Mini) and 14B (Medium) state-of-the-art open models by Microsoft.',
|
descriptionEn: 'Phi-3 is a family of lightweight 3B (Mini) and 14B (Medium) state-of-the-art open models by Microsoft.',
|
||||||
descriptionPt: 'Phi-3 é uma família de modelos leves de 3B (Mini) e 14B (Médio) de última geração, abertos pela Microsoft.',
|
descriptionPt: 'Phi-3 é uma família de modelos leves de 3B (Mini) e 14B (Médio) de última geração, abertos pela Microsoft.',
|
||||||
models: [
|
models: [
|
||||||
{ name: 'phi3:3.8b', label: 'Phi3 3.8B', parameterSize: '3.8B' },
|
{
|
||||||
|
name: 'phi3:3.8b',
|
||||||
|
label: 'Phi3 3.8B',
|
||||||
|
parameterSize: '3.8B',
|
||||||
|
thinking: false,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -142,7 +378,13 @@ export const models: ModelInfo[] = [
|
||||||
descriptionEn: 'Llama 3, a lightweight model from Meta.',
|
descriptionEn: 'Llama 3, a lightweight model from Meta.',
|
||||||
descriptionPt: 'Llama 3, um modelo leve da Meta.',
|
descriptionPt: 'Llama 3, um modelo leve da Meta.',
|
||||||
models: [
|
models: [
|
||||||
{ name: 'llama3:8b', label: 'Llama3 8B', parameterSize: '8B' },
|
{
|
||||||
|
name: 'llama3:8b',
|
||||||
|
label: 'Llama3 8B',
|
||||||
|
parameterSize: '8B',
|
||||||
|
thinking: false,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -151,7 +393,13 @@ export const models: ModelInfo[] = [
|
||||||
descriptionEn: 'Ablitered v3 llama-3.1 8b with uncensored prompt ',
|
descriptionEn: 'Ablitered v3 llama-3.1 8b with uncensored prompt ',
|
||||||
descriptionPt: 'Llama3.1 é um modelo aberto, leve e para dispositivos locais, com prompt não censurado.',
|
descriptionPt: 'Llama3.1 é um modelo aberto, leve e para dispositivos locais, com prompt não censurado.',
|
||||||
models: [
|
models: [
|
||||||
{ name: 'mannix/llama3.1-8b-abliterated:latest', label: 'Llama3.1 8B', parameterSize: '8B' },
|
{
|
||||||
|
name: 'mannix/llama3.1-8b-abliterated:latest',
|
||||||
|
label: 'Llama3.1 8B',
|
||||||
|
parameterSize: '8B',
|
||||||
|
thinking: false,
|
||||||
|
uncensored: true
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -160,9 +408,27 @@ export const models: ModelInfo[] = [
|
||||||
descriptionEn: 'Llama3.2 is a family of open, lightweight models for general tasks.',
|
descriptionEn: 'Llama3.2 is a family of open, lightweight models for general tasks.',
|
||||||
descriptionPt: 'Llama3.2 é uma família de modelos abertos, leves e para dispositivos locais, para tarefas gerais.',
|
descriptionPt: 'Llama3.2 é uma família de modelos abertos, leves e para dispositivos locais, para tarefas gerais.',
|
||||||
models: [
|
models: [
|
||||||
{ name: 'llama3.2:1b', label: 'Llama3.2 1B', parameterSize: '1B' },
|
{
|
||||||
{ name: 'llama3.2:3b', label: 'Llama3.2 3B', parameterSize: '3B' },
|
name: 'llama3.2:1b',
|
||||||
{ name: 'socialnetwooky/llama3.2-abliterated:3b_q8_0', label: 'Llama3.2 Uncensored 3B', parameterSize: '3B' },
|
label: 'Llama3.2 1B',
|
||||||
|
parameterSize: '1B',
|
||||||
|
thinking: false,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'llama3.2:3b',
|
||||||
|
label: 'Llama3.2 3B',
|
||||||
|
parameterSize: '3B',
|
||||||
|
thinking: false,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'socialnetwooky/llama3.2-abliterated:3b_q8_0',
|
||||||
|
label: 'Llama3.2 Uncensored 3B',
|
||||||
|
parameterSize: '3B',
|
||||||
|
thinking: false,
|
||||||
|
uncensored: true
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
];
|
];
|
0
config/settings.ts
Normal file → Executable file
0
config/settings.ts
Normal file → Executable file
52
database/schema.ts
Executable file
52
database/schema.ts
Executable file
|
@ -0,0 +1,52 @@
|
||||||
|
import {
|
||||||
|
integer,
|
||||||
|
pgTable,
|
||||||
|
varchar,
|
||||||
|
timestamp,
|
||||||
|
boolean,
|
||||||
|
real,
|
||||||
|
index
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
export const usersTable = pgTable("users", {
|
||||||
|
telegramId: varchar({ length: 255 }).notNull().primaryKey(),
|
||||||
|
username: varchar({ length: 255 }).notNull(),
|
||||||
|
firstName: varchar({ length: 255 }).notNull(),
|
||||||
|
lastName: varchar({ length: 255 }).notNull(),
|
||||||
|
aiEnabled: boolean().notNull().default(false),
|
||||||
|
showThinking: boolean().notNull().default(false),
|
||||||
|
customAiModel: varchar({ length: 255 }).notNull().default("deepseek-r1:1.5b"),
|
||||||
|
aiTemperature: real().notNull().default(0.9),
|
||||||
|
aiRequests: integer().notNull().default(0),
|
||||||
|
aiCharacters: integer().notNull().default(0),
|
||||||
|
disabledCommands: varchar({ length: 255 }).array().notNull().default([]),
|
||||||
|
languageCode: varchar({ length: 255 }).notNull(),
|
||||||
|
aiTimeoutUntil: timestamp(),
|
||||||
|
aiMaxExecutionTime: integer().default(0),
|
||||||
|
createdAt: timestamp().notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp().notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const twoFactorTable = pgTable("two_factor", {
|
||||||
|
userId: varchar({ length: 255 }).notNull().references(() => usersTable.telegramId).primaryKey(),
|
||||||
|
currentCode: varchar({ length: 255 }).notNull(),
|
||||||
|
codeExpiresAt: timestamp().notNull(),
|
||||||
|
codeAttempts: integer().notNull().default(0),
|
||||||
|
createdAt: timestamp().notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp().notNull().defaultNow(),
|
||||||
|
}, (table) => [
|
||||||
|
index("idx_two_factor_user_id").on(table.userId),
|
||||||
|
index("idx_two_factor_code_expires_at").on(table.codeExpiresAt),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const sessionsTable = pgTable("sessions", {
|
||||||
|
id: varchar({ length: 255 }).notNull().primaryKey(),
|
||||||
|
userId: varchar({ length: 255 }).notNull().references(() => usersTable.telegramId),
|
||||||
|
sessionToken: varchar({ length: 255 }).notNull().unique(),
|
||||||
|
expiresAt: timestamp().notNull(),
|
||||||
|
createdAt: timestamp().notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp().notNull().defaultNow(),
|
||||||
|
}, (table) => [
|
||||||
|
index("idx_sessions_user_id").on(table.userId),
|
||||||
|
index("idx_sessions_expires_at").on(table.expiresAt),
|
||||||
|
]);
|
13
docker-compose.yml.ai.example
Normal file → Executable file
13
docker-compose.yml.ai.example
Normal file → Executable file
|
@ -2,23 +2,26 @@ services:
|
||||||
kowalski:
|
kowalski:
|
||||||
build: .
|
build: .
|
||||||
container_name: kowalski
|
container_name: kowalski
|
||||||
restart: unless-stopped
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./.env:/usr/src/app/.env:ro
|
- ./.env:/usr/src/app/.env:ro
|
||||||
|
- ./telegram/props/lastfm.json:/usr/src/app/telegram/props/lastfm.json
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
- ollama
|
||||||
ollama:
|
ollama:
|
||||||
image: ollama/ollama
|
image: ollama/ollama
|
||||||
container_name: kowalski-ollama
|
container_name: kowalski-ollama
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./ollama:/root/.ollama
|
- ./ollama:/root/.ollama
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:17
|
image: postgres:17
|
||||||
container_name: kowalski-postgres
|
container_name: kowalski-postgres
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- 5433:5432
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./db:/var/lib/postgresql/data
|
- ./db:/var/lib/postgresql/data
|
||||||
environment:
|
environment:
|
||||||
|
|
11
docker-compose.yml.example
Normal file → Executable file
11
docker-compose.yml.example
Normal file → Executable file
|
@ -2,17 +2,20 @@ services:
|
||||||
kowalski:
|
kowalski:
|
||||||
build: .
|
build: .
|
||||||
container_name: kowalski
|
container_name: kowalski
|
||||||
restart: unless-stopped
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./.env:/usr/src/app/.env:ro
|
- ./.env:/usr/src/app/.env:ro
|
||||||
|
- ./telegram/props/lastfm.json:/usr/src/app/telegram/props/lastfm.json
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:17
|
image: postgres:17
|
||||||
container_name: kowalski-postgres
|
container_name: kowalski-postgres
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- 5433:5432
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./db:/var/lib/postgresql/data
|
- ./db:/var/lib/postgresql/data
|
||||||
environment:
|
environment:
|
||||||
|
|
2
drizzle.config.ts
Normal file → Executable file
2
drizzle.config.ts
Normal file → Executable file
|
@ -3,7 +3,7 @@ import { defineConfig } from 'drizzle-kit';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
out: './drizzle',
|
out: './drizzle',
|
||||||
schema: './src/db/schema.ts',
|
schema: './database/schema.ts',
|
||||||
dialect: 'postgresql',
|
dialect: 'postgresql',
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url: process.env.databaseUrl!,
|
url: process.env.databaseUrl!,
|
||||||
|
|
6
nodemon.json
Normal file → Executable file
6
nodemon.json
Normal file → Executable file
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"ignore": ["src/props/*.json", "src/props/*.txt"],
|
"ignore": ["telegram/props/*.json", "telegram/props/*.txt"],
|
||||||
"watch": ["src", "config"],
|
"watch": ["telegram", "database", "config"],
|
||||||
"ext": "ts,js",
|
"ext": "ts,js",
|
||||||
"exec": "bun src/bot.ts"
|
"exec": "bun telegram/bot.ts"
|
||||||
}
|
}
|
3
package.json
Normal file → Executable file
3
package.json
Normal file → Executable file
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "nodemon src/bot.ts",
|
"start": "nodemon telegram/bot.ts",
|
||||||
"docs": "bunx typedoc",
|
"docs": "bunx typedoc",
|
||||||
"serve:docs": "bun run serve-docs.ts"
|
"serve:docs": "bun run serve-docs.ts"
|
||||||
},
|
},
|
||||||
|
@ -10,6 +10,7 @@
|
||||||
"axios": "^1.10.0",
|
"axios": "^1.10.0",
|
||||||
"dotenv": "^17.0.0",
|
"dotenv": "^17.0.0",
|
||||||
"drizzle-orm": "^0.44.2",
|
"drizzle-orm": "^0.44.2",
|
||||||
|
"express": "^5.1.0",
|
||||||
"node-html-parser": "^7.0.1",
|
"node-html-parser": "^7.0.1",
|
||||||
"nodemon": "^3.1.10",
|
"nodemon": "^3.1.10",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
|
|
|
@ -1,627 +0,0 @@
|
||||||
// AI.TS
|
|
||||||
// by ihatenodejs/Aidan
|
|
||||||
//
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
//
|
|
||||||
// Anyone is free to copy, modify, publish, use, compile, sell, or
|
|
||||||
// distribute this software, either in source code form or as a compiled
|
|
||||||
// binary, for any purpose, commercial or non-commercial, and by any
|
|
||||||
// means.
|
|
||||||
//
|
|
||||||
// In jurisdictions that recognize copyright laws, the author or authors
|
|
||||||
// of this software dedicate any and all copyright interest in the
|
|
||||||
// software to the public domain. We make this dedication for the benefit
|
|
||||||
// of the public at large and to the detriment of our heirs and
|
|
||||||
// successors. We intend this dedication to be an overt act of
|
|
||||||
// relinquishment in perpetuity of all present and future rights to this
|
|
||||||
// software under copyright law.
|
|
||||||
//
|
|
||||||
// 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 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.
|
|
||||||
//
|
|
||||||
// For more information, please refer to <https://unlicense.org/>
|
|
||||||
|
|
||||||
import { isOnSpamWatch } from "../spamwatch/spamwatch"
|
|
||||||
import spamwatchMiddlewareModule from "../spamwatch/Middleware"
|
|
||||||
import { Telegraf, Context } from "telegraf"
|
|
||||||
import type { Message } from "telegraf/types"
|
|
||||||
import { replyToMessageId } from "../utils/reply-to-message-id"
|
|
||||||
import { getStrings } from "../plugins/checklang"
|
|
||||||
import axios from "axios"
|
|
||||||
import { rateLimiter } from "../utils/rate-limiter"
|
|
||||||
import { logger } from "../utils/log"
|
|
||||||
import { ensureUserInDb } from "../utils/ensure-user"
|
|
||||||
import * as schema from '../db/schema'
|
|
||||||
import type { NodePgDatabase } from "drizzle-orm/node-postgres"
|
|
||||||
import { eq, sql } from 'drizzle-orm'
|
|
||||||
import { models, unloadModelAfterB, maxUserQueueSize } from "../../config/ai"
|
|
||||||
|
|
||||||
const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch)
|
|
||||||
export const flash_model = process.env.flashModel || "gemma3:4b"
|
|
||||||
export const thinking_model = process.env.thinkingModel || "qwen3:4b"
|
|
||||||
|
|
||||||
type TextContext = Context & { message: Message.TextMessage }
|
|
||||||
|
|
||||||
type User = typeof schema.usersTable.$inferSelect
|
|
||||||
|
|
||||||
export interface ModelInfo {
|
|
||||||
name: string;
|
|
||||||
label: string;
|
|
||||||
descriptionEn: string;
|
|
||||||
descriptionPt: string;
|
|
||||||
models: Array<{
|
|
||||||
name: string;
|
|
||||||
label: string;
|
|
||||||
parameterSize: string;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OllamaResponse {
|
|
||||||
response: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function usingSystemPrompt(ctx: TextContext, db: NodePgDatabase<typeof schema>, botName: string, message: string): Promise<string> {
|
|
||||||
const user = await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(ctx.from!.id)), limit: 1 });
|
|
||||||
if (user.length === 0) await ensureUserInDb(ctx, db);
|
|
||||||
const userData = user[0];
|
|
||||||
const lang = userData?.languageCode || "en";
|
|
||||||
const Strings = getStrings(lang);
|
|
||||||
const utcDate = new Date().toISOString();
|
|
||||||
const prompt = Strings.ai.systemPrompt
|
|
||||||
.replace("{botName}", botName)
|
|
||||||
.replace("{date}", utcDate)
|
|
||||||
.replace("{message}", message);
|
|
||||||
return prompt;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sanitizeForJson(text: string): string {
|
|
||||||
return text
|
|
||||||
.replace(/\\/g, '\\\\')
|
|
||||||
.replace(/"/g, '\\"')
|
|
||||||
.replace(/\n/g, '\\n')
|
|
||||||
.replace(/\r/g, '\\r')
|
|
||||||
.replace(/\t/g, '\\t')
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeMarkdownForTelegram(text: string): string {
|
|
||||||
let sanitizedText = text;
|
|
||||||
|
|
||||||
const replacements: string[] = [];
|
|
||||||
const addReplacement = (match: string): string => {
|
|
||||||
replacements.push(match);
|
|
||||||
return `___PLACEHOLDER_${replacements.length - 1}___`;
|
|
||||||
};
|
|
||||||
|
|
||||||
sanitizedText = sanitizedText.replace(/```([\s\S]*?)```/g, addReplacement);
|
|
||||||
sanitizedText = sanitizedText.replace(/`([^`]+)`/g, addReplacement);
|
|
||||||
sanitizedText = sanitizedText.replace(/\[([^\]]+)\]\(([^)]+)\)/g, addReplacement);
|
|
||||||
|
|
||||||
const parts = sanitizedText.split(/(___PLACEHOLDER_\d+___)/g);
|
|
||||||
const processedParts = parts.map(part => {
|
|
||||||
if (part.match(/___PLACEHOLDER_\d+___/)) {
|
|
||||||
return part;
|
|
||||||
} else {
|
|
||||||
let processedPart = part;
|
|
||||||
processedPart = processedPart.replace(/^(#{1,6})\s+(.+)/gm, '*$2*');
|
|
||||||
processedPart = processedPart.replace(/^(\s*)[-*]\s+/gm, '$1- ');
|
|
||||||
processedPart = processedPart.replace(/\*\*(.*?)\*\*/g, '*$1*');
|
|
||||||
processedPart = processedPart.replace(/__(.*?)__/g, '*$1*');
|
|
||||||
processedPart = processedPart.replace(/(^|\s)\*(?!\*)([^*]+?)\*(?!\*)/g, '$1_$2_');
|
|
||||||
processedPart = processedPart.replace(/(^|\s)_(?!_)([^_]+?)_(?!_)/g, '$1_$2_');
|
|
||||||
processedPart = processedPart.replace(/~~(.*?)~~/g, '~$1~');
|
|
||||||
processedPart = processedPart.replace(/^\s*┃/gm, '>');
|
|
||||||
processedPart = processedPart.replace(/^>\s?/gm, '> ');
|
|
||||||
|
|
||||||
return processedPart;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
sanitizedText = processedParts.join('');
|
|
||||||
|
|
||||||
sanitizedText = sanitizedText.replace(/___PLACEHOLDER_(\d+)___/g, (_, idx) => replacements[Number(idx)]);
|
|
||||||
|
|
||||||
const codeBlockCount = (sanitizedText.match(/```/g) || []).length;
|
|
||||||
if (codeBlockCount % 2 !== 0) {
|
|
||||||
sanitizedText += '\n```';
|
|
||||||
}
|
|
||||||
|
|
||||||
return sanitizedText;
|
|
||||||
}
|
|
||||||
|
|
||||||
function processThinkingTags(text: string): string {
|
|
||||||
let processedText = text;
|
|
||||||
|
|
||||||
const firstThinkIndex = processedText.indexOf('<think>');
|
|
||||||
if (firstThinkIndex === -1) {
|
|
||||||
return processedText.replace(/<\/think>/g, '___THINK_END___');
|
|
||||||
}
|
|
||||||
|
|
||||||
processedText = processedText.substring(0, firstThinkIndex) + '___THINK_START___' + processedText.substring(firstThinkIndex + '<think>'.length);
|
|
||||||
const lastThinkEndIndex = processedText.lastIndexOf('</think>');
|
|
||||||
if (lastThinkEndIndex !== -1) {
|
|
||||||
processedText = processedText.substring(0, lastThinkEndIndex) + '___THEND___' + processedText.substring(lastThinkEndIndex + '</think>'.length);
|
|
||||||
}
|
|
||||||
processedText = processedText.replace(/<think>/g, '');
|
|
||||||
processedText = processedText.replace(/<\/think>/g, '');
|
|
||||||
processedText = processedText.replace('___THEND___', '___THINK_END___');
|
|
||||||
|
|
||||||
return processedText;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function preChecks() {
|
|
||||||
const envs = [
|
|
||||||
"ollamaApi",
|
|
||||||
"flashModel",
|
|
||||||
"thinkingModel",
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const env of envs) {
|
|
||||||
if (!process.env[env]) {
|
|
||||||
console.error(`[✨ AI | !] ❌ ${env} not set!`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ollamaApi = process.env.ollamaApi!;
|
|
||||||
let ollamaOk = false;
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
try {
|
|
||||||
const res = await axios.get(ollamaApi, { timeout: 2000 });
|
|
||||||
if (res.status === 200) {
|
|
||||||
ollamaOk = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (i < 9) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ollamaOk) {
|
|
||||||
console.error(`[✨ AI | !] ❌ Ollama API is not responding at ${ollamaApi}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[✨ AI] Pre-checks passed.`);
|
|
||||||
const modelCount = models.reduce((acc, model) => acc + model.models.length, 0);
|
|
||||||
console.log(`[✨ AI] Found ${modelCount} models.`);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAxiosError(error: unknown): error is { response?: { data?: { error?: string }, status?: number, statusText?: string }, request?: unknown, message?: string } {
|
|
||||||
return typeof error === 'object' && error !== null && (
|
|
||||||
'response' in error || 'request' in error || 'message' in error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractAxiosErrorMessage(error: unknown): string {
|
|
||||||
if (isAxiosError(error)) {
|
|
||||||
const err = error as { response?: { data?: { error?: string }, status?: number, statusText?: string }, request?: unknown, message?: string };
|
|
||||||
if (err.response && typeof err.response === 'object') {
|
|
||||||
const resp = err.response;
|
|
||||||
if (resp.data && typeof resp.data === 'object' && 'error' in resp.data) {
|
|
||||||
return String(resp.data.error);
|
|
||||||
}
|
|
||||||
if ('status' in resp && 'statusText' in resp) {
|
|
||||||
return `HTTP ${resp.status}: ${resp.statusText}`;
|
|
||||||
}
|
|
||||||
return JSON.stringify(resp.data ?? resp);
|
|
||||||
}
|
|
||||||
if (err.request) {
|
|
||||||
return 'No response received from server.';
|
|
||||||
}
|
|
||||||
if (typeof err.message === 'string') {
|
|
||||||
return err.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 'An unexpected error occurred.';
|
|
||||||
}
|
|
||||||
|
|
||||||
function containsUrls(text: string): boolean {
|
|
||||||
return text.includes('http://') || text.includes('https://') || text.includes('.com') || text.includes('.net') || text.includes('.org') || text.includes('.io') || text.includes('.ai') || text.includes('.dev')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Message, model: string, aiTemperature: number, originalMessage: string, db: NodePgDatabase<typeof schema>, userId: string, Strings: ReturnType<typeof getStrings>, showThinking: boolean): Promise<{ success: boolean; response?: string; error?: string, messageType?: 'generation' | 'system' }> {
|
|
||||||
if (!ctx.chat) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: Strings.unexpectedErr.replace("{error}", Strings.ai.noChatFound),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const cleanedModelName = model.includes('/') ? model.split('/').pop()! : model;
|
|
||||||
let status = Strings.ai.statusWaitingRender;
|
|
||||||
let modelHeader = Strings.ai.modelHeader
|
|
||||||
.replace("{model}", `\`${cleanedModelName}\``)
|
|
||||||
.replace("{temperature}", String(aiTemperature))
|
|
||||||
.replace("{status}", status) + "\n\n";
|
|
||||||
|
|
||||||
const promptCharCount = originalMessage.length;
|
|
||||||
await db.update(schema.usersTable)
|
|
||||||
.set({ aiCharacters: sql`${schema.usersTable.aiCharacters} + ${promptCharCount}` })
|
|
||||||
.where(eq(schema.usersTable.telegramId, userId));
|
|
||||||
const paramSizeStr = models.find(m => m.name === model)?.models.find(m => m.name === model)?.parameterSize?.replace('B', '');
|
|
||||||
const shouldKeepAlive = paramSizeStr ? Number(paramSizeStr) > unloadModelAfterB : false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const aiResponse = await axios.post<unknown>(
|
|
||||||
`${process.env.ollamaApi}/api/generate`,
|
|
||||||
{
|
|
||||||
model,
|
|
||||||
prompt,
|
|
||||||
stream: true,
|
|
||||||
keep_alive: shouldKeepAlive ? '1' : '0',
|
|
||||||
options: {
|
|
||||||
temperature: aiTemperature
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
responseType: "stream",
|
|
||||||
}
|
|
||||||
);
|
|
||||||
let fullResponse = "";
|
|
||||||
let lastUpdateCharCount = 0;
|
|
||||||
let sentHeader = false;
|
|
||||||
let firstChunk = true;
|
|
||||||
const stream: NodeJS.ReadableStream = aiResponse.data as any;
|
|
||||||
|
|
||||||
const formatThinkingMessage = (text: string) => {
|
|
||||||
const withPlaceholders = text
|
|
||||||
.replace(/___THINK_START___/g, `${Strings.ai.thinking}`)
|
|
||||||
.replace(/___THINK_END___/g, `${Strings.ai.finishedThinking}`);
|
|
||||||
return sanitizeMarkdownForTelegram(withPlaceholders);
|
|
||||||
};
|
|
||||||
|
|
||||||
for await (const chunk of stream) {
|
|
||||||
const lines = chunk.toString().split('\n');
|
|
||||||
for (const line of lines) {
|
|
||||||
if (!line.trim()) continue;
|
|
||||||
let ln: OllamaResponse;
|
|
||||||
try {
|
|
||||||
ln = JSON.parse(line);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("[✨ AI | !] Error parsing chunk");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ln.response) {
|
|
||||||
if (ln.response.includes('<think>')) {
|
|
||||||
const thinkMatch = ln.response.match(/<think>([\s\S]*?)<\/think>/);
|
|
||||||
if (thinkMatch && thinkMatch[1].trim().length > 0) {
|
|
||||||
logger.logThinking(ctx.chat.id, replyGenerating.message_id, true);
|
|
||||||
} else if (!thinkMatch) {
|
|
||||||
logger.logThinking(ctx.chat.id, replyGenerating.message_id, true);
|
|
||||||
}
|
|
||||||
} else if (ln.response.includes('</think>')) {
|
|
||||||
logger.logThinking(ctx.chat.id, replyGenerating.message_id, false);
|
|
||||||
}
|
|
||||||
fullResponse += ln.response;
|
|
||||||
if (showThinking) {
|
|
||||||
let displayResponse = processThinkingTags(fullResponse);
|
|
||||||
|
|
||||||
if (firstChunk) {
|
|
||||||
status = Strings.ai.statusWaitingRender;
|
|
||||||
modelHeader = Strings.ai.modelHeader
|
|
||||||
.replace("{model}", `\`${cleanedModelName}\``)
|
|
||||||
.replace("{temperature}", aiTemperature)
|
|
||||||
.replace("{status}", status) + "\n\n";
|
|
||||||
await rateLimiter.editMessageWithRetry(
|
|
||||||
ctx,
|
|
||||||
ctx.chat.id,
|
|
||||||
replyGenerating.message_id,
|
|
||||||
modelHeader + formatThinkingMessage(displayResponse),
|
|
||||||
{ parse_mode: 'Markdown' }
|
|
||||||
);
|
|
||||||
lastUpdateCharCount = displayResponse.length;
|
|
||||||
sentHeader = true;
|
|
||||||
firstChunk = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const updateEveryChars = Number(process.env.updateEveryChars) || 100;
|
|
||||||
if (displayResponse.length - lastUpdateCharCount >= updateEveryChars || !sentHeader) {
|
|
||||||
await rateLimiter.editMessageWithRetry(
|
|
||||||
ctx,
|
|
||||||
ctx.chat.id,
|
|
||||||
replyGenerating.message_id,
|
|
||||||
modelHeader + formatThinkingMessage(displayResponse),
|
|
||||||
{ parse_mode: 'Markdown' }
|
|
||||||
);
|
|
||||||
lastUpdateCharCount = displayResponse.length;
|
|
||||||
sentHeader = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
status = Strings.ai.statusRendering;
|
|
||||||
modelHeader = Strings.ai.modelHeader
|
|
||||||
.replace("{model}", `\`${cleanedModelName}\``)
|
|
||||||
.replace("{temperature}", aiTemperature)
|
|
||||||
.replace("{status}", status) + "\n\n";
|
|
||||||
|
|
||||||
if (showThinking) {
|
|
||||||
let displayResponse = processThinkingTags(fullResponse);
|
|
||||||
|
|
||||||
await rateLimiter.editMessageWithRetry(
|
|
||||||
ctx,
|
|
||||||
ctx.chat.id,
|
|
||||||
replyGenerating.message_id,
|
|
||||||
modelHeader + formatThinkingMessage(displayResponse),
|
|
||||||
{ parse_mode: 'Markdown' }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const responseCharCount = fullResponse.length;
|
|
||||||
await db.update(schema.usersTable)
|
|
||||||
.set({
|
|
||||||
aiCharacters: sql`${schema.usersTable.aiCharacters} + ${responseCharCount}`,
|
|
||||||
aiRequests: sql`${schema.usersTable.aiRequests} + 1`
|
|
||||||
})
|
|
||||||
.where(eq(schema.usersTable.telegramId, userId));
|
|
||||||
|
|
||||||
const patchedResponse = processThinkingTags(fullResponse);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
response: patchedResponse,
|
|
||||||
messageType: 'generation'
|
|
||||||
};
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const errorMsg = extractAxiosErrorMessage(error);
|
|
||||||
console.error("[✨ AI | !] Error:", errorMsg);
|
|
||||||
if (isAxiosError(error) && error.response && typeof error.response === 'object') {
|
|
||||||
const resp = error.response as { data?: { error?: string }, status?: number };
|
|
||||||
const errData = resp.data && typeof resp.data === 'object' && 'error' in resp.data ? (resp.data as { error?: string }).error : undefined;
|
|
||||||
const errStatus = 'status' in resp ? resp.status : undefined;
|
|
||||||
if ((typeof errData === 'string' && errData.includes(`model '${model}' not found`)) || errStatus === 404) {
|
|
||||||
await ctx.telegram.editMessageText(
|
|
||||||
ctx.chat!.id,
|
|
||||||
replyGenerating.message_id,
|
|
||||||
undefined,
|
|
||||||
Strings.ai.pulling.replace("{model}", `\`${cleanedModelName}\``),
|
|
||||||
{ parse_mode: 'Markdown' }
|
|
||||||
);
|
|
||||||
console.log(`[✨ AI] Pulling ${model} from ollama...`);
|
|
||||||
try {
|
|
||||||
await axios.post(
|
|
||||||
`${process.env.ollamaApi}/api/pull`,
|
|
||||||
{
|
|
||||||
model,
|
|
||||||
stream: false,
|
|
||||||
timeout: Number(process.env.ollamaApiTimeout) || 10000,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const pullMsg = extractAxiosErrorMessage(e);
|
|
||||||
console.error("[✨ AI | !] Pull error:", pullMsg);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: `❌ Something went wrong while pulling \`${model}\`: ${pullMsg}`,
|
|
||||||
messageType: 'system'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
console.log(`[✨ AI] ${model} pulled successfully`);
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
response: Strings.ai.pulled.replace("{model}", `\`${cleanedModelName}\``),
|
|
||||||
messageType: 'system'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: errorMsg,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAiReply(ctx: TextContext, model: string, prompt: string, replyGenerating: Message, aiTemperature: number, originalMessage: string, db: NodePgDatabase<typeof schema>, userId: string, Strings: ReturnType<typeof getStrings>, showThinking: boolean) {
|
|
||||||
const aiResponse = await getResponse(prompt, ctx, replyGenerating, model, aiTemperature, originalMessage, db, userId, Strings, showThinking);
|
|
||||||
if (!aiResponse) return;
|
|
||||||
if (!ctx.chat) return;
|
|
||||||
if (aiResponse.success && aiResponse.response) {
|
|
||||||
if (aiResponse.messageType === 'system') {
|
|
||||||
await rateLimiter.editMessageWithRetry(
|
|
||||||
ctx,
|
|
||||||
ctx.chat.id,
|
|
||||||
replyGenerating.message_id,
|
|
||||||
aiResponse.response,
|
|
||||||
{ parse_mode: 'Markdown' }
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cleanedModelName = model.includes('/') ? model.split('/').pop()! : model;
|
|
||||||
const status = Strings.ai.statusComplete;
|
|
||||||
const modelHeader = Strings.ai.modelHeader
|
|
||||||
.replace("{model}", `\`${cleanedModelName}\``)
|
|
||||||
.replace("{temperature}", String(aiTemperature))
|
|
||||||
.replace("{status}", status) + "\n\n";
|
|
||||||
const urlWarning = containsUrls(originalMessage) ? Strings.ai.urlWarning : '';
|
|
||||||
let finalResponse = aiResponse.response;
|
|
||||||
if (showThinking) {
|
|
||||||
finalResponse = finalResponse.replace(/___THINK_START___/g, `${Strings.ai.thinking}`)
|
|
||||||
.replace(/___THINK_END___/g, `${Strings.ai.finishedThinking}`);
|
|
||||||
} else {
|
|
||||||
finalResponse = finalResponse.replace(/___THINK_START___[\s\S]*?___THINK_END___/g, '').trim();
|
|
||||||
finalResponse = finalResponse.replace(/___THINK_START___[\s\S]*/g, '').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
await rateLimiter.editMessageWithRetry(
|
|
||||||
ctx,
|
|
||||||
ctx.chat.id,
|
|
||||||
replyGenerating.message_id,
|
|
||||||
modelHeader + sanitizeMarkdownForTelegram(finalResponse) + urlWarning,
|
|
||||||
{ parse_mode: 'Markdown' }
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const error = Strings.unexpectedErr.replace("{error}", aiResponse.error);
|
|
||||||
await rateLimiter.editMessageWithRetry(
|
|
||||||
ctx,
|
|
||||||
ctx.chat.id,
|
|
||||||
replyGenerating.message_id,
|
|
||||||
error,
|
|
||||||
{ parse_mode: 'Markdown' }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getUserWithStringsAndModel(ctx: Context, db: NodePgDatabase<typeof schema>): Promise<{ user: User; Strings: ReturnType<typeof getStrings>; languageCode: string; customAiModel: string; aiTemperature: number, showThinking: boolean }> {
|
|
||||||
const userArr = await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(ctx.from!.id)), limit: 1 });
|
|
||||||
let user = userArr[0];
|
|
||||||
if (!user) {
|
|
||||||
await ensureUserInDb(ctx, db);
|
|
||||||
const newUserArr = await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(ctx.from!.id)), limit: 1 });
|
|
||||||
user = newUserArr[0];
|
|
||||||
const Strings = getStrings(user.languageCode);
|
|
||||||
return { user, Strings, languageCode: user.languageCode, customAiModel: user.customAiModel, aiTemperature: user.aiTemperature, showThinking: user.showThinking };
|
|
||||||
}
|
|
||||||
const Strings = getStrings(user.languageCode);
|
|
||||||
return { user, Strings, languageCode: user.languageCode, customAiModel: user.customAiModel, aiTemperature: user.aiTemperature, showThinking: user.showThinking };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getModelLabelByName(name: string): string {
|
|
||||||
for (const series of models) {
|
|
||||||
const found = series.models.find(m => m.name === name);
|
|
||||||
if (found) return found.label;
|
|
||||||
}
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default (bot: Telegraf<Context>, db: NodePgDatabase<typeof schema>) => {
|
|
||||||
const botName = bot.botInfo?.first_name && bot.botInfo?.last_name ? `${bot.botInfo.first_name} ${bot.botInfo.last_name}` : "Kowalski"
|
|
||||||
|
|
||||||
interface AiRequest {
|
|
||||||
task: () => Promise<void>;
|
|
||||||
ctx: TextContext;
|
|
||||||
wasQueued: boolean;
|
|
||||||
userId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestQueue: AiRequest[] = [];
|
|
||||||
let isProcessing = false;
|
|
||||||
|
|
||||||
async function processQueue() {
|
|
||||||
if (isProcessing || requestQueue.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isProcessing = true;
|
|
||||||
const { task, ctx, wasQueued } = requestQueue.shift()!;
|
|
||||||
const { Strings } = await getUserWithStringsAndModel(ctx, db);
|
|
||||||
const reply_to_message_id = replyToMessageId(ctx);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (wasQueued) {
|
|
||||||
await ctx.reply(Strings.ai.startingProcessing, {
|
|
||||||
...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }),
|
|
||||||
parse_mode: 'Markdown'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await task();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[✨ AI | !] Error processing task:", error);
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
||||||
await ctx.reply(Strings.unexpectedErr.replace("{error}", errorMessage), {
|
|
||||||
...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }),
|
|
||||||
parse_mode: 'Markdown'
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
isProcessing = false;
|
|
||||||
processQueue();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function aiCommandHandler(ctx: TextContext, command: 'ask' | 'think' | 'ai') {
|
|
||||||
const reply_to_message_id = replyToMessageId(ctx);
|
|
||||||
const { user, Strings, customAiModel, aiTemperature, showThinking } = await getUserWithStringsAndModel(ctx, db);
|
|
||||||
const message = ctx.message.text;
|
|
||||||
const author = ("@" + ctx.from?.username) || ctx.from?.first_name || "Unknown";
|
|
||||||
|
|
||||||
const model = command === 'ai'
|
|
||||||
? (customAiModel || flash_model)
|
|
||||||
: (command === 'ask' ? flash_model : thinking_model);
|
|
||||||
|
|
||||||
const fixedMsg = message.replace(new RegExp(`^/${command}(@\\w+)?\\s*`), "").trim();
|
|
||||||
logger.logCmdStart(author, command, model);
|
|
||||||
|
|
||||||
if (!process.env.ollamaApi) {
|
|
||||||
await ctx.reply(Strings.ai.disabled, { parse_mode: 'Markdown', ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user.aiEnabled) {
|
|
||||||
await ctx.reply(Strings.ai.disabledForUser, { parse_mode: 'Markdown', ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fixedMsg.length < 1) {
|
|
||||||
await ctx.reply(Strings.ai.askNoMessage, { parse_mode: 'Markdown', ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = ctx.from!.id;
|
|
||||||
const userQueueSize = requestQueue.filter(req => req.userId === userId).length;
|
|
||||||
|
|
||||||
if (userQueueSize >= maxUserQueueSize) {
|
|
||||||
await ctx.reply(Strings.ai.queueFull, {
|
|
||||||
parse_mode: 'Markdown',
|
|
||||||
...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } })
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const task = async () => {
|
|
||||||
const modelLabel = getModelLabelByName(model);
|
|
||||||
const replyGenerating = await ctx.reply(Strings.ai.askGenerating.replace("{model}", `\`${modelLabel}\``), {
|
|
||||||
parse_mode: 'Markdown',
|
|
||||||
...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } })
|
|
||||||
});
|
|
||||||
const prompt = sanitizeForJson(await usingSystemPrompt(ctx, db, botName, fixedMsg));
|
|
||||||
await handleAiReply(ctx, model, prompt, replyGenerating, aiTemperature, fixedMsg, db, user.telegramId, Strings, showThinking);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isProcessing) {
|
|
||||||
requestQueue.push({ task, ctx, wasQueued: true, userId: ctx.from!.id });
|
|
||||||
const position = requestQueue.length;
|
|
||||||
await ctx.reply(Strings.ai.inQueue.replace("{position}", String(position)), {
|
|
||||||
parse_mode: 'Markdown',
|
|
||||||
...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } })
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
requestQueue.push({ task, ctx, wasQueued: false, userId: ctx.from!.id });
|
|
||||||
processQueue();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bot.command(["ask", "think"], spamwatchMiddleware, async (ctx) => {
|
|
||||||
if (!ctx.message || !('text' in ctx.message)) return;
|
|
||||||
const command = ctx.message.text.startsWith('/ask') ? 'ask' : 'think';
|
|
||||||
await aiCommandHandler(ctx as TextContext, command);
|
|
||||||
});
|
|
||||||
|
|
||||||
bot.command(["ai"], spamwatchMiddleware, async (ctx) => {
|
|
||||||
if (!ctx.message || !('text' in ctx.message)) return;
|
|
||||||
await aiCommandHandler(ctx as TextContext, 'ai');
|
|
||||||
});
|
|
||||||
|
|
||||||
bot.command(["aistats"], spamwatchMiddleware, async (ctx) => {
|
|
||||||
const { user, Strings } = await getUserWithStringsAndModel(ctx, db);
|
|
||||||
if (!user) {
|
|
||||||
await ctx.reply(Strings.userNotFound || "User not found.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const bookCount = Math.max(1, Math.round(user.aiCharacters / 500000));
|
|
||||||
const bookWord = bookCount === 1 ? 'book' : 'books';
|
|
||||||
const msg = `${Strings.aiStats.header}\n\n${Strings.aiStats.requests.replace('{aiRequests}', user.aiRequests)}\n${Strings.aiStats.characters.replace('{aiCharacters}', user.aiCharacters).replace('{bookCount}', bookCount).replace('books', bookWord)}`;
|
|
||||||
await ctx.reply(msg, { parse_mode: 'Markdown' });
|
|
||||||
});
|
|
||||||
}
|
|
|
@ -1,24 +0,0 @@
|
||||||
import {
|
|
||||||
integer,
|
|
||||||
pgTable,
|
|
||||||
varchar,
|
|
||||||
timestamp,
|
|
||||||
boolean,
|
|
||||||
real
|
|
||||||
} from "drizzle-orm/pg-core";
|
|
||||||
|
|
||||||
export const usersTable = pgTable("users", {
|
|
||||||
telegramId: varchar({ length: 255 }).notNull().primaryKey(),
|
|
||||||
username: varchar({ length: 255 }).notNull(),
|
|
||||||
firstName: varchar({ length: 255 }).notNull(),
|
|
||||||
lastName: varchar({ length: 255 }).notNull(),
|
|
||||||
aiEnabled: boolean().notNull().default(false),
|
|
||||||
showThinking: boolean().notNull().default(false),
|
|
||||||
customAiModel: varchar({ length: 255 }).notNull().default("deepseek-r1:1.5b"),
|
|
||||||
aiTemperature: real().notNull().default(0.9),
|
|
||||||
aiRequests: integer().notNull().default(0),
|
|
||||||
aiCharacters: integer().notNull().default(0),
|
|
||||||
languageCode: varchar({ length: 255 }).notNull(),
|
|
||||||
createdAt: timestamp().notNull().defaultNow(),
|
|
||||||
updatedAt: timestamp().notNull().defaultNow(),
|
|
||||||
});
|
|
|
@ -1 +0,0 @@
|
||||||
Subproject commit cee30dc64217e7ec235635f5bf5066eac56eec87
|
|
19
start-services.sh
Normal file
19
start-services.sh
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "Starting BOT..."
|
||||||
|
cd /usr/src/app
|
||||||
|
bun start 2>&1 | sed "s/^/[BOT] /" &
|
||||||
|
BOT_PID=$!
|
||||||
|
echo "BOT started with PID $BOT_PID"
|
||||||
|
|
||||||
|
echo "Starting WEBUI..."
|
||||||
|
cd /usr/src/app/webui
|
||||||
|
bun run start 2>&1 | sed "s/^/[WEBUI] /" &
|
||||||
|
WEBUI_PID=$!
|
||||||
|
echo "WEBUI started with PID $WEBUI_PID"
|
||||||
|
|
||||||
|
echo "Services started:"
|
||||||
|
echo " Bot PID: $BOT_PID"
|
||||||
|
echo " WebUI PID: $WEBUI_PID"
|
||||||
|
|
||||||
|
wait $BOT_PID $WEBUI_PID
|
31
supervisord.conf
Normal file
31
supervisord.conf
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
[supervisord]
|
||||||
|
nodaemon=true
|
||||||
|
user=root
|
||||||
|
logfile=/dev/stdout
|
||||||
|
logfile_maxbytes=0
|
||||||
|
pidfile=/var/run/supervisord.pid
|
||||||
|
loglevel=info
|
||||||
|
|
||||||
|
[program:telegram-bot]
|
||||||
|
command=bun start
|
||||||
|
directory=/usr/src/app
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
stdout_logfile_backups=0
|
||||||
|
stderr_logfile_backups=0
|
||||||
|
|
||||||
|
[program:webui]
|
||||||
|
command=bun run start
|
||||||
|
directory=/usr/src/app/webui
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
stdout_logfile_backups=0
|
||||||
|
stderr_logfile_backups=0
|
102
telegram/api/server.ts
Executable file
102
telegram/api/server.ts
Executable file
|
@ -0,0 +1,102 @@
|
||||||
|
import express from "express";
|
||||||
|
import { drizzle } from "drizzle-orm/node-postgres";
|
||||||
|
import { Client } from "pg";
|
||||||
|
import * as schema from "../../database/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { twoFactorTable, usersTable } from "../../database/schema";
|
||||||
|
import { Telegraf } from "telegraf";
|
||||||
|
import { getStrings } from "../plugins/checklang";
|
||||||
|
|
||||||
|
const client = new Client({ connectionString: process.env.databaseUrl });
|
||||||
|
const db = drizzle(client, { schema });
|
||||||
|
|
||||||
|
const bot = new Telegraf(process.env.botToken!);
|
||||||
|
const botName = bot.botInfo?.first_name && bot.botInfo?.last_name ? `${bot.botInfo.first_name} ${bot.botInfo.last_name}` : "Kowalski"
|
||||||
|
|
||||||
|
function shouldLogLonger() {
|
||||||
|
return process.env.longerLogs === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startServer() {
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
app.get("/health", (res) => {
|
||||||
|
res.send("OK");
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/2fa/get", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { userId } = req.body;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
console.log("[🌐 API] Missing userId in request");
|
||||||
|
return res.status(400).json({ generated: false, error: "User ID is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldLogLonger()) {
|
||||||
|
console.log("[🌐 API] Looking up user:", userId);
|
||||||
|
}
|
||||||
|
const user = await db.query.usersTable.findFirst({
|
||||||
|
where: eq(usersTable.telegramId, userId),
|
||||||
|
columns: {
|
||||||
|
languageCode: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
console.log("[🌐 API] User not found:", userId);
|
||||||
|
return res.status(404).json({ generated: false, error: "User not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = Math.floor(100000 + Math.random() * 900000).toString();
|
||||||
|
|
||||||
|
console.log("[🌐 API] Inserting 2FA record");
|
||||||
|
|
||||||
|
await db.insert(twoFactorTable).values({
|
||||||
|
userId,
|
||||||
|
currentCode: code,
|
||||||
|
codeAttempts: 0,
|
||||||
|
codeExpiresAt: new Date(Date.now() + 1000 * 60 * 5),
|
||||||
|
}).onConflictDoUpdate({
|
||||||
|
target: twoFactorTable.userId,
|
||||||
|
set: {
|
||||||
|
currentCode: code,
|
||||||
|
codeAttempts: 0,
|
||||||
|
codeExpiresAt: new Date(Date.now() + 1000 * 60 * 5),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (shouldLogLonger()) {
|
||||||
|
console.log("[🌐 API] Sending 2FA message");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const Strings = getStrings(user.languageCode);
|
||||||
|
const message = Strings.twoFactor.codeMessage
|
||||||
|
.replace("{botName}", botName)
|
||||||
|
.replace("{code}", code);
|
||||||
|
await bot.telegram.sendMessage(userId, message, { parse_mode: "MarkdownV2" });
|
||||||
|
if (shouldLogLonger()) {
|
||||||
|
console.log("[🌐 API] Message sent successfully");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[🌐 API] Error sending 2FA code to user", error);
|
||||||
|
return res.status(500).json({ generated: false, error: "Error sending 2FA message" });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ generated: true });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[🌐 API] Unexpected error in 2FA endpoint:", error);
|
||||||
|
return res.status(500).json({ generated: false, error: "Internal server error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(3030, () => {
|
||||||
|
console.log("[🌐 API] Running on port 3030\n");
|
||||||
|
});
|
||||||
|
}
|
8
src/bot.ts → telegram/bot.ts
Normal file → Executable file
8
src/bot.ts → telegram/bot.ts
Normal file → Executable file
|
@ -8,9 +8,10 @@ import './plugins/ytDlpWrapper';
|
||||||
import { preChecks } from './commands/ai';
|
import { preChecks } from './commands/ai';
|
||||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||||
import { Client } from 'pg';
|
import { Client } from 'pg';
|
||||||
import * as schema from './db/schema';
|
import * as schema from '../database/schema';
|
||||||
import { ensureUserInDb } from './utils/ensure-user';
|
import { ensureUserInDb } from './utils/ensure-user';
|
||||||
import { getSpamwatchBlockedCount } from './spamwatch/spamwatch';
|
import { getSpamwatchBlockedCount } from './spamwatch/spamwatch';
|
||||||
|
import { startServer } from './api/server';
|
||||||
|
|
||||||
(async function main() {
|
(async function main() {
|
||||||
const { botToken, handlerTimeout, maxRetries, databaseUrl, ollamaEnabled } = process.env;
|
const { botToken, handlerTimeout, maxRetries, databaseUrl, ollamaEnabled } = process.env;
|
||||||
|
@ -46,7 +47,7 @@ import { getSpamwatchBlockedCount } from './spamwatch/spamwatch';
|
||||||
let loadedCount = 0;
|
let loadedCount = 0;
|
||||||
try {
|
try {
|
||||||
const files = fs.readdirSync(commandsPath)
|
const files = fs.readdirSync(commandsPath)
|
||||||
.filter(file => file.endsWith('.ts') || file.endsWith('.js'));
|
.filter(file => file.endsWith('.ts'));
|
||||||
files.forEach((file) => {
|
files.forEach((file) => {
|
||||||
try {
|
try {
|
||||||
const commandPath = path.join(commandsPath, file);
|
const commandPath = path.join(commandsPath, file);
|
||||||
|
@ -59,7 +60,7 @@ import { getSpamwatchBlockedCount } from './spamwatch/spamwatch';
|
||||||
console.error(`Failed to load command file ${file}: ${error.message}`);
|
console.error(`Failed to load command file ${file}: ${error.message}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
console.log(`[🤖 BOT] Loaded ${loadedCount} commands.\n`);
|
console.log(`[🤖 BOT] Loaded ${loadedCount} commands.`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to read commands directory: ${error.message}`);
|
console.error(`Failed to read commands directory: ${error.message}`);
|
||||||
}
|
}
|
||||||
|
@ -125,5 +126,6 @@ import { getSpamwatchBlockedCount } from './spamwatch/spamwatch';
|
||||||
}
|
}
|
||||||
|
|
||||||
loadCommands();
|
loadCommands();
|
||||||
|
startServer();
|
||||||
startBot();
|
startBot();
|
||||||
})();
|
})();
|
1291
telegram/commands/ai.ts
Executable file
1291
telegram/commands/ai.ts
Executable file
File diff suppressed because it is too large
Load diff
34
src/commands/animal.ts → telegram/commands/animal.ts
Normal file → Executable file
34
src/commands/animal.ts → telegram/commands/animal.ts
Normal file → Executable file
|
@ -6,6 +6,7 @@ import axios from 'axios';
|
||||||
import { Context, Telegraf } from 'telegraf';
|
import { Context, Telegraf } from 'telegraf';
|
||||||
import { replyToMessageId } from '../utils/reply-to-message-id';
|
import { replyToMessageId } from '../utils/reply-to-message-id';
|
||||||
import { languageCode } from '../utils/language-code';
|
import { languageCode } from '../utils/language-code';
|
||||||
|
import { isCommandDisabled } from '../utils/check-command-disabled';
|
||||||
|
|
||||||
const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch);
|
const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch);
|
||||||
|
|
||||||
|
@ -130,10 +131,29 @@ export const soggyHandler = async (ctx: Context & { message: { text: string } })
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default (bot: Telegraf<Context>) => {
|
export default (bot: Telegraf<Context>, db: any) => {
|
||||||
bot.command("duck", spamwatchMiddleware, duckHandler);
|
bot.command("duck", spamwatchMiddleware, async (ctx) => {
|
||||||
bot.command("fox", spamwatchMiddleware, foxHandler);
|
if (await isCommandDisabled(ctx, db, 'animals-basic')) return;
|
||||||
bot.command("dog", spamwatchMiddleware, dogHandler);
|
await duckHandler(ctx);
|
||||||
bot.command("cat", spamwatchMiddleware, catHandler);
|
});
|
||||||
bot.command(['soggy', 'soggycat'], spamwatchMiddleware, soggyHandler);
|
|
||||||
}
|
bot.command("fox", spamwatchMiddleware, async (ctx) => {
|
||||||
|
if (await isCommandDisabled(ctx, db, 'animals-basic')) return;
|
||||||
|
await foxHandler(ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
bot.command("dog", spamwatchMiddleware, async (ctx) => {
|
||||||
|
if (await isCommandDisabled(ctx, db, 'animals-basic')) return;
|
||||||
|
await dogHandler(ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
bot.command("cat", spamwatchMiddleware, async (ctx) => {
|
||||||
|
if (await isCommandDisabled(ctx, db, 'animals-basic')) return;
|
||||||
|
await catHandler(ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
bot.command(['soggy', 'soggycat'], spamwatchMiddleware, async (ctx) => {
|
||||||
|
if (await isCommandDisabled(ctx, db, 'soggy-cat')) return;
|
||||||
|
await soggyHandler(ctx);
|
||||||
|
});
|
||||||
|
}
|
5
src/commands/codename.ts → telegram/commands/codename.ts
Normal file → Executable file
5
src/commands/codename.ts → telegram/commands/codename.ts
Normal file → Executable file
|
@ -6,8 +6,9 @@ import axios from 'axios';
|
||||||
import verifyInput from '../plugins/verifyInput';
|
import verifyInput from '../plugins/verifyInput';
|
||||||
import { Context, Telegraf } from 'telegraf';
|
import { Context, Telegraf } from 'telegraf';
|
||||||
import { replyToMessageId } from '../utils/reply-to-message-id';
|
import { replyToMessageId } from '../utils/reply-to-message-id';
|
||||||
import * as schema from '../db/schema';
|
import * as schema from '../../database/schema';
|
||||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||||
|
import { isCommandDisabled } from '../utils/check-command-disabled';
|
||||||
|
|
||||||
const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch);
|
const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch);
|
||||||
|
|
||||||
|
@ -53,6 +54,8 @@ async function getUserAndStrings(ctx: Context, db?: NodePgDatabase<typeof schema
|
||||||
|
|
||||||
export default (bot: Telegraf<Context>, db) => {
|
export default (bot: Telegraf<Context>, db) => {
|
||||||
bot.command(['codename', 'whatis'], spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => {
|
bot.command(['codename', 'whatis'], spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => {
|
||||||
|
if (await isCommandDisabled(ctx, db, 'codename-lookup')) return;
|
||||||
|
|
||||||
const userInput = ctx.message.text.split(" ").slice(1).join(" ");
|
const userInput = ctx.message.text.split(" ").slice(1).join(" ");
|
||||||
const { Strings } = await getUserAndStrings(ctx, db);
|
const { Strings } = await getUserAndStrings(ctx, db);
|
||||||
const { noCodename } = Strings.codenameCheck;
|
const { noCodename } = Strings.codenameCheck;
|
2
src/commands/crew.ts → telegram/commands/crew.ts
Normal file → Executable file
2
src/commands/crew.ts → telegram/commands/crew.ts
Normal file → Executable file
|
@ -5,7 +5,7 @@ import os from 'os';
|
||||||
import { exec } from 'child_process';
|
import { exec } from 'child_process';
|
||||||
import { error } from 'console';
|
import { error } from 'console';
|
||||||
import { Context, Telegraf } from 'telegraf';
|
import { Context, Telegraf } from 'telegraf';
|
||||||
import * as schema from '../db/schema';
|
import * as schema from '../../database/schema';
|
||||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||||
|
|
||||||
const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch);
|
const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch);
|
16
src/commands/fun.ts → telegram/commands/fun.ts
Normal file → Executable file
16
src/commands/fun.ts → telegram/commands/fun.ts
Normal file → Executable file
|
@ -3,8 +3,9 @@ import { getStrings } from '../plugins/checklang';
|
||||||
import { isOnSpamWatch } from '../spamwatch/spamwatch';
|
import { isOnSpamWatch } from '../spamwatch/spamwatch';
|
||||||
import spamwatchMiddlewareModule from '../spamwatch/Middleware';
|
import spamwatchMiddlewareModule from '../spamwatch/Middleware';
|
||||||
import { Context, Telegraf } from 'telegraf';
|
import { Context, Telegraf } from 'telegraf';
|
||||||
import * as schema from '../db/schema';
|
import * as schema from '../../database/schema';
|
||||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||||
|
import { isCommandDisabled } from '../utils/check-command-disabled';
|
||||||
|
|
||||||
const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch);
|
const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch);
|
||||||
|
|
||||||
|
@ -78,6 +79,8 @@ function getRandomInt(max: number) {
|
||||||
|
|
||||||
export default (bot: Telegraf<Context>, db) => {
|
export default (bot: Telegraf<Context>, db) => {
|
||||||
bot.command('random', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => {
|
bot.command('random', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => {
|
||||||
|
if (await isCommandDisabled(ctx, db, 'fun-random')) return;
|
||||||
|
|
||||||
const { Strings } = await getUserAndStrings(ctx, db);
|
const { Strings } = await getUserAndStrings(ctx, db);
|
||||||
const randomValue = getRandomInt(10);
|
const randomValue = getRandomInt(10);
|
||||||
const randomVStr = Strings.randomNum.replace('{number}', randomValue);
|
const randomVStr = Strings.randomNum.replace('{number}', randomValue);
|
||||||
|
@ -91,26 +94,33 @@ export default (bot: Telegraf<Context>, db) => {
|
||||||
|
|
||||||
// TODO: maybe send custom stickers to match result of the roll? i think there are pre-existing ones
|
// TODO: maybe send custom stickers to match result of the roll? i think there are pre-existing ones
|
||||||
bot.command('dice', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => {
|
bot.command('dice', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => {
|
||||||
|
if (await isCommandDisabled(ctx, db, 'games-dice')) return;
|
||||||
await handleDiceCommand(ctx, '🎲', 4000, db);
|
await handleDiceCommand(ctx, '🎲', 4000, db);
|
||||||
});
|
});
|
||||||
|
|
||||||
bot.command('slot', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => {
|
bot.command('slot', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => {
|
||||||
await handleDiceCommand(ctx, '<27><>', 3000, db);
|
if (await isCommandDisabled(ctx, db, 'games-dice')) return;
|
||||||
|
await handleDiceCommand(ctx, '🎰', 3000, db);
|
||||||
});
|
});
|
||||||
|
|
||||||
bot.command('ball', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => {
|
bot.command('ball', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => {
|
||||||
|
if (await isCommandDisabled(ctx, db, 'games-dice')) return;
|
||||||
await handleDiceCommand(ctx, '⚽', 3000, db);
|
await handleDiceCommand(ctx, '⚽', 3000, db);
|
||||||
});
|
});
|
||||||
|
|
||||||
bot.command('dart', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => {
|
bot.command('dart', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => {
|
||||||
|
if (await isCommandDisabled(ctx, db, 'games-dice')) return;
|
||||||
await handleDiceCommand(ctx, '🎯', 3000, db);
|
await handleDiceCommand(ctx, '🎯', 3000, db);
|
||||||
});
|
});
|
||||||
|
|
||||||
bot.command('bowling', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => {
|
bot.command('bowling', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => {
|
||||||
|
if (await isCommandDisabled(ctx, db, 'games-dice')) return;
|
||||||
await handleDiceCommand(ctx, '🎳', 3000, db);
|
await handleDiceCommand(ctx, '🎳', 3000, db);
|
||||||
});
|
});
|
||||||
|
|
||||||
bot.command('idice', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => {
|
bot.command('idice', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => {
|
||||||
|
if (await isCommandDisabled(ctx, db, 'infinite-dice')) return;
|
||||||
|
|
||||||
const { Strings } = await getUserAndStrings(ctx, db);
|
const { Strings } = await getUserAndStrings(ctx, db);
|
||||||
ctx.replyWithSticker(
|
ctx.replyWithSticker(
|
||||||
Resources.infiniteDice, {
|
Resources.infiniteDice, {
|
||||||
|
@ -119,10 +129,12 @@ export default (bot: Telegraf<Context>, db) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
bot.command('furry', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => {
|
bot.command('furry', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => {
|
||||||
|
if (await isCommandDisabled(ctx, db, 'fun-random')) return;
|
||||||
sendRandomReply(ctx, Resources.furryGif, 'furryAmount', db);
|
sendRandomReply(ctx, Resources.furryGif, 'furryAmount', db);
|
||||||
});
|
});
|
||||||
|
|
||||||
bot.command('gay', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => {
|
bot.command('gay', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => {
|
||||||
|
if (await isCommandDisabled(ctx, db, 'fun-random')) return;
|
||||||
sendRandomReply(ctx, Resources.gayFlag, 'gayAmount', db);
|
sendRandomReply(ctx, Resources.gayFlag, 'gayAmount', db);
|
||||||
});
|
});
|
||||||
};
|
};
|
5
src/commands/gsmarena.ts → telegram/commands/gsmarena.ts
Normal file → Executable file
5
src/commands/gsmarena.ts → telegram/commands/gsmarena.ts
Normal file → Executable file
|
@ -11,6 +11,7 @@ import { parse } from 'node-html-parser';
|
||||||
import { getDeviceByCodename } from './codename';
|
import { getDeviceByCodename } from './codename';
|
||||||
import { getStrings } from '../plugins/checklang';
|
import { getStrings } from '../plugins/checklang';
|
||||||
import { languageCode } from '../utils/language-code';
|
import { languageCode } from '../utils/language-code';
|
||||||
|
import { isCommandDisabled } from '../utils/check-command-disabled';
|
||||||
|
|
||||||
const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch);
|
const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch);
|
||||||
|
|
||||||
|
@ -212,8 +213,10 @@ function getUsername(ctx){
|
||||||
const deviceSelectionCache: Record<number, { results: PhoneSearchResult[], timeout: NodeJS.Timeout }> = {};
|
const deviceSelectionCache: Record<number, { results: PhoneSearchResult[], timeout: NodeJS.Timeout }> = {};
|
||||||
const lastSelectionMessageId: Record<number, number> = {};
|
const lastSelectionMessageId: Record<number, number> = {};
|
||||||
|
|
||||||
export default (bot) => {
|
export default (bot, db) => {
|
||||||
bot.command(['d', 'device'], spamwatchMiddleware, async (ctx) => {
|
bot.command(['d', 'device'], spamwatchMiddleware, async (ctx) => {
|
||||||
|
if (await isCommandDisabled(ctx, db, 'device-specs')) return;
|
||||||
|
|
||||||
const userId = ctx.from.id;
|
const userId = ctx.from.id;
|
||||||
const userName = getUsername(ctx);
|
const userName = getUsername(ctx);
|
||||||
const Strings = getStrings(languageCode(ctx));
|
const Strings = getStrings(languageCode(ctx));
|
10
src/commands/help.ts → telegram/commands/help.ts
Normal file → Executable file
10
src/commands/help.ts → telegram/commands/help.ts
Normal file → Executable file
|
@ -22,6 +22,13 @@ async function getUserAndStrings(ctx: Context, db?: any): Promise<{ Strings: any
|
||||||
return { Strings, languageCode };
|
return { Strings, languageCode };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAdmin(ctx: Context): boolean {
|
||||||
|
const userId = ctx.from?.id;
|
||||||
|
if (!userId) return false;
|
||||||
|
const adminArray = process.env.botAdmins ? process.env.botAdmins.split(',').map(id => parseInt(id.trim())) : [];
|
||||||
|
return adminArray.includes(userId);
|
||||||
|
}
|
||||||
|
|
||||||
interface MessageOptions {
|
interface MessageOptions {
|
||||||
parse_mode: string;
|
parse_mode: string;
|
||||||
disable_web_page_preview: boolean;
|
disable_web_page_preview: boolean;
|
||||||
|
@ -136,7 +143,8 @@ export default (bot, db) => {
|
||||||
});
|
});
|
||||||
bot.action('helpAi', async (ctx) => {
|
bot.action('helpAi', async (ctx) => {
|
||||||
const { Strings } = await getUserAndStrings(ctx, db);
|
const { Strings } = await getUserAndStrings(ctx, db);
|
||||||
await ctx.editMessageText(Strings.ai.helpDesc, options(Strings));
|
const helpText = isAdmin(ctx) ? Strings.ai.helpDescAdmin : Strings.ai.helpDesc;
|
||||||
|
await ctx.editMessageText(helpText, options(Strings));
|
||||||
await ctx.answerCbQuery();
|
await ctx.answerCbQuery();
|
||||||
});
|
});
|
||||||
bot.action('helpBack', async (ctx) => {
|
bot.action('helpBack', async (ctx) => {
|
7
src/commands/http.ts → telegram/commands/http.ts
Normal file → Executable file
7
src/commands/http.ts → telegram/commands/http.ts
Normal file → Executable file
|
@ -5,9 +5,10 @@ import spamwatchMiddlewareModule from '../spamwatch/Middleware';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import verifyInput from '../plugins/verifyInput';
|
import verifyInput from '../plugins/verifyInput';
|
||||||
import { Context, Telegraf } from 'telegraf';
|
import { Context, Telegraf } from 'telegraf';
|
||||||
import * as schema from '../db/schema';
|
import * as schema from '../../database/schema';
|
||||||
import { languageCode } from '../utils/language-code';
|
import { languageCode } from '../utils/language-code';
|
||||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||||
|
import { isCommandDisabled } from '../utils/check-command-disabled';
|
||||||
|
|
||||||
const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch);
|
const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch);
|
||||||
|
|
||||||
|
@ -34,6 +35,8 @@ async function getUserAndStrings(ctx: Context, db?: NodePgDatabase<typeof schema
|
||||||
|
|
||||||
export default (bot: Telegraf<Context>, db) => {
|
export default (bot: Telegraf<Context>, db) => {
|
||||||
bot.command("http", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => {
|
bot.command("http", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => {
|
||||||
|
if (await isCommandDisabled(ctx, db, 'http-status')) return;
|
||||||
|
|
||||||
const reply_to_message_id = ctx.message.message_id;
|
const reply_to_message_id = ctx.message.message_id;
|
||||||
const { Strings } = await getUserAndStrings(ctx, db);
|
const { Strings } = await getUserAndStrings(ctx, db);
|
||||||
const userInput = ctx.message.text.split(' ')[1];
|
const userInput = ctx.message.text.split(' ')[1];
|
||||||
|
@ -75,6 +78,8 @@ export default (bot: Telegraf<Context>, db) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
bot.command("httpcat", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => {
|
bot.command("httpcat", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => {
|
||||||
|
if (await isCommandDisabled(ctx, db, 'animals-basic')) return;
|
||||||
|
|
||||||
const Strings = getStrings(languageCode(ctx));
|
const Strings = getStrings(languageCode(ctx));
|
||||||
const reply_to_message_id = ctx.message.message_id;
|
const reply_to_message_id = ctx.message.message_id;
|
||||||
const userInput = ctx.message.text.split(' ').slice(1).join(' ').replace(/\s+/g, '');
|
const userInput = ctx.message.text.split(' ').slice(1).join(' ').replace(/\s+/g, '');
|
7
src/commands/info.ts → telegram/commands/info.ts
Normal file → Executable file
7
src/commands/info.ts → telegram/commands/info.ts
Normal file → Executable file
|
@ -2,8 +2,9 @@ import { getStrings } from '../plugins/checklang';
|
||||||
import { isOnSpamWatch } from '../spamwatch/spamwatch';
|
import { isOnSpamWatch } from '../spamwatch/spamwatch';
|
||||||
import spamwatchMiddlewareModule from '../spamwatch/Middleware';
|
import spamwatchMiddlewareModule from '../spamwatch/Middleware';
|
||||||
import { Context, Telegraf } from 'telegraf';
|
import { Context, Telegraf } from 'telegraf';
|
||||||
import * as schema from '../db/schema';
|
import * as schema from '../../database/schema';
|
||||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||||
|
import { isCommandDisabled } from '../utils/check-command-disabled';
|
||||||
|
|
||||||
const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch);
|
const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch);
|
||||||
|
|
||||||
|
@ -62,6 +63,8 @@ async function getChatInfo(ctx: Context & { message: { text: string } }, db: any
|
||||||
|
|
||||||
export default (bot: Telegraf<Context>, db) => {
|
export default (bot: Telegraf<Context>, db) => {
|
||||||
bot.command('chatinfo', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => {
|
bot.command('chatinfo', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => {
|
||||||
|
if (await isCommandDisabled(ctx, db, 'info-commands')) return;
|
||||||
|
|
||||||
const chatInfo = await getChatInfo(ctx, db);
|
const chatInfo = await getChatInfo(ctx, db);
|
||||||
ctx.reply(
|
ctx.reply(
|
||||||
chatInfo, {
|
chatInfo, {
|
||||||
|
@ -72,6 +75,8 @@ export default (bot: Telegraf<Context>, db) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
bot.command('userinfo', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => {
|
bot.command('userinfo', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => {
|
||||||
|
if (await isCommandDisabled(ctx, db, 'info-commands')) return;
|
||||||
|
|
||||||
const userInfo = await getUserInfo(ctx, db);
|
const userInfo = await getUserInfo(ctx, db);
|
||||||
ctx.reply(
|
ctx.reply(
|
||||||
userInfo, {
|
userInfo, {
|
11
src/commands/lastfm.ts → telegram/commands/lastfm.ts
Normal file → Executable file
11
src/commands/lastfm.ts → telegram/commands/lastfm.ts
Normal file → Executable file
|
@ -4,13 +4,14 @@ import axios from 'axios';
|
||||||
import { getStrings } from '../plugins/checklang';
|
import { getStrings } from '../plugins/checklang';
|
||||||
import { isOnSpamWatch } from '../spamwatch/spamwatch';
|
import { isOnSpamWatch } from '../spamwatch/spamwatch';
|
||||||
import spamwatchMiddlewareModule from '../spamwatch/Middleware';
|
import spamwatchMiddlewareModule from '../spamwatch/Middleware';
|
||||||
|
import { isCommandDisabled } from '../utils/check-command-disabled';
|
||||||
|
|
||||||
const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch);
|
const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch);
|
||||||
|
|
||||||
const scrobbler_url = Resources.lastFmApi;
|
const scrobbler_url = Resources.lastFmApi;
|
||||||
const api_key = process.env.lastKey;
|
const api_key = process.env.lastKey;
|
||||||
|
|
||||||
const dbFile = 'src/props/lastfm.json';
|
const dbFile = 'telegram/props/lastfm.json';
|
||||||
let users = {};
|
let users = {};
|
||||||
|
|
||||||
function loadUsers() {
|
function loadUsers() {
|
||||||
|
@ -60,10 +61,12 @@ function getFromLast(track) {
|
||||||
return imageUrl;
|
return imageUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default (bot) => {
|
export default (bot, db) => {
|
||||||
loadUsers();
|
loadUsers();
|
||||||
|
|
||||||
bot.command('setuser', (ctx) => {
|
bot.command('setuser', async (ctx) => {
|
||||||
|
if (await isCommandDisabled(ctx, db, 'lastfm')) return;
|
||||||
|
|
||||||
const userId = ctx.from.id;
|
const userId = ctx.from.id;
|
||||||
const Strings = getStrings(ctx.from.language_code);
|
const Strings = getStrings(ctx.from.language_code);
|
||||||
const lastUser = ctx.message.text.split(' ')[1];
|
const lastUser = ctx.message.text.split(' ')[1];
|
||||||
|
@ -89,6 +92,8 @@ export default (bot) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
bot.command(['lt', 'lmu', 'last', 'lfm'], spamwatchMiddleware, async (ctx) => {
|
bot.command(['lt', 'lmu', 'last', 'lfm'], spamwatchMiddleware, async (ctx) => {
|
||||||
|
if (await isCommandDisabled(ctx, db, 'lastfm')) return;
|
||||||
|
|
||||||
const userId = ctx.from.id;
|
const userId = ctx.from.id;
|
||||||
const Strings = getStrings(ctx.from.language_code);
|
const Strings = getStrings(ctx.from.language_code);
|
||||||
const lastfmUser = users[userId];
|
const lastfmUser = users[userId];
|
2
src/commands/main.ts → telegram/commands/main.ts
Normal file → Executable file
2
src/commands/main.ts → telegram/commands/main.ts
Normal file → Executable file
|
@ -3,7 +3,7 @@ import { isOnSpamWatch } from '../spamwatch/spamwatch';
|
||||||
import spamwatchMiddlewareModule from '../spamwatch/Middleware';
|
import spamwatchMiddlewareModule from '../spamwatch/Middleware';
|
||||||
import { Context, Telegraf } from 'telegraf';
|
import { Context, Telegraf } from 'telegraf';
|
||||||
import { replyToMessageId } from '../utils/reply-to-message-id';
|
import { replyToMessageId } from '../utils/reply-to-message-id';
|
||||||
import * as schema from '../db/schema';
|
import * as schema from '../../database/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { ensureUserInDb } from '../utils/ensure-user';
|
import { ensureUserInDb } from '../utils/ensure-user';
|
||||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
8
src/commands/modarchive.ts → telegram/commands/modarchive.ts
Normal file → Executable file
8
src/commands/modarchive.ts → telegram/commands/modarchive.ts
Normal file → Executable file
|
@ -8,6 +8,7 @@ import spamwatchMiddlewareModule from '../spamwatch/Middleware';
|
||||||
import { languageCode } from '../utils/language-code';
|
import { languageCode } from '../utils/language-code';
|
||||||
import { Context, Telegraf } from 'telegraf';
|
import { Context, Telegraf } from 'telegraf';
|
||||||
import { replyToMessageId } from '../utils/reply-to-message-id';
|
import { replyToMessageId } from '../utils/reply-to-message-id';
|
||||||
|
import { isCommandDisabled } from '../utils/check-command-disabled';
|
||||||
|
|
||||||
const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch);
|
const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch);
|
||||||
|
|
||||||
|
@ -79,6 +80,9 @@ export const modarchiveHandler = async (ctx: Context) => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export default (bot: Telegraf<Context>) => {
|
export default (bot: Telegraf<Context>, db) => {
|
||||||
bot.command(['modarchive', 'tma'], spamwatchMiddleware, modarchiveHandler);
|
bot.command(['modarchive', 'tma'], spamwatchMiddleware, async (ctx) => {
|
||||||
|
if (await isCommandDisabled(ctx, db, 'modarchive')) return;
|
||||||
|
await modarchiveHandler(ctx);
|
||||||
|
});
|
||||||
};
|
};
|
11
src/commands/ponyapi.ts → telegram/commands/ponyapi.ts
Normal file → Executable file
11
src/commands/ponyapi.ts → telegram/commands/ponyapi.ts
Normal file → Executable file
|
@ -7,6 +7,7 @@ import verifyInput from '../plugins/verifyInput';
|
||||||
import { Telegraf, Context } from 'telegraf';
|
import { Telegraf, Context } from 'telegraf';
|
||||||
import { languageCode } from '../utils/language-code';
|
import { languageCode } from '../utils/language-code';
|
||||||
import { replyToMessageId } from '../utils/reply-to-message-id';
|
import { replyToMessageId } from '../utils/reply-to-message-id';
|
||||||
|
import { isCommandDisabled } from '../utils/check-command-disabled';
|
||||||
|
|
||||||
const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch);
|
const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch);
|
||||||
|
|
||||||
|
@ -68,14 +69,18 @@ function sendPhoto(ctx: Context, photo: string, caption: string, reply_to_messag
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default (bot: Telegraf<Context>) => {
|
export default (bot: Telegraf<Context>, db) => {
|
||||||
bot.command("mlp", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => {
|
bot.command("mlp", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => {
|
||||||
|
if (await isCommandDisabled(ctx, db, 'mlp-content')) return;
|
||||||
|
|
||||||
const Strings = getStrings(languageCode(ctx));
|
const Strings = getStrings(languageCode(ctx));
|
||||||
const reply_to_message_id = replyToMessageId(ctx);
|
const reply_to_message_id = replyToMessageId(ctx);
|
||||||
sendReply(ctx, Strings.ponyApi.helpDesc, reply_to_message_id);
|
sendReply(ctx, Strings.ponyApi.helpDesc, reply_to_message_id);
|
||||||
});
|
});
|
||||||
|
|
||||||
bot.command("mlpchar", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => {
|
bot.command("mlpchar", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => {
|
||||||
|
if (await isCommandDisabled(ctx, db, 'mlp-content')) return;
|
||||||
|
|
||||||
const { message } = ctx;
|
const { message } = ctx;
|
||||||
const reply_to_message_id = replyToMessageId(ctx);
|
const reply_to_message_id = replyToMessageId(ctx);
|
||||||
const Strings = getStrings(languageCode(ctx) || 'en');
|
const Strings = getStrings(languageCode(ctx) || 'en');
|
||||||
|
@ -118,6 +123,8 @@ export default (bot: Telegraf<Context>) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
bot.command("mlpep", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => {
|
bot.command("mlpep", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => {
|
||||||
|
if (await isCommandDisabled(ctx, db, 'mlp-content')) return;
|
||||||
|
|
||||||
const Strings = getStrings(languageCode(ctx) || 'en');
|
const Strings = getStrings(languageCode(ctx) || 'en');
|
||||||
const userInput = ctx.message.text.split(' ').slice(1).join(' ').replace(" ", "+");
|
const userInput = ctx.message.text.split(' ').slice(1).join(' ').replace(" ", "+");
|
||||||
const reply_to_message_id = replyToMessageId(ctx);
|
const reply_to_message_id = replyToMessageId(ctx);
|
||||||
|
@ -194,6 +201,8 @@ export default (bot: Telegraf<Context>) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
bot.command("mlpcomic", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => {
|
bot.command("mlpcomic", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => {
|
||||||
|
if (await isCommandDisabled(ctx, db, 'mlp-content')) return;
|
||||||
|
|
||||||
const Strings = getStrings(languageCode(ctx) || 'en');
|
const Strings = getStrings(languageCode(ctx) || 'en');
|
||||||
const userInput = ctx.message.text.split(' ').slice(1).join(' ').replace(" ", "+");
|
const userInput = ctx.message.text.split(' ').slice(1).join(' ').replace(" ", "+");
|
||||||
const reply_to_message_id = replyToMessageId(ctx);
|
const reply_to_message_id = replyToMessageId(ctx);
|
0
src/commands/quotes.ts → telegram/commands/quotes.ts
Normal file → Executable file
0
src/commands/quotes.ts → telegram/commands/quotes.ts
Normal file → Executable file
8
src/commands/randompony.ts → telegram/commands/randompony.ts
Normal file → Executable file
8
src/commands/randompony.ts → telegram/commands/randompony.ts
Normal file → Executable file
|
@ -6,6 +6,7 @@ import axios from 'axios';
|
||||||
import { Telegraf, Context } from 'telegraf';
|
import { Telegraf, Context } from 'telegraf';
|
||||||
import { languageCode } from '../utils/language-code';
|
import { languageCode } from '../utils/language-code';
|
||||||
import { replyToMessageId } from '../utils/reply-to-message-id';
|
import { replyToMessageId } from '../utils/reply-to-message-id';
|
||||||
|
import { isCommandDisabled } from '../utils/check-command-disabled';
|
||||||
|
|
||||||
const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch);
|
const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch);
|
||||||
|
|
||||||
|
@ -43,6 +44,9 @@ export const randomponyHandler = async (ctx: Context & { message: { text: string
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default (bot: Telegraf<Context>) => {
|
export default (bot: Telegraf<Context>, db) => {
|
||||||
bot.command(["rpony", "randompony", "mlpart"], spamwatchMiddleware, randomponyHandler);
|
bot.command(["rpony", "randompony", "mlpart"], spamwatchMiddleware, async (ctx) => {
|
||||||
|
if (await isCommandDisabled(ctx, db, 'random-pony')) return;
|
||||||
|
await randomponyHandler(ctx);
|
||||||
|
});
|
||||||
}
|
}
|
9
src/commands/weather.ts → telegram/commands/weather.ts
Normal file → Executable file
9
src/commands/weather.ts → telegram/commands/weather.ts
Normal file → Executable file
|
@ -9,6 +9,7 @@ import { isOnSpamWatch } from '../spamwatch/spamwatch';
|
||||||
import spamwatchMiddlewareModule from '../spamwatch/Middleware';
|
import spamwatchMiddlewareModule from '../spamwatch/Middleware';
|
||||||
import verifyInput from '../plugins/verifyInput';
|
import verifyInput from '../plugins/verifyInput';
|
||||||
import { Context, Telegraf } from 'telegraf';
|
import { Context, Telegraf } from 'telegraf';
|
||||||
|
import { isCommandDisabled } from '../utils/check-command-disabled';
|
||||||
|
|
||||||
const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch);
|
const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch);
|
||||||
|
|
||||||
|
@ -34,10 +35,12 @@ function getLocaleUnit(countryCode: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default (bot: Telegraf<Context>) => {
|
export default (bot: Telegraf<Context>, db: any) => {
|
||||||
bot.command(['clima', 'weather'], spamwatchMiddleware, async (ctx) => {
|
bot.command(['weather', 'clima'], spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => {
|
||||||
|
if (await isCommandDisabled(ctx, db, 'weather')) return;
|
||||||
|
|
||||||
const reply_to_message_id = ctx.message.message_id;
|
const reply_to_message_id = ctx.message.message_id;
|
||||||
const userLang = ctx.from.language_code || "en-US";
|
const userLang = ctx.from?.language_code || "en-US";
|
||||||
const Strings = getStrings(userLang);
|
const Strings = getStrings(userLang);
|
||||||
const userInput = ctx.message.text.split(' ').slice(1).join(' ');
|
const userInput = ctx.message.text.split(' ').slice(1).join(' ');
|
||||||
const { provideLocation } = Strings.weatherStatus
|
const { provideLocation } = Strings.weatherStatus
|
0
src/commands/wiki.ts → telegram/commands/wiki.ts
Normal file → Executable file
0
src/commands/wiki.ts → telegram/commands/wiki.ts
Normal file → Executable file
7
src/commands/youtube.ts → telegram/commands/youtube.ts
Normal file → Executable file
7
src/commands/youtube.ts → telegram/commands/youtube.ts
Normal file → Executable file
|
@ -2,6 +2,7 @@ import { getStrings } from '../plugins/checklang';
|
||||||
import { isOnSpamWatch } from '../spamwatch/spamwatch';
|
import { isOnSpamWatch } from '../spamwatch/spamwatch';
|
||||||
import spamwatchMiddlewareModule from '../spamwatch/Middleware';
|
import spamwatchMiddlewareModule from '../spamwatch/Middleware';
|
||||||
import { execFile } from 'child_process';
|
import { execFile } from 'child_process';
|
||||||
|
import { isCommandDisabled } from '../utils/check-command-disabled';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
@ -72,8 +73,10 @@ const isValidUrl = (url: string): boolean => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default (bot) => {
|
export default (bot, db) => {
|
||||||
bot.command(['yt', 'ytdl', 'sdl', 'video', 'dl'], spamwatchMiddleware, async (ctx) => {
|
bot.command(['yt', 'ytdl', 'sdl', 'video', 'dl'], spamwatchMiddleware, async (ctx) => {
|
||||||
|
if (await isCommandDisabled(ctx, db, 'youtube-download')) return;
|
||||||
|
|
||||||
const Strings = getStrings(ctx.from.language_code);
|
const Strings = getStrings(ctx.from.language_code);
|
||||||
const ytDlpPath = getYtDlpPath();
|
const ytDlpPath = getYtDlpPath();
|
||||||
const userId: number = ctx.from.id;
|
const userId: number = ctx.from.id;
|
||||||
|
@ -113,7 +116,7 @@ export default (bot) => {
|
||||||
console.log(`\nDownload Request:\nURL: ${videoUrl}\nYOUTUBE: ${videoIsYoutube}\n`)
|
console.log(`\nDownload Request:\nURL: ${videoUrl}\nYOUTUBE: ${videoIsYoutube}\n`)
|
||||||
|
|
||||||
if (fs.existsSync(path.resolve(__dirname, "../props/cookies.txt"))) {
|
if (fs.existsSync(path.resolve(__dirname, "../props/cookies.txt"))) {
|
||||||
cmdArgs = "--max-filesize 2G --no-playlist --cookies src/props/cookies.txt --merge-output-format mp4 -o";
|
cmdArgs = "--max-filesize 2G --no-playlist --cookies telegram/props/cookies.txt --merge-output-format mp4 -o";
|
||||||
} else {
|
} else {
|
||||||
cmdArgs = `--max-filesize 2G --no-playlist --merge-output-format mp4 -o`;
|
cmdArgs = `--max-filesize 2G --no-playlist --merge-output-format mp4 -o`;
|
||||||
}
|
}
|
0
src/locales/config.ts → telegram/locales/config.ts
Normal file → Executable file
0
src/locales/config.ts → telegram/locales/config.ts
Normal file → Executable file
41
src/locales/english.json → telegram/locales/english.json
Normal file → Executable file
41
src/locales/english.json → telegram/locales/english.json
Normal file → Executable file
|
@ -19,6 +19,7 @@
|
||||||
},
|
},
|
||||||
"unexpectedErr": "An unexpected error occurred: {error}",
|
"unexpectedErr": "An unexpected error occurred: {error}",
|
||||||
"errInvalidOption": "Whoops! Invalid option!",
|
"errInvalidOption": "Whoops! Invalid option!",
|
||||||
|
"commandDisabled": "🚫 This command is currently disabled for your account.\n\nYou can enable it in the web interface: {frontUrl}",
|
||||||
"kickingMyself": "*Since you don't need me, I'll leave.*",
|
"kickingMyself": "*Since you don't need me, I'll leave.*",
|
||||||
"kickingMyselfErr": "Error leaving the chat.",
|
"kickingMyselfErr": "Error leaving the chat.",
|
||||||
"noPermission": "You don't have permission to run this command.",
|
"noPermission": "You don't have permission to run this command.",
|
||||||
|
@ -67,7 +68,8 @@
|
||||||
"animalCommandsDesc": "🐱 *Animals*\n\n- /soggy | /soggycat `<1 | 2 | 3 | 4 | orig | thumb | sticker | alt>`: Sends the [Soggy cat meme](https://knowyourmeme.com/memes/soggy-cat)\n- /cat: Sends a random picture of a cat.\n- /fox: Sends a random picture of a fox.\n- /duck: Sends a random picture of a duck.\n- /dog: Sends a random picture of a dog.\n- /httpcat `<http code>`: Send cat memes from http.cat with your specified HTTP code. Example: `/httpcat 404`",
|
"animalCommandsDesc": "🐱 *Animals*\n\n- /soggy | /soggycat `<1 | 2 | 3 | 4 | orig | thumb | sticker | alt>`: Sends the [Soggy cat meme](https://knowyourmeme.com/memes/soggy-cat)\n- /cat: Sends a random picture of a cat.\n- /fox: Sends a random picture of a fox.\n- /duck: Sends a random picture of a duck.\n- /dog: Sends a random picture of a dog.\n- /httpcat `<http code>`: Send cat memes from http.cat with your specified HTTP code. Example: `/httpcat 404`",
|
||||||
"ai": {
|
"ai": {
|
||||||
"helpEntry": "✨ AI Commands",
|
"helpEntry": "✨ AI Commands",
|
||||||
"helpDesc": "✨ *AI Commands*\n\n- /ask `<prompt>`: Ask a question to an AI model\n- /think `<prompt>`: Ask a thinking model about a question\n- /ai `<prompt>`: Ask your custom-set AI model a question\n- /aistats: Show your AI usage stats",
|
"helpDesc": "✨ *AI Commands*\n\n- /ask `<prompt>`: Ask a question to an AI model\n- /think `<prompt>`: Ask a thinking model about a question\n- /ai `<prompt>`: Ask your custom-set AI model a question\n- /aistop: Stop your current AI request\n- /aistats: Show your AI usage stats",
|
||||||
|
"helpDescAdmin": "✨ *AI Commands*\n\n- /ask `<prompt>`: Ask a question to an AI model\n- /think `<prompt>`: Ask a thinking model about a question\n- /ai `<prompt>`: Ask your custom-set AI model a question\n- /aistop: Stop your current AI request\n- /aistats: Show your AI usage stats\n\n*Admin Commands:*\n- /queue: List current AI queue\n- /qdel `<user_id>`: Clear queue items for a user\n- /qlimit `<user_id>` `<duration>`: Timeout user from AI commands\n- /setexec `<user_id>` `<duration>`: Set max execution time for user\n- /rlimit `<user_id>`: Remove all AI limits for user\n- /limits: List all current AI limits",
|
||||||
"disabled": "✨ AI features are currently disabled globally.",
|
"disabled": "✨ AI features are currently disabled globally.",
|
||||||
"disabledForUser": "✨ AI features are disabled for your account. You can enable them with the /settings command.",
|
"disabledForUser": "✨ AI features are disabled for your account. You can enable them with the /settings command.",
|
||||||
"pulling": "🔄 Model {model} not found locally, pulling...",
|
"pulling": "🔄 Model {model} not found locally, pulling...",
|
||||||
|
@ -88,7 +90,37 @@
|
||||||
"noChatFound": "No chat found",
|
"noChatFound": "No chat found",
|
||||||
"pulled": "✅ Pulled {model} successfully, please retry the command.",
|
"pulled": "✅ Pulled {model} successfully, please retry the command.",
|
||||||
"selectTemperature": "*Please select a temperature:*",
|
"selectTemperature": "*Please select a temperature:*",
|
||||||
"temperatureExplanation": "Temperature controls the randomness of the AI's responses. Lower values (e.g., 0.2) make the model more focused and deterministic, while higher values (e.g., 1.2 or above) make it more creative and random."
|
"temperatureExplanation": "Temperature controls the randomness of the AI's responses. Lower values (e.g., 0.2) make the model more focused and deterministic, while higher values (e.g., 1.2 or above) make it more creative and random.",
|
||||||
|
"queueEmpty": "✅ The AI queue is currently empty.",
|
||||||
|
"queueList": "📋 *AI Queue Status*\n\n{queueItems}\n\n*Total items:* {totalItems}",
|
||||||
|
"queueItem": "• User: {username} ({userId})\n Model: {model}\n Status: {status}\n",
|
||||||
|
"queueCleared": "✅ Cleared {count} queue items for user {userId}.",
|
||||||
|
"queueClearError": "❌ Error clearing queue for user {userId}: {error}",
|
||||||
|
"noQueueItems": "ℹ️ No queue items found for user {userId}.",
|
||||||
|
"userTimedOut": "⏱️ User {userId} has been timed out from AI commands until {timeoutEnd}.",
|
||||||
|
"userTimeoutRemoved": "✅ AI timeout removed for user {userId}.",
|
||||||
|
"userTimeoutError": "❌ Error setting timeout for user {userId}: {error}",
|
||||||
|
"invalidDuration": "❌ Invalid duration format. Use: 1m, 1h, 1d, 1w, etc.",
|
||||||
|
"userExecTimeSet": "⏱️ Max execution time set to {duration} for user {userId}.",
|
||||||
|
"userExecTimeRemoved": "✅ Max execution time limit removed for user {userId}.",
|
||||||
|
"userExecTimeError": "❌ Error setting execution time for user {userId}: {error}",
|
||||||
|
"invalidUserId": "❌ Invalid user ID. Please provide a valid Telegram user ID.",
|
||||||
|
"userNotFound": "❌ User {userId} not found in database.",
|
||||||
|
"userTimedOutFromAI": "⏱️ You are currently timed out from AI commands until {timeoutEnd}.",
|
||||||
|
"requestTooLong": "⏱️ Your request is taking too long. It has been cancelled to prevent system overload.",
|
||||||
|
"userLimitsRemoved": "✅ All AI limits removed for user {userId}.",
|
||||||
|
"userLimitRemoveError": "❌ Error removing limits for user {userId}: {error}",
|
||||||
|
"limitsHeader": "📋 *Current AI Limits*",
|
||||||
|
"noLimitsSet": "✅ No AI limits are currently set.",
|
||||||
|
"timeoutLimitsHeader": "*🔒 Users with AI Timeouts:*",
|
||||||
|
"timeoutLimitItem": "• {displayName} ({userId}) - Until: {timeoutEnd}",
|
||||||
|
"execLimitsHeader": "*⏱️ Users with Execution Time Limits:*",
|
||||||
|
"execLimitItem": "• {displayName} ({userId}) - Max: {execTime}",
|
||||||
|
"limitsListError": "❌ Error retrieving limits: {error}",
|
||||||
|
"requestStopped": "🛑 Your AI request has been stopped.",
|
||||||
|
"requestRemovedFromQueue": "🛑 Your AI request has been removed from the queue.",
|
||||||
|
"noActiveRequest": "ℹ️ You don't have any active AI requests to stop.",
|
||||||
|
"executionTimeoutReached": "\n\n⏱️ Max execution time limit reached!"
|
||||||
},
|
},
|
||||||
"maInvalidModule": "Please provide a valid module ID from The Mod Archive.\nExample: `/modarchive 81574`",
|
"maInvalidModule": "Please provide a valid module ID from The Mod Archive.\nExample: `/modarchive 81574`",
|
||||||
"maDownloadError": "Error downloading the file. Check the module ID and try again.",
|
"maDownloadError": "Error downloading the file. Check the module ID and try again.",
|
||||||
|
@ -196,5 +228,10 @@
|
||||||
"header": "✨ *Your AI Usage Stats*",
|
"header": "✨ *Your AI Usage Stats*",
|
||||||
"requests": "*Total AI Requests:* {aiRequests}",
|
"requests": "*Total AI Requests:* {aiRequests}",
|
||||||
"characters": "*Total AI Characters:* {aiCharacters}\n_That's around {bookCount} books of text!_"
|
"characters": "*Total AI Characters:* {aiCharacters}\n_That's around {bookCount} books of text!_"
|
||||||
|
},
|
||||||
|
"twoFactor": {
|
||||||
|
"helpEntry": "🔒 2FA",
|
||||||
|
"helpDesc": "🔒 *2FA*\n\n- /2fa: Show your 2FA settings",
|
||||||
|
"codeMessage": "🔒 *{botName} 2FA*\n\nYour 2FA code is: `{code}`"
|
||||||
}
|
}
|
||||||
}
|
}
|
41
src/locales/portuguese.json → telegram/locales/portuguese.json
Normal file → Executable file
41
src/locales/portuguese.json → telegram/locales/portuguese.json
Normal file → Executable file
|
@ -18,6 +18,7 @@
|
||||||
},
|
},
|
||||||
"unexpectedErr": "Ocorreu um erro inesperado: {error}",
|
"unexpectedErr": "Ocorreu um erro inesperado: {error}",
|
||||||
"errInvalidOption": "Ops! Opção inválida!",
|
"errInvalidOption": "Ops! Opção inválida!",
|
||||||
|
"commandDisabled": "🚫 Este comando está atualmente desativado para sua conta.\n\nVocê pode habilitá-lo na interface web: {frontUrl}",
|
||||||
"kickingMyself": "*Já que você não precisa de mim, vou sair daqui.*",
|
"kickingMyself": "*Já que você não precisa de mim, vou sair daqui.*",
|
||||||
"kickingMyselfErr": "Erro ao sair do chat.",
|
"kickingMyselfErr": "Erro ao sair do chat.",
|
||||||
"noPermission": "Você não tem permissão para executar este comando.",
|
"noPermission": "Você não tem permissão para executar este comando.",
|
||||||
|
@ -66,7 +67,8 @@
|
||||||
"animalCommandsDesc": "🐱 *Animais*\n\n- /soggy | /soggycat `<1 | 2 | 3 | 4 | orig | thumb | sticker | alt>`: Envia o [meme do gato encharcado](https://knowyourmeme.com/memes/soggy-cat)\n- /cat - Envia uma foto aleatória de um gato.\n- /fox - Envia uma foto aleatória de uma raposa.\n- /duck - Envia uma foto aleatória de um pato.\n- /dog - Envia uma imagem aleatória de um cachorro.\n- /httpcat `<código http>`: Envia memes de gato do http.cat com o código HTTP especificado. Exemplo: `/httpcat 404`",
|
"animalCommandsDesc": "🐱 *Animais*\n\n- /soggy | /soggycat `<1 | 2 | 3 | 4 | orig | thumb | sticker | alt>`: Envia o [meme do gato encharcado](https://knowyourmeme.com/memes/soggy-cat)\n- /cat - Envia uma foto aleatória de um gato.\n- /fox - Envia uma foto aleatória de uma raposa.\n- /duck - Envia uma foto aleatória de um pato.\n- /dog - Envia uma imagem aleatória de um cachorro.\n- /httpcat `<código http>`: Envia memes de gato do http.cat com o código HTTP especificado. Exemplo: `/httpcat 404`",
|
||||||
"ai": {
|
"ai": {
|
||||||
"helpEntry": "✨ Comandos de IA",
|
"helpEntry": "✨ Comandos de IA",
|
||||||
"helpDesc": "✨ *Comandos de IA*\n\n- /ask `<prompt>`: Fazer uma pergunta a uma IA\n- /think `<prompt>`: Fazer uma pergunta a um modelo de pensamento\n- /ai `<prompt>`: Fazer uma pergunta a um modelo de IA personalizado\n- /aistats: Mostra suas estatísticas de uso de IA",
|
"helpDesc": "✨ *Comandos de IA*\n\n- /ask `<prompt>`: Fazer uma pergunta a uma IA\n- /think `<prompt>`: Fazer uma pergunta a um modelo de pensamento\n- /ai `<prompt>`: Fazer uma pergunta a um modelo de IA personalizado\n- /aistop: Parar sua solicitação de IA atual\n- /aistats: Mostra suas estatísticas de uso de IA",
|
||||||
|
"helpDescAdmin": "✨ *Comandos de IA*\n\n- /ask `<prompt>`: Fazer uma pergunta a uma IA\n- /think `<prompt>`: Fazer uma pergunta a um modelo de pensamento\n- /ai `<prompt>`: Fazer uma pergunta a um modelo de IA personalizado\n- /aistop: Parar sua solicitação de IA atual\n- /aistats: Mostra suas estatísticas de uso de IA\n\n*Comandos de Admin:*\n- /queue: Listar fila atual de IA\n- /qdel `<user_id>`: Limpar itens da fila para um usuário\n- /qlimit `<user_id>` `<duration>`: Timeout de usuário dos comandos de IA\n- /setexec `<user_id>` `<duration>`: Definir tempo máximo de execução para usuário\n- /rlimit `<user_id>`: Remover todos os limites de IA para usuário\n- /limits: Listar todos os limites atuais de IA",
|
||||||
"disabled": "A AIApi foi desativada\\.",
|
"disabled": "A AIApi foi desativada\\.",
|
||||||
"disabledForUser": "As funções de IA estão desativadas para a sua conta. Você pode ativá-las com o comando /settings.",
|
"disabledForUser": "As funções de IA estão desativadas para a sua conta. Você pode ativá-las com o comando /settings.",
|
||||||
"pulling": "🔄 Modelo {model} não encontrado localmente, baixando...",
|
"pulling": "🔄 Modelo {model} não encontrado localmente, baixando...",
|
||||||
|
@ -91,7 +93,37 @@
|
||||||
"statusComplete": "✅ Completo!",
|
"statusComplete": "✅ Completo!",
|
||||||
"modelHeader": "🤖 *{model}* 🌡️ *{temperature}* {status}",
|
"modelHeader": "🤖 *{model}* 🌡️ *{temperature}* {status}",
|
||||||
"noChatFound": "Nenhum chat encontrado",
|
"noChatFound": "Nenhum chat encontrado",
|
||||||
"pulled": "✅ {model} baixado com sucesso, por favor tente o comando novamente."
|
"pulled": "✅ {model} baixado com sucesso, por favor tente o comando novamente.",
|
||||||
|
"queueEmpty": "✅ A fila de IA está atualmente vazia.",
|
||||||
|
"queueList": "📋 *Status da Fila de IA*\n\n{queueItems}\n\n*Total de itens:* {totalItems}",
|
||||||
|
"queueItem": "• Usuário: {username} ({userId})\n Modelo: {model}\n Status: {status}\n",
|
||||||
|
"queueCleared": "✅ Limpos {count} itens da fila para o usuário {userId}.",
|
||||||
|
"queueClearError": "❌ Erro ao limpar fila para o usuário {userId}: {error}",
|
||||||
|
"noQueueItems": "ℹ️ Nenhum item da fila encontrado para o usuário {userId}.",
|
||||||
|
"userTimedOut": "⏱️ Usuário {userId} foi suspenso dos comandos de IA até {timeoutEnd}.",
|
||||||
|
"userTimeoutRemoved": "✅ Timeout de IA removido para o usuário {userId}.",
|
||||||
|
"userTimeoutError": "❌ Erro ao definir timeout para o usuário {userId}: {error}",
|
||||||
|
"invalidDuration": "❌ Formato de duração inválido. Use: 1m, 1h, 1d, 1w, etc.",
|
||||||
|
"userExecTimeSet": "⏱️ Tempo máximo de execução definido para {duration} para o usuário {userId}.",
|
||||||
|
"userExecTimeRemoved": "✅ Limite de tempo máximo de execução removido para o usuário {userId}.",
|
||||||
|
"userExecTimeError": "❌ Erro ao definir tempo de execução para o usuário {userId}: {error}",
|
||||||
|
"invalidUserId": "❌ ID de usuário inválido. Por favor, forneça um ID de usuário válido do Telegram.",
|
||||||
|
"userNotFound": "❌ Usuário {userId} não encontrado na base de dados.",
|
||||||
|
"userTimedOutFromAI": "⏱️ Você está atualmente suspenso dos comandos de IA até {timeoutEnd}.",
|
||||||
|
"requestTooLong": "⏱️ Sua solicitação está demorando muito. Foi cancelada para evitar sobrecarga do sistema.",
|
||||||
|
"userLimitsRemoved": "✅ Todos os limites de IA removidos para o usuário {userId}.",
|
||||||
|
"userLimitRemoveError": "❌ Erro ao remover limites para o usuário {userId}: {error}",
|
||||||
|
"limitsHeader": "📋 *Limites Atuais de IA*",
|
||||||
|
"noLimitsSet": "✅ Nenhum limite de IA está atualmente definido.",
|
||||||
|
"timeoutLimitsHeader": "*🔒 Usuários com Timeouts de IA:*",
|
||||||
|
"timeoutLimitItem": "• {displayName} ({userId}) - Até: {timeoutEnd}",
|
||||||
|
"execLimitsHeader": "*⏱️ Usuários com Limites de Tempo de Execução:*",
|
||||||
|
"execLimitItem": "• {displayName} ({userId}) - Máx: {execTime}",
|
||||||
|
"limitsListError": "❌ Erro ao recuperar limites: {error}",
|
||||||
|
"requestStopped": "🛑 Sua solicitação de IA foi interrompida.",
|
||||||
|
"requestRemovedFromQueue": "🛑 Sua solicitação de IA foi removida da fila.",
|
||||||
|
"noActiveRequest": "ℹ️ Você não tem nenhuma solicitação ativa de IA para parar.",
|
||||||
|
"executionTimeoutReached": "\n\n⏱️ Limite máximo de tempo de execução atingido!"
|
||||||
},
|
},
|
||||||
"maInvalidModule": "Por favor, forneça um ID de módulo válido do The Mod Archive.\nExemplo: `/modarchive 81574`",
|
"maInvalidModule": "Por favor, forneça um ID de módulo válido do The Mod Archive.\nExemplo: `/modarchive 81574`",
|
||||||
"maDownloadError": "Erro ao baixar o arquivo. Verifique o ID do módulo e tente novamente.",
|
"maDownloadError": "Erro ao baixar o arquivo. Verifique o ID do módulo e tente novamente.",
|
||||||
|
@ -191,5 +223,10 @@
|
||||||
"header": "✨ *Suas estatísticas de uso de IA*",
|
"header": "✨ *Suas estatísticas de uso de IA*",
|
||||||
"requests": "*Total de requisições de IA:* {aiRequests}",
|
"requests": "*Total de requisições de IA:* {aiRequests}",
|
||||||
"characters": "*Total de caracteres de IA:* {aiCharacters}\n_Isso é cerca de {bookCount} livros de texto!_"
|
"characters": "*Total de caracteres de IA:* {aiCharacters}\n_Isso é cerca de {bookCount} livros de texto!_"
|
||||||
|
},
|
||||||
|
"twoFactor": {
|
||||||
|
"helpEntry": "🔒 2FA",
|
||||||
|
"helpDesc": "🔒 *2FA*\n\n- /2fa: Mostra suas configurações de 2FA",
|
||||||
|
"codeMessage": "🔒 *{botName} 2FA*\n\nSeu código de 2FA é: `{code}`"
|
||||||
}
|
}
|
||||||
}
|
}
|
0
src/plugins/checklang.ts → telegram/plugins/checklang.ts
Normal file → Executable file
0
src/plugins/checklang.ts → telegram/plugins/checklang.ts
Normal file → Executable file
0
src/plugins/verifyInput.ts → telegram/plugins/verifyInput.ts
Normal file → Executable file
0
src/plugins/verifyInput.ts → telegram/plugins/verifyInput.ts
Normal file → Executable file
0
src/plugins/ytDlpWrapper.ts → telegram/plugins/ytDlpWrapper.ts
Normal file → Executable file
0
src/plugins/ytDlpWrapper.ts → telegram/plugins/ytDlpWrapper.ts
Normal file → Executable file
0
src/props/resources.json → telegram/props/resources.json
Normal file → Executable file
0
src/props/resources.json → telegram/props/resources.json
Normal file → Executable file
72
telegram/utils/check-command-disabled.ts
Executable file
72
telegram/utils/check-command-disabled.ts
Executable file
|
@ -0,0 +1,72 @@
|
||||||
|
// CHECK-COMMAND-DISABLED.TS
|
||||||
|
// by ihatenodejs/Aidan
|
||||||
|
//
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
//
|
||||||
|
// Anyone is free to copy, modify, publish, use, compile, sell, or
|
||||||
|
// distribute this software, either in source code form or as a compiled
|
||||||
|
// binary, for any purpose, commercial or non-commercial, and by any
|
||||||
|
// means.
|
||||||
|
//
|
||||||
|
// In jurisdictions that recognize copyright laws, the author or authors
|
||||||
|
// of this software dedicate any and all copyright interest in the
|
||||||
|
// software to the public domain. We make this dedication for the benefit
|
||||||
|
// of the public at large and to the detriment of our heirs and
|
||||||
|
// successors. We intend this dedication to be an overt act of
|
||||||
|
// relinquishment in perpetuity of all present and future rights to this
|
||||||
|
// software under copyright law.
|
||||||
|
//
|
||||||
|
// 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 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.
|
||||||
|
//
|
||||||
|
// For more information, please refer to <https://unlicense.org/>
|
||||||
|
|
||||||
|
import { Context } from 'telegraf';
|
||||||
|
import { getStrings } from '../plugins/checklang';
|
||||||
|
import { replyToMessageId } from './reply-to-message-id';
|
||||||
|
|
||||||
|
export async function isCommandDisabled(ctx: Context, db: any, commandId: string): Promise<boolean> {
|
||||||
|
if (!ctx.from) return false;
|
||||||
|
|
||||||
|
const telegramId = String(ctx.from.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await db.query.usersTable.findFirst({
|
||||||
|
where: (fields, { eq }) => eq(fields.telegramId, telegramId),
|
||||||
|
columns: {
|
||||||
|
disabledCommands: true,
|
||||||
|
languageCode: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) return false;
|
||||||
|
|
||||||
|
const isDisabled = user.disabledCommands?.includes(commandId) || false;
|
||||||
|
|
||||||
|
if (isDisabled) {
|
||||||
|
const Strings = getStrings(user.languageCode);
|
||||||
|
const frontUrl = process.env.frontUrl || 'https://kowalski.social';
|
||||||
|
const reply_to_message_id = replyToMessageId(ctx);
|
||||||
|
|
||||||
|
await ctx.reply(
|
||||||
|
Strings.commandDisabled.replace('{frontUrl}', frontUrl),
|
||||||
|
{
|
||||||
|
parse_mode: 'Markdown',
|
||||||
|
...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return isDisabled;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[💽 DB] Error checking disabled commands:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
5
src/utils/ensure-user.ts → telegram/utils/ensure-user.ts
Normal file → Executable file
5
src/utils/ensure-user.ts → telegram/utils/ensure-user.ts
Normal file → Executable file
|
@ -28,7 +28,7 @@
|
||||||
//
|
//
|
||||||
// For more information, please refer to <https://unlicense.org/>
|
// For more information, please refer to <https://unlicense.org/>
|
||||||
|
|
||||||
import { usersTable } from '../db/schema';
|
import { usersTable } from '../../database/schema';
|
||||||
|
|
||||||
export async function ensureUserInDb(ctx, db) {
|
export async function ensureUserInDb(ctx, db) {
|
||||||
if (!ctx.from) return;
|
if (!ctx.from) return;
|
||||||
|
@ -52,6 +52,9 @@ export async function ensureUserInDb(ctx, db) {
|
||||||
aiTemperature: 0.9,
|
aiTemperature: 0.9,
|
||||||
aiRequests: 0,
|
aiRequests: 0,
|
||||||
aiCharacters: 0,
|
aiCharacters: 0,
|
||||||
|
disabledCommands: [],
|
||||||
|
aiTimeoutUntil: null,
|
||||||
|
aiMaxExecutionTime: 0,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
await db.insert(usersTable).values(userToInsert);
|
await db.insert(usersTable).values(userToInsert);
|
0
src/utils/language-code.ts → telegram/utils/language-code.ts
Normal file → Executable file
0
src/utils/language-code.ts → telegram/utils/language-code.ts
Normal file → Executable file
0
src/utils/log.ts → telegram/utils/log.ts
Normal file → Executable file
0
src/utils/log.ts → telegram/utils/log.ts
Normal file → Executable file
0
src/utils/rate-limiter.ts → telegram/utils/rate-limiter.ts
Normal file → Executable file
0
src/utils/rate-limiter.ts → telegram/utils/rate-limiter.ts
Normal file → Executable file
0
src/utils/reply-to-message-id.ts → telegram/utils/reply-to-message-id.ts
Normal file → Executable file
0
src/utils/reply-to-message-id.ts → telegram/utils/reply-to-message-id.ts
Normal file → Executable file
41
webui/.gitignore
vendored
Executable file
41
webui/.gitignore
vendored
Executable file
|
@ -0,0 +1,41 @@
|
||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
24
webui/LICENSE
Normal file
24
webui/LICENSE
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
This is free and unencumbered software released into the public domain.
|
||||||
|
|
||||||
|
Anyone is free to copy, modify, publish, use, compile, sell, or
|
||||||
|
distribute this software, either in source code form or as a compiled
|
||||||
|
binary, for any purpose, commercial or non-commercial, and by any
|
||||||
|
means.
|
||||||
|
|
||||||
|
In jurisdictions that recognize copyright laws, the author or authors
|
||||||
|
of this software dedicate any and all copyright interest in the
|
||||||
|
software to the public domain. We make this dedication for the benefit
|
||||||
|
of the public at large and to the detriment of our heirs and
|
||||||
|
successors. We intend this dedication to be an overt act of
|
||||||
|
relinquishment in perpetuity of all present and future rights to this
|
||||||
|
software under copyright law.
|
||||||
|
|
||||||
|
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 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.
|
||||||
|
|
||||||
|
For more information, please refer to <https://unlicense.org/>
|
549
webui/app/about/page.tsx
Executable file
549
webui/app/about/page.tsx
Executable file
|
@ -0,0 +1,549 @@
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Sparkles,
|
||||||
|
Users,
|
||||||
|
Download,
|
||||||
|
Brain,
|
||||||
|
Shield,
|
||||||
|
Zap,
|
||||||
|
Tv,
|
||||||
|
Heart,
|
||||||
|
Code,
|
||||||
|
Globe,
|
||||||
|
MessageSquare,
|
||||||
|
Layers,
|
||||||
|
Network,
|
||||||
|
Lock,
|
||||||
|
UserCheck,
|
||||||
|
BarChart3,
|
||||||
|
Languages,
|
||||||
|
Trash2,
|
||||||
|
FileText,
|
||||||
|
Headphones,
|
||||||
|
CloudSun,
|
||||||
|
Smartphone,
|
||||||
|
Dices,
|
||||||
|
Cat,
|
||||||
|
Music,
|
||||||
|
Bot
|
||||||
|
} from "lucide-react";
|
||||||
|
import { SiTypescript, SiPostgresql, SiDocker, SiNextdotjs, SiBun, SiForgejo } from "react-icons/si";
|
||||||
|
import { RiTelegram2Line } from "react-icons/ri";
|
||||||
|
import { BsInfoLg } from "react-icons/bs";
|
||||||
|
import { TbRocket, TbSparkles } from "react-icons/tb";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { TbPalette } from "react-icons/tb";
|
||||||
|
import Footer from "@/components/footer";
|
||||||
|
|
||||||
|
export default function About() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col min-h-screen">
|
||||||
|
<section className="flex flex-col items-center justify-center py-24 px-6 text-center bg-gradient-to-br from-background to-muted">
|
||||||
|
<div className="max-w-4xl mx-auto space-y-8">
|
||||||
|
<div className="flex items-center justify-center mb-6">
|
||||||
|
<div className="flex items-center justify-center w-20 h-20 rounded-full bg-primary/10 p-4">
|
||||||
|
<BsInfoLg className="w-10 h-10" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-6xl font-bold tracking-tight bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent">
|
||||||
|
About Kowalski
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-xl text-muted-foreground max-w-3xl mx-auto leading-relaxed">
|
||||||
|
Kowalski is an open-source, feature-rich Telegram bot built with modern web technologies.
|
||||||
|
From AI-powered conversations to video downloads, user management, and community features —
|
||||||
|
it's designed to enhance your Telegram experience while respecting your privacy.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center pt-8">
|
||||||
|
<Button size="lg" className="min-w-32" asChild>
|
||||||
|
<Link href="https://git.p0ntus.com/ABOCN/TelegramBot" target="_blank">
|
||||||
|
<SiForgejo />
|
||||||
|
View Source Code
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="lg" className="min-w-32" asChild>
|
||||||
|
<Link href="https://p0ntus.com/services/hosting" target="_blank">
|
||||||
|
<TbRocket />
|
||||||
|
Deploy free with p0ntus
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="lg" className="min-w-32" asChild>
|
||||||
|
<Link href="https://t.me/KowalskiNodeBot" target="_blank">
|
||||||
|
<RiTelegram2Line />
|
||||||
|
Try on Telegram
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="py-24 px-6">
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
<div className="text-center mb-16">
|
||||||
|
<h2 className="text-4xl font-bold mb-4">Architecture</h2>
|
||||||
|
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||||
|
We've built Kowalski with modern technologies and best practices for reliability and maintainability.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center justify-center w-12 h-12 rounded-lg bg-blue-500/10 text-blue-500">
|
||||||
|
<Code className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-2xl font-semibold">Tech Stack</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground leading-relaxed">
|
||||||
|
Kowalski is built completely in TypeScript with Node.js and Telegraf.
|
||||||
|
The web interface uses Next.js with Tailwind CSS, while data persistence is handled by PostgreSQL with Drizzle ORM.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||||
|
<SiTypescript className="w-5 h-5 mx-3 text-blue-500" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">TypeScript + Node.js</div>
|
||||||
|
<div className="text-sm text-muted-foreground">Type-safe backend w/ Telegraf</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||||
|
<SiNextdotjs className="w-5 h-5 mx-3 text-blue-500" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">Next.js WebUI</div>
|
||||||
|
<div className="text-sm text-muted-foreground">Modern, responsive admin and user panel</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||||
|
<SiPostgresql className="w-5 h-5 mx-3 text-blue-500" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">PostgreSQL + Drizzle ORM</div>
|
||||||
|
<div className="text-sm text-muted-foreground">Reliable data persistence</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center justify-center w-12 h-12 rounded-lg bg-green-500/10 text-green-500">
|
||||||
|
<SiDocker className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-2xl font-semibold">Deployment</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground leading-relaxed">
|
||||||
|
Kowalski is built to be deployed anywhere, and has been tested on multiple platforms.
|
||||||
|
We prioritize support for Docker and Bun for easy deployment.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-background border">
|
||||||
|
<SiDocker className="w-5 h-5 mx-3 text-green-500" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">Docker Support</div>
|
||||||
|
<div className="text-sm text-muted-foreground">Easy containerized deployment w/ Docker Compose</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-background border">
|
||||||
|
<SiBun className="w-5 h-5 mx-3 text-green-500" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">Bun</div>
|
||||||
|
<div className="text-sm text-muted-foreground">A fast JavaScript runtime for best performance</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-background border">
|
||||||
|
<Layers className="w-5 h-5 mx-3 text-green-500" />
|
||||||
|
<div> {/* some ppl probably don't know what af means :( */}
|
||||||
|
<div className="font-medium">Modular AF Backend</div>
|
||||||
|
<div className="text-sm text-muted-foreground">Command-based structure for easy feature addition</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="py-24 px-6 bg-muted/30">
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
<div className="text-center mb-16">
|
||||||
|
<h2 className="text-4xl font-bold mb-4">AI Integrations</h2>
|
||||||
|
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||||
|
Powered by Ollama, Kowalski has support for 50+ AI models, with customizable
|
||||||
|
options for users and admins.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center justify-center w-12 h-12 rounded-lg bg-purple-500/10 text-purple-500">
|
||||||
|
<Brain className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-2xl font-semibold">Vast Model Support</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground leading-relaxed">
|
||||||
|
Kowalski has support for 50+ models, both thinking and non-thinking. We have
|
||||||
|
good Markdown parsing, with customizable options for both users and admins.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-background border">
|
||||||
|
<TbSparkles className="w-5 h-5 mx-3 text-purple-500" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">/ask - Quick Responses</div>
|
||||||
|
<div className="text-sm text-muted-foreground">Fast answers using smaller non-thinking models</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-background border">
|
||||||
|
<Brain className="w-5 h-5 mx-3 text-purple-500" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">/think - Deep Reasoning</div>
|
||||||
|
<div className="text-sm text-muted-foreground">Advanced thinking models with togglable reasoning visibility</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-background border">
|
||||||
|
<Bot className="w-5 h-5 mx-3 text-purple-500" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">/ai - Your Custom Model!</div>
|
||||||
|
<div className="text-sm text-muted-foreground">Use your personally configured AI model</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center justify-center w-12 h-12 rounded-lg bg-orange-500/10 text-orange-500">
|
||||||
|
<Zap className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-2xl font-semibold">Kowalski's <span className="italic">Powerful</span></h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground leading-relaxed">
|
||||||
|
We have amazing Markdown V2 parsing, queue management, and usage statistics tracking.
|
||||||
|
It's hella private, too. AI is disabled by default for the best user experience.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||||
|
<Network className="w-5 h-5 mx-3 text-orange-500" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">Streaming</div>
|
||||||
|
<div className="text-sm text-muted-foreground">Real-time Markdown V2 message updates as the model generates</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||||
|
<BarChart3 className="w-5 h-5 mx-3 text-orange-500" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">Usage Stats</div>
|
||||||
|
<div className="text-sm text-muted-foreground">Track your AI requests and usage with /aistats</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||||
|
<UserCheck className="w-5 h-5 mx-3 text-orange-500" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">Queues</div>
|
||||||
|
<div className="text-sm text-muted-foreground">High usage limits with intelligent request queuing</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="py-24 px-6">
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
<div className="text-center mb-16">
|
||||||
|
<h2 className="text-4xl font-bold mb-4">We're User-First</h2>
|
||||||
|
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||||
|
Kowalski has privacy-focused user management with customizable settings,
|
||||||
|
multilingual support, and transparent data handling.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center justify-center w-12 h-12 rounded-lg bg-emerald-500/10 text-emerald-500">
|
||||||
|
<Lock className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-2xl font-semibold">Privacy</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground leading-relaxed">
|
||||||
|
User data is minimized and linked only by Telegram ID. No personal information
|
||||||
|
is shared with third parties, and users maintain full control over their data
|
||||||
|
with easy account deletion options.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||||
|
<Shield className="w-5 h-5 mx-3 text-emerald-500" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">Limited Data Collection</div>
|
||||||
|
<div className="text-sm text-muted-foreground">Only essential data is stored, linked by Telegram ID</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||||
|
<FileText className="w-5 h-5 mx-3 text-emerald-500" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">Transparent Policies</div>
|
||||||
|
<div className="text-sm text-muted-foreground">Clear privacy policy accessible via /privacy</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||||
|
<Trash2 className="w-5 h-5 mx-3 text-emerald-500" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">Easy Account Deletion</div>
|
||||||
|
<div className="text-sm text-muted-foreground">You can delete your data at any time</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center justify-center w-12 h-12 rounded-lg bg-blue-500/10 text-blue-500">
|
||||||
|
<TbPalette className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-2xl font-semibold">Customization</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground leading-relaxed">
|
||||||
|
Personalize your experience with custom AI preferences,
|
||||||
|
temperature settings, language selection, and detailed usage statistics.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-background border">
|
||||||
|
<Bot className="w-5 h-5 mx-3 text-blue-500" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">AI Preferences</div>
|
||||||
|
<div className="text-sm text-muted-foreground">Choose default models and configure temperature</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-background border">
|
||||||
|
<Languages className="w-5 h-5 mx-3 text-blue-500" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">Multilingual Support</div>
|
||||||
|
<div className="text-sm text-muted-foreground">English and Portuguese language options</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-background border">
|
||||||
|
<BarChart3 className="w-5 h-5 mx-3 text-blue-500" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">Usage Analytics</div>
|
||||||
|
<div className="text-sm text-muted-foreground">Personal statistics and usage tracking</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="py-24 px-6 bg-muted/30">
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
<div className="text-center mb-16">
|
||||||
|
<h2 className="text-4xl font-bold mb-4">There's <span className="text-5xl">WAYYYYY</span> more!</h2>
|
||||||
|
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||||
|
Beyond AI, Kowalski has a ton of entertainment, utility, fun, configuration, and information
|
||||||
|
commands.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-3 gap-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-red-500/10 text-red-500">
|
||||||
|
<Download className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-semibold">Media Downloads</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||||
|
Download videos from YouTube and 1000s of other platforms using yt-dlp.
|
||||||
|
Featuring automatic size checking for Telegram'.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Tv className="w-4 h-4 text-red-500" />
|
||||||
|
<span>/yt [URL] - Video downloads</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Shield className="w-4 h-4 text-red-500" />
|
||||||
|
<span>Automatic size limit handling</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-blue-500/10 text-blue-500">
|
||||||
|
<Globe className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-semibold">Information & Utilities</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||||
|
Access real-world information like weather reports, device specifications,
|
||||||
|
HTTP status codes, and a Last.fm music integration.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<CloudSun className="w-4 h-4 text-blue-500" />
|
||||||
|
<span>/weather - Weather reports</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Smartphone className="w-4 h-4 text-blue-500" />
|
||||||
|
<span>/device - GSMArena specs</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Headphones className="w-4 h-4 text-blue-500" />
|
||||||
|
<span>/last - Last.fm integration</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-green-500/10 text-green-500">
|
||||||
|
<Heart className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-semibold">Entertainment</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||||
|
Interactive emojis, random animal pictures, My Little Pony,
|
||||||
|
and fun commands to engage you and your community.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Dices className="w-4 h-4 text-green-500" />
|
||||||
|
<span>/dice, /slot - Interactive games</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Cat className="w-4 h-4 text-green-500" />
|
||||||
|
<span>/cat, /dog - Random animals</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Music className="w-4 h-4 text-green-500" />
|
||||||
|
<span>/mlp - My Little Pony DB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="py-24 px-6">
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
<div className="text-center mb-16">
|
||||||
|
<h2 className="text-4xl font-bold mb-4">Our Community</h2>
|
||||||
|
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||||
|
Kowalski is built by developers, for developers. We use open licenses and
|
||||||
|
take input from our development communities.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center justify-center w-12 h-12 rounded-lg bg-purple-500/10 text-purple-500">
|
||||||
|
<SiForgejo className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-2xl font-semibold">Open Development</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground leading-relaxed">
|
||||||
|
Kowalski is licensed under BSD-3-Clause with components under Unlicense. Our
|
||||||
|
codebase is available on our Forgejo and GitHub, with lots of documentation.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||||
|
<SiForgejo className="w-5 h-5 mx-3 text-purple-500" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">Public Code</div>
|
||||||
|
<div className="text-sm text-muted-foreground">Feel free to contribute or review our code</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||||
|
<FileText className="w-5 h-5 mx-3 text-purple-500" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">Documentation</div>
|
||||||
|
<div className="text-sm text-muted-foreground">We have documentation to help contributors, users, and admins</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||||
|
<Users className="w-5 h-5 mx-3 text-purple-500" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">Contributor Friendly</div>
|
||||||
|
<div className="text-sm text-muted-foreground">Our communities are welcoming to new contributors</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center justify-center w-12 h-12 rounded-lg bg-orange-500/10 text-orange-500">
|
||||||
|
<Heart className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-2xl font-semibold">Community Centric</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground leading-relaxed">
|
||||||
|
Kowalski was created by Lucas Gabriel (lucmsilva). It is now also maintained by ihatenodejs,
|
||||||
|
givfnz2, and other contributors. Thank you to all of our contributors!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-background border">
|
||||||
|
<MessageSquare className="w-5 h-5 mx-3 text-orange-500" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">Active Maintenance</div>
|
||||||
|
<div className="text-sm text-muted-foreground">Regular updates and fixes w/ room for input and feedback</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-background border">
|
||||||
|
<Code className="w-5 h-5 mx-3 text-orange-500" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">Quality Code</div>
|
||||||
|
<div className="text-sm text-muted-foreground">We use TypeScript, linting, and modern standards</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-background border">
|
||||||
|
<Sparkles className="w-5 h-5 mx-3 text-orange-500" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">Focus on New Features</div>
|
||||||
|
<div className="text-sm text-muted-foreground">We are always looking for new features to add</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-16 text-center">
|
||||||
|
<div className="inline-flex items-center gap-4 p-6 rounded-lg bg-muted/50 border">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<SiForgejo className="w-5 h-5" />
|
||||||
|
<span className="font-medium">Ready to contribute?</span>
|
||||||
|
</div>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="https://git.p0ntus.com/ABOCN/TelegramBot" target="_blank">
|
||||||
|
<SiForgejo />
|
||||||
|
View on Forgejo
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
204
webui/app/account/delete/page.tsx
Executable file
204
webui/app/account/delete/page.tsx
Executable file
|
@ -0,0 +1,204 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Trash2, ArrowLeft, AlertTriangle } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
export default function DeleteAccountPage() {
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const { user, isAuthenticated, loading } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleDeleteAccount = async () => {
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
setIsDeleting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/user/delete', {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
alert('Your account has been deleted. You will now be redirected to the home page. Thanks for using Kowalski!');
|
||||||
|
window.location.href = '/';
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(`Failed to delete account: ${error.message || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting account:', error);
|
||||||
|
alert('An error occurred while deleting your account. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
setDialogOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
router.push('/login');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full bg-background">
|
||||||
|
<div className="container mx-auto px-6 py-8 max-w-2xl">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4 mb-8">
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<Link href="/account">
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
Back to Account
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-red-500/10 flex items-center justify-center">
|
||||||
|
<Trash2 className="w-8 h-8 text-red-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">Delete Account</h1>
|
||||||
|
<p className="text-muted-foreground">Permanently remove your account and data</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-6">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertTriangle className="w-6 h-6 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="font-semibold text-yellow-800 dark:text-yellow-200">
|
||||||
|
This action cannot be undone
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-yellow-700 dark:text-yellow-300">
|
||||||
|
Deleting your account will permanently remove all your data, including:
|
||||||
|
</p>
|
||||||
|
<ul className="text-sm text-yellow-700 dark:text-yellow-300 list-disc list-inside space-y-1 ml-2">
|
||||||
|
<li>Your user profile and settings</li>
|
||||||
|
<li>AI usage statistics and request history</li>
|
||||||
|
<li>Custom AI model preferences</li>
|
||||||
|
<li>Command configuration and disabled commands</li>
|
||||||
|
<li>All associated sessions and authentication data</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-card border rounded-lg p-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Account Information</h3>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Username:</span>
|
||||||
|
<span className="font-medium">@{user?.username}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Name:</span>
|
||||||
|
<span className="font-medium">{user?.firstName} {user?.lastName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Telegram ID:</span>
|
||||||
|
<span className="font-medium">{user?.telegramId}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">AI Requests:</span>
|
||||||
|
<span className="font-medium">{user?.aiRequests.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-6 border-t">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">Ready to delete your account?</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This will immediately and permanently delete your account.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="destructive" className="gap-2">
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
Delete Account
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-red-600" />
|
||||||
|
Confirm Account Deletion
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="space-y-2">
|
||||||
|
<p>
|
||||||
|
Are you absolutely sure you want to delete your account? This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
Your account <span className="font-bold">@{user?.username}</span> and all associated data will be permanently removed.
|
||||||
|
</p>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDialogOpen(false)}
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleDeleteAccount}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{isDeleting ? (
|
||||||
|
<>
|
||||||
|
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||||
|
Deleting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
Yes, Delete Account
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
725
webui/app/account/page.tsx
Executable file
725
webui/app/account/page.tsx
Executable file
|
@ -0,0 +1,725 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||||
|
import {
|
||||||
|
User,
|
||||||
|
Bot,
|
||||||
|
Brain,
|
||||||
|
Settings,
|
||||||
|
CloudSun,
|
||||||
|
Smartphone,
|
||||||
|
Heart,
|
||||||
|
Cat,
|
||||||
|
Dices,
|
||||||
|
Thermometer,
|
||||||
|
BarChart3,
|
||||||
|
LogOut,
|
||||||
|
Edit3,
|
||||||
|
Save,
|
||||||
|
X,
|
||||||
|
Network,
|
||||||
|
Cpu,
|
||||||
|
Languages,
|
||||||
|
Bug,
|
||||||
|
Lightbulb,
|
||||||
|
ExternalLink,
|
||||||
|
Quote,
|
||||||
|
Info,
|
||||||
|
Shuffle,
|
||||||
|
Rainbow,
|
||||||
|
Database,
|
||||||
|
Hash,
|
||||||
|
Download,
|
||||||
|
Archive
|
||||||
|
} from "lucide-react";
|
||||||
|
import { RiTelegram2Line } from "react-icons/ri";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { ModelPicker } from "@/components/account/model-picker";
|
||||||
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
|
import { FaLastfm } from "react-icons/fa";
|
||||||
|
import { TiInfinity } from "react-icons/ti";
|
||||||
|
|
||||||
|
interface CommandCard {
|
||||||
|
id: string;
|
||||||
|
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
commands: string[];
|
||||||
|
category: "ai" | "entertainment" | "utility" | "media" | "admin" | "animals";
|
||||||
|
gradient: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allCommands: CommandCard[] = [
|
||||||
|
{
|
||||||
|
id: "ai-ask-think",
|
||||||
|
icon: Brain,
|
||||||
|
title: "AI Chats",
|
||||||
|
description: "Chat with AI models and use deep thinking",
|
||||||
|
commands: ["/ask", "/think"],
|
||||||
|
category: "ai",
|
||||||
|
gradient: "from-purple-500 to-pink-500",
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ai-custom",
|
||||||
|
icon: Bot,
|
||||||
|
title: "Custom AI Model",
|
||||||
|
description: "Use your personally configured AI model",
|
||||||
|
commands: ["/ai"],
|
||||||
|
category: "ai",
|
||||||
|
gradient: "from-indigo-500 to-purple-500",
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ai-stats",
|
||||||
|
icon: BarChart3,
|
||||||
|
title: "AI Statistics",
|
||||||
|
description: "View your AI usage statistics",
|
||||||
|
commands: ["/aistats"],
|
||||||
|
category: "ai",
|
||||||
|
gradient: "from-purple-600 to-indigo-600",
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "games-dice",
|
||||||
|
icon: Dices,
|
||||||
|
title: "Interactive Emojis",
|
||||||
|
description: "Roll dice, play slots, and other interactive emojis",
|
||||||
|
commands: ["/dice", "/slot", "/ball", "/dart", "/bowling"],
|
||||||
|
category: "entertainment",
|
||||||
|
gradient: "from-green-500 to-teal-500",
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "fun-random",
|
||||||
|
icon: Shuffle,
|
||||||
|
title: "Fun Commands",
|
||||||
|
description: "Random numbers and fun responses",
|
||||||
|
commands: ["/random", "/furry", "/gay"],
|
||||||
|
category: "entertainment",
|
||||||
|
gradient: "from-pink-500 to-rose-500",
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "infinite-dice",
|
||||||
|
icon: TiInfinity,
|
||||||
|
title: "Infinite Dice",
|
||||||
|
description: "Sends an infinite dice sticker",
|
||||||
|
commands: ["/idice"],
|
||||||
|
category: "entertainment",
|
||||||
|
gradient: "from-yellow-500 to-orange-500",
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "animals-basic",
|
||||||
|
icon: Cat,
|
||||||
|
title: "Animal Pictures",
|
||||||
|
description: "Get random cute animal pictures",
|
||||||
|
commands: ["/cat", "/dog", "/duck", "/fox"],
|
||||||
|
category: "animals",
|
||||||
|
gradient: "from-orange-500 to-red-500",
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "soggy-cat",
|
||||||
|
icon: Heart,
|
||||||
|
title: "Soggy Cat",
|
||||||
|
description: "Wet cats!",
|
||||||
|
commands: ["/soggy", "/soggycat"],
|
||||||
|
category: "animals",
|
||||||
|
gradient: "from-blue-500 to-purple-500",
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "weather",
|
||||||
|
icon: CloudSun,
|
||||||
|
title: "Weather",
|
||||||
|
description: "Get current weather for any location",
|
||||||
|
commands: ["/weather", "/clima"],
|
||||||
|
category: "utility",
|
||||||
|
gradient: "from-blue-500 to-cyan-500",
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "device-specs",
|
||||||
|
icon: Smartphone,
|
||||||
|
title: "Device Specifications",
|
||||||
|
description: "Look up phone specifications via GSMArena",
|
||||||
|
commands: ["/device", "/d"],
|
||||||
|
category: "utility",
|
||||||
|
gradient: "from-slate-500 to-gray-500",
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "http-status",
|
||||||
|
icon: Network,
|
||||||
|
title: "HTTP Status Codes",
|
||||||
|
description: "Look up HTTP status codes and meanings",
|
||||||
|
commands: ["/http", "/httpcat"],
|
||||||
|
category: "utility",
|
||||||
|
gradient: "from-emerald-500 to-green-500",
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "codename-lookup",
|
||||||
|
icon: Hash,
|
||||||
|
title: "Codename Lookup",
|
||||||
|
description: "Look up codenames and meanings",
|
||||||
|
commands: ["/codename", "/whatis"],
|
||||||
|
category: "utility",
|
||||||
|
gradient: "from-teal-500 to-cyan-500",
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "info-commands",
|
||||||
|
icon: Info,
|
||||||
|
title: "Information",
|
||||||
|
description: "Get chat and user information",
|
||||||
|
commands: ["/chatinfo", "/userinfo"],
|
||||||
|
category: "utility",
|
||||||
|
gradient: "from-indigo-500 to-blue-500",
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "quotes",
|
||||||
|
icon: Quote,
|
||||||
|
title: "Random Quotes",
|
||||||
|
description: "Get random quotes",
|
||||||
|
commands: ["/quote"],
|
||||||
|
category: "utility",
|
||||||
|
gradient: "from-amber-500 to-yellow-500",
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "youtube-download",
|
||||||
|
icon: Download,
|
||||||
|
title: "Video Downloads",
|
||||||
|
description: "Download videos from YouTube and 1000+ platforms",
|
||||||
|
commands: ["/yt", "/ytdl", "/video", "/dl"],
|
||||||
|
category: "media",
|
||||||
|
gradient: "from-red-500 to-pink-500",
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "lastfm",
|
||||||
|
icon: FaLastfm,
|
||||||
|
title: "Last.fm Integration",
|
||||||
|
description: "Connect your music listening history",
|
||||||
|
commands: ["/last", "/lfm", "/setuser"],
|
||||||
|
category: "media",
|
||||||
|
gradient: "from-violet-500 to-purple-500",
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mlp-content",
|
||||||
|
icon: Database,
|
||||||
|
title: "MLP Database",
|
||||||
|
description: "My Little Pony content and information",
|
||||||
|
commands: ["/mlp", "/mlpchar", "/mlpep", "/mlpcomic"],
|
||||||
|
category: "media",
|
||||||
|
gradient: "from-fuchsia-500 to-pink-500",
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "modarchive",
|
||||||
|
icon: Archive,
|
||||||
|
title: "Mod Archive",
|
||||||
|
description: "Access classic tracker music files",
|
||||||
|
commands: ["/modarchive", "/tma"],
|
||||||
|
category: "media",
|
||||||
|
gradient: "from-cyan-500 to-blue-500",
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "random-pony",
|
||||||
|
icon: Rainbow,
|
||||||
|
title: "Random Pony Art",
|
||||||
|
description: "Get random My Little Pony artwork",
|
||||||
|
commands: ["/rpony", "/randompony", "/mlpart"],
|
||||||
|
category: "media",
|
||||||
|
gradient: "from-pink-500 to-purple-500",
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const categoryColors = {
|
||||||
|
ai: "bg-purple-500/10 text-purple-600 border-purple-200 dark:border-purple-800",
|
||||||
|
entertainment: "bg-green-500/10 text-green-600 border-green-200 dark:border-green-800",
|
||||||
|
utility: "bg-blue-500/10 text-blue-600 border-blue-200 dark:border-blue-800",
|
||||||
|
media: "bg-red-500/10 text-red-600 border-red-200 dark:border-red-800",
|
||||||
|
admin: "bg-orange-500/10 text-orange-600 border-orange-200 dark:border-orange-800",
|
||||||
|
animals: "bg-emerald-500/10 text-emerald-600 border-emerald-200 dark:border-emerald-800"
|
||||||
|
};
|
||||||
|
|
||||||
|
const languageOptions = [
|
||||||
|
{ code: 'en', name: 'English', flag: '🇺🇸' },
|
||||||
|
{ code: 'pt', name: 'Português', flag: '🇧🇷' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AccountPage() {
|
||||||
|
const [editingTemp, setEditingTemp] = useState(false);
|
||||||
|
const [tempValue, setTempValue] = useState("");
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||||
|
const [reportTab, setReportTab] = useState("bug");
|
||||||
|
const [commands, setCommands] = useState<CommandCard[]>(allCommands);
|
||||||
|
|
||||||
|
const { user, loading, logout, refreshUser } = useAuth();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
setTempValue(user.aiTemperature.toString());
|
||||||
|
setCommands(allCommands.map(cmd => ({
|
||||||
|
...cmd,
|
||||||
|
enabled: !user.disabledCommands.includes(cmd.id)
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const updateSetting = async (setting: string, value: boolean | number | string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/user/settings', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ [setting]: value }),
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
await refreshUser();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating setting:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveTemperature = () => {
|
||||||
|
const temp = parseFloat(tempValue);
|
||||||
|
if (temp >= 0.1 && temp <= 2.0) {
|
||||||
|
updateSetting('aiTemperature', temp);
|
||||||
|
setEditingTemp(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCommand = async (commandId: string) => {
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
const commandToToggle = commands.find(cmd => cmd.id === commandId);
|
||||||
|
if (!commandToToggle) return;
|
||||||
|
|
||||||
|
const newEnabledState = !commandToToggle.enabled;
|
||||||
|
|
||||||
|
setCommands(prev => prev.map(cmd =>
|
||||||
|
cmd.id === commandId ? { ...cmd, enabled: newEnabledState } : cmd
|
||||||
|
));
|
||||||
|
|
||||||
|
try {
|
||||||
|
let newDisabledCommands: string[];
|
||||||
|
|
||||||
|
if (newEnabledState) {
|
||||||
|
newDisabledCommands = user.disabledCommands.filter(id => id !== commandId);
|
||||||
|
} else {
|
||||||
|
newDisabledCommands = [...user.disabledCommands, commandId];
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('/api/user/settings', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ disabledCommands: newDisabledCommands }),
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
await refreshUser();
|
||||||
|
} else {
|
||||||
|
setCommands(prev => prev.map(cmd =>
|
||||||
|
cmd.id === commandId ? { ...cmd, enabled: !newEnabledState } : cmd
|
||||||
|
));
|
||||||
|
console.error('Failed to update command state');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setCommands(prev => prev.map(cmd =>
|
||||||
|
cmd.id === commandId ? { ...cmd, enabled: !newEnabledState } : cmd
|
||||||
|
));
|
||||||
|
console.error('Error updating command state:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredCommands = selectedCategory
|
||||||
|
? commands.filter(cmd => cmd.category === selectedCategory)
|
||||||
|
: commands;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-2xl font-bold mb-4">Authentication Required</h1>
|
||||||
|
<Button onClick={() => window.location.href = '/login'}>
|
||||||
|
Go to Login
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full min-h-screen bg-background">
|
||||||
|
<div className="container mx-auto px-6 py-8">
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-primary/10 items-center justify-center hidden md:flex">
|
||||||
|
<User className="w-8 h-8 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">Welcome back, {user.firstName}!</h1>
|
||||||
|
<p className="text-muted-foreground">@{user.username}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={logout} className="gap-2">
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||||
|
<motion.div
|
||||||
|
className="p-6 rounded-lg border bg-gradient-to-br from-purple-500/10 to-pink-500/10"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: 0.1 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<BarChart3 className="w-8 h-8 text-purple-600" />
|
||||||
|
<h3 className="text-xl font-semibold">AI Usage</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-2xl font-bold">{user.aiRequests}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Total AI Requests</p>
|
||||||
|
<p className="text-lg">{user.aiCharacters.toLocaleString()}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Characters Generated</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="p-6 rounded-lg border bg-gradient-to-br from-blue-500/10 to-cyan-500/10"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: 0.2 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<Settings className="w-8 h-8 text-blue-600" />
|
||||||
|
<h3 className="text-xl font-semibold">AI Settings</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm">AI Enabled</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={user.aiEnabled ? "default" : "outline"}
|
||||||
|
onClick={() => updateSetting('aiEnabled', !user.aiEnabled)}
|
||||||
|
className="h-8 px-3"
|
||||||
|
>
|
||||||
|
{user.aiEnabled ? "ON" : "OFF"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm">Show Thinking</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={user.showThinking ? "default" : "outline"}
|
||||||
|
onClick={() => updateSetting('showThinking', !user.showThinking)}
|
||||||
|
className="h-8 px-3"
|
||||||
|
>
|
||||||
|
{user.showThinking ? "ON" : "OFF"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="p-6 rounded-lg border bg-gradient-to-br from-green-500/10 to-emerald-500/10"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: 0.3 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<Thermometer className="w-8 h-8 text-green-600" />
|
||||||
|
<h3 className="text-xl font-semibold">Temperature</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{editingTemp ? (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0.1"
|
||||||
|
max="2.0"
|
||||||
|
step="0.1"
|
||||||
|
value={tempValue}
|
||||||
|
onChange={(e) => setTempValue(e.target.value)}
|
||||||
|
className="h-8 w-20"
|
||||||
|
/>
|
||||||
|
<Button size="sm" onClick={saveTemperature} className="h-8 w-8 p-0">
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setEditingTemp(false)} className="h-8 w-8 p-0">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-2xl font-bold">{user.aiTemperature}</span>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setEditingTemp(true)} className="h-8 w-8 p-0">
|
||||||
|
<Edit3 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">Controls randomness in AI responses. Lower values (0.1-0.5) = more focused, higher values (0.7-2.0) = more creative.</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="p-6 rounded-lg border bg-gradient-to-br from-teal-500/10 to-cyan-500/10"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: 0.4 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<Languages className="w-8 h-8 text-teal-600" />
|
||||||
|
<h3 className="text-xl font-semibold">Language Options</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
{languageOptions.map((lang) => (
|
||||||
|
<Button
|
||||||
|
key={lang.code}
|
||||||
|
variant={user.languageCode === lang.code ? "default" : "outline"}
|
||||||
|
onClick={() => updateSetting('languageCode', lang.code)}
|
||||||
|
className="justify-start gap-3 h-10"
|
||||||
|
>
|
||||||
|
<span className="text-lg">{lang.flag}</span>
|
||||||
|
<span>{lang.name}</span>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">Choose your preferred language for bot responses and interface text.</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="p-6 rounded-lg border bg-gradient-to-br from-indigo-500/10 to-violet-500/10 col-span-1 md:col-span-2"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: 0.5 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<Cpu className="w-8 h-8 text-indigo-600" />
|
||||||
|
<h3 className="text-xl font-semibold">My Model</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<ModelPicker
|
||||||
|
value={user.customAiModel}
|
||||||
|
onValueChange={(newModel) => updateSetting('customAiModel', newModel)}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">Your selected AI model for custom /ai commands. Different models have varying capabilities, speeds, and response styles.</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="p-6 rounded-lg border bg-gradient-to-br from-orange-500/10 to-red-500/10 col-span-1 md:col-span-2"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: 0.6 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<Bug className="w-8 h-8 text-orange-600" />
|
||||||
|
<h3 className="text-xl font-semibold">Report An Issue</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Tabs value={reportTab} onValueChange={setReportTab}>
|
||||||
|
<TabsList className="grid w-full grid-cols-2 gap-2">
|
||||||
|
<TabsTrigger value="bug" className="gap-2">
|
||||||
|
<Bug className="w-4 h-4" />
|
||||||
|
Bug Report
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="feature" className="gap-2">
|
||||||
|
<Lightbulb className="w-4 h-4" />
|
||||||
|
Feature Request
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<div className="mt-4">
|
||||||
|
<TabsContent value="bug" className="space-y-12">
|
||||||
|
<p className="text-sm text-muted-foreground">Found a bug or issue? Report it to help us improve Kowalski.</p>
|
||||||
|
<Button asChild className="w-full gap-2">
|
||||||
|
<a
|
||||||
|
href="https://libre-cloud.atlassian.net/jira/software/c/form/4a535b59-dc7e-4b55-b905-a79ff831928e?atlOrigin=eyJpIjoiNzQwYTcxZDdmMjJkNDljNzgzNTY2MjliYjliMjMzMDkiLCJwIjoiaiJ9"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Bug className="w-4 h-4" />
|
||||||
|
Report Bug
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="feature" className="space-y-12">
|
||||||
|
<p className="text-sm text-muted-foreground">Have an idea for a new feature? Let us know what you'd like to see!</p>
|
||||||
|
<Button asChild className="w-full gap-2">
|
||||||
|
<a
|
||||||
|
href="https://libre-cloud.atlassian.net/jira/software/c/form/5ce1e6e9-9618-4b46-94ee-122e7bde2ba1?atlOrigin=eyJpIjoiZjMwZTc3MDVlY2MwNDBjODliYWNhMTgzN2ZjYzI5MDAiLCJwIjoiaiJ9"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Lightbulb className="w-4 h-4" />
|
||||||
|
Request Feature
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</TabsContent>
|
||||||
|
</div>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="mb-6"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: 0.7 }}
|
||||||
|
>
|
||||||
|
<h2 className="text-2xl font-bold mb-4">Command Management</h2>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant={selectedCategory === null ? "default" : "outline"}
|
||||||
|
onClick={() => setSelectedCategory(null)}
|
||||||
|
className="mb-2"
|
||||||
|
>
|
||||||
|
All Commands
|
||||||
|
</Button>
|
||||||
|
{Object.entries(categoryColors).map(([category, colorClass]) => (
|
||||||
|
<Button
|
||||||
|
key={category}
|
||||||
|
variant={selectedCategory === category ? "default" : "outline"}
|
||||||
|
onClick={() => setSelectedCategory(category)}
|
||||||
|
className={`mb-2 capitalize ${selectedCategory === category ? '' : colorClass}`}
|
||||||
|
>
|
||||||
|
{category === "ai" ? "AI" : category}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.3, delay: 0.8 }}
|
||||||
|
>
|
||||||
|
{filteredCommands.map((command) => (
|
||||||
|
<div
|
||||||
|
key={command.id}
|
||||||
|
className={`p-4 rounded-lg border transition-all duration-200 ${
|
||||||
|
command.enabled
|
||||||
|
? 'bg-card hover:shadow-md shadow-sm'
|
||||||
|
: 'bg-muted/30 border-muted-foreground/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className={`w-10 h-10 rounded-lg bg-gradient-to-br ${command.gradient} flex items-center justify-center ${
|
||||||
|
command.enabled ? '' : 'grayscale opacity-50'
|
||||||
|
}`}>
|
||||||
|
<command.icon className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div
|
||||||
|
className={`w-11 h-6 rounded-full cursor-pointer transition-colors duration-200 ${
|
||||||
|
command.enabled
|
||||||
|
? 'bg-green-500 dark:bg-green-600'
|
||||||
|
: 'bg-gray-300 dark:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleCommand(command.id)}
|
||||||
|
>
|
||||||
|
<div className={`w-5 h-5 rounded-full shadow-sm transition-transform duration-200 ${
|
||||||
|
command.enabled
|
||||||
|
? 'translate-x-5 bg-white dark:bg-gray-100'
|
||||||
|
: 'translate-x-0.5 bg-white dark:bg-gray-200'
|
||||||
|
} mt-0.5`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className={`text-base font-semibold mb-2 ${command.enabled ? '' : 'text-muted-foreground'}`}>
|
||||||
|
{command.title}
|
||||||
|
</h3>
|
||||||
|
<p className={`text-sm mb-3 ${command.enabled ? 'text-muted-foreground' : 'text-muted-foreground/60'}`}>
|
||||||
|
{command.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{command.commands.slice(0, 2).map((cmd, idx) => (
|
||||||
|
<code key={idx} className={`px-1.5 py-0.5 rounded text-xs ${
|
||||||
|
command.enabled
|
||||||
|
? 'bg-muted text-foreground'
|
||||||
|
: 'bg-muted-foreground/10 text-muted-foreground/60'
|
||||||
|
}`}>
|
||||||
|
{cmd}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
|
{command.commands.length > 2 && (
|
||||||
|
<span className={`text-xs ${command.enabled ? 'text-muted-foreground' : 'text-muted-foreground/60'}`}>
|
||||||
|
+{command.commands.length - 2}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={`px-2 py-1 rounded-full text-xs border ${
|
||||||
|
command.enabled
|
||||||
|
? categoryColors[command.category]
|
||||||
|
: 'bg-muted-foreground/10 text-muted-foreground/60 border-muted-foreground/20'
|
||||||
|
}`}>
|
||||||
|
{command.category === "ai" ? "AI" : command.category}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="mt-12 text-center"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: 1.0 }}
|
||||||
|
>
|
||||||
|
<div className="inline-flex items-center gap-16 p-6 px-8 rounded-lg bg-muted/50 border">
|
||||||
|
<span className="font-medium">Ready to start using Kowalski?</span>
|
||||||
|
<Button asChild>
|
||||||
|
<a href="https://t.me/KowalskiNodeBot" target="_blank" rel="noopener noreferrer">
|
||||||
|
<RiTelegram2Line />
|
||||||
|
Open on Telegram
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
34
webui/app/api/auth/logout/route.ts
Executable file
34
webui/app/api/auth/logout/route.ts
Executable file
|
@ -0,0 +1,34 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { invalidateSession } from "@/lib/auth";
|
||||||
|
import { SESSION_COOKIE_NAME } from "@/lib/auth-constants";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const cookieToken = request.cookies.get(SESSION_COOKIE_NAME)?.value;
|
||||||
|
const authHeader = request.headers.get('authorization');
|
||||||
|
const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
|
||||||
|
const sessionToken = bearerToken || cookieToken;
|
||||||
|
|
||||||
|
if (sessionToken) {
|
||||||
|
await invalidateSession(sessionToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = NextResponse.json({ success: true });
|
||||||
|
|
||||||
|
response.cookies.set(SESSION_COOKIE_NAME, '', {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
expires: new Date(0),
|
||||||
|
path: "/",
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in logout API:", error);
|
||||||
|
return NextResponse.json({
|
||||||
|
error: "Internal server error"
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
91
webui/app/api/auth/username/route.ts
Executable file
91
webui/app/api/auth/username/route.ts
Executable file
|
@ -0,0 +1,91 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import * as schema from "@/lib/schema";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const requestContentType = request.headers.get('content-type');
|
||||||
|
if (!requestContentType || !requestContentType.includes('application/json')) {
|
||||||
|
return NextResponse.json({ success: false, error: "Invalid content type" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { username } = body;
|
||||||
|
|
||||||
|
if (!username) {
|
||||||
|
return NextResponse.json({ success: false, error: "Username is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof username !== 'string' || username.length < 3 || username.length > 32) {
|
||||||
|
return NextResponse.json({ success: false, error: "Invalid username format" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanUsername = username.replace('@', '');
|
||||||
|
|
||||||
|
const user = await db.query.usersTable.findFirst({
|
||||||
|
where: eq(schema.usersTable.username, cleanUsername),
|
||||||
|
columns: {
|
||||||
|
telegramId: true,
|
||||||
|
username: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
const botUsername = process.env.botUsername || "KowalskiNodeBot";
|
||||||
|
return NextResponse.json({ success: false, error: `Please DM @${botUsername} before signing in.` }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const botApiUrl = process.env.botApiUrl || "http://kowalski:3030";
|
||||||
|
const fullUrl = `${botApiUrl}/2fa/get`;
|
||||||
|
|
||||||
|
const botApiResponse = await fetch(fullUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ userId: user.telegramId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!botApiResponse.ok) {
|
||||||
|
const errorText = await botApiResponse.text();
|
||||||
|
console.error("Bot API error response:", errorText);
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: `Bot API error: ${botApiResponse.status} - ${errorText.slice(0, 200)}`
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = botApiResponse.headers.get("content-type");
|
||||||
|
if (!contentType || !contentType.includes("application/json")) {
|
||||||
|
const errorText = await botApiResponse.text();
|
||||||
|
console.error("Bot API returned non-JSON:", errorText.slice(0, 200));
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: "Bot API returned invalid response format"
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const botApiResult = await botApiResponse.json();
|
||||||
|
|
||||||
|
if (!botApiResult.generated) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: botApiResult.error || "Failed to send 2FA code"
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: "2FA code sent successfully",
|
||||||
|
userId: user.telegramId
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in username API:", error);
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: "Internal server error"
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
107
webui/app/api/auth/verify/route.ts
Executable file
107
webui/app/api/auth/verify/route.ts
Executable file
|
@ -0,0 +1,107 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { eq, and, gt } from "drizzle-orm";
|
||||||
|
import * as schema from "@/lib/schema";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { createSession, getSessionCookieOptions } from "@/lib/auth";
|
||||||
|
import { SESSION_COOKIE_NAME } from "@/lib/auth-constants";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const contentType = request.headers.get('content-type');
|
||||||
|
if (!contentType || !contentType.includes('application/json')) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: "Invalid content type"
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { userId, code } = body;
|
||||||
|
|
||||||
|
if (!userId || !code) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: "User ID and code are required"
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof userId !== 'string' || typeof code !== 'string') {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: "Invalid input format"
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^\d{6}$/.test(code)) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: "Invalid code format"
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const twoFactorRecord = await db.query.twoFactorTable.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(schema.twoFactorTable.userId, userId),
|
||||||
|
gt(schema.twoFactorTable.codeExpiresAt, new Date())
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!twoFactorRecord) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: "No valid 2FA code found or code has expired"
|
||||||
|
}, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (twoFactorRecord.codeAttempts >= 5) {
|
||||||
|
await db.delete(schema.twoFactorTable)
|
||||||
|
.where(eq(schema.twoFactorTable.userId, userId));
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: "Too many failed attempts. Please request a new code."
|
||||||
|
}, { status: 429 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (twoFactorRecord.currentCode !== code) {
|
||||||
|
await db.update(schema.twoFactorTable)
|
||||||
|
.set({
|
||||||
|
codeAttempts: twoFactorRecord.codeAttempts + 1,
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(schema.twoFactorTable.userId, userId));
|
||||||
|
|
||||||
|
console.log(`2FA verification failed for user: ${userId}, attempts: ${twoFactorRecord.codeAttempts + 1}`);
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: "Invalid 2FA code"
|
||||||
|
}, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await createSession(userId);
|
||||||
|
|
||||||
|
await db.delete(schema.twoFactorTable)
|
||||||
|
.where(eq(schema.twoFactorTable.userId, userId));
|
||||||
|
|
||||||
|
console.log("2FA verification successful for user:", userId);
|
||||||
|
|
||||||
|
const response = NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: "2FA verification successful",
|
||||||
|
redirectTo: "/account",
|
||||||
|
sessionToken: session.sessionToken
|
||||||
|
});
|
||||||
|
|
||||||
|
const cookieOptions = getSessionCookieOptions();
|
||||||
|
response.cookies.set(SESSION_COOKIE_NAME, session.sessionToken, cookieOptions);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in verify API:", error);
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: "Internal server error"
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
59
webui/app/api/user/delete/route.ts
Executable file
59
webui/app/api/user/delete/route.ts
Executable file
|
@ -0,0 +1,59 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { validateSession } from "@/lib/auth";
|
||||||
|
import { SESSION_COOKIE_NAME } from "@/lib/auth-constants";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { usersTable, sessionsTable, twoFactorTable } from "@/lib/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const cookieToken = request.cookies.get(SESSION_COOKIE_NAME)?.value;
|
||||||
|
const authHeader = request.headers.get('authorization');
|
||||||
|
const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
|
||||||
|
const sessionToken = bearerToken || cookieToken;
|
||||||
|
|
||||||
|
if (!sessionToken) {
|
||||||
|
return NextResponse.json({ error: "Authentication required" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionData = await validateSession(sessionToken);
|
||||||
|
|
||||||
|
if (!sessionData || !sessionData.user) {
|
||||||
|
return NextResponse.json({ error: "Invalid or expired session" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = sessionData.user.telegramId;
|
||||||
|
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await tx.delete(sessionsTable)
|
||||||
|
.where(eq(sessionsTable.userId, userId));
|
||||||
|
|
||||||
|
await tx.delete(twoFactorTable)
|
||||||
|
.where(eq(twoFactorTable.userId, userId));
|
||||||
|
|
||||||
|
await tx.delete(usersTable)
|
||||||
|
.where(eq(usersTable.telegramId, userId));
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: "Account deleted successfully"
|
||||||
|
});
|
||||||
|
|
||||||
|
response.cookies.set(SESSION_COOKIE_NAME, '', {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
expires: new Date(0),
|
||||||
|
path: "/",
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting account:", error);
|
||||||
|
return NextResponse.json({
|
||||||
|
error: "Failed to delete account"
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
46
webui/app/api/user/profile/route.ts
Executable file
46
webui/app/api/user/profile/route.ts
Executable file
|
@ -0,0 +1,46 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { validateSession } from "@/lib/auth";
|
||||||
|
import { SESSION_COOKIE_NAME } from "@/lib/auth-constants";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const cookieToken = request.cookies.get(SESSION_COOKIE_NAME)?.value;
|
||||||
|
const authHeader = request.headers.get('authorization');
|
||||||
|
const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
|
||||||
|
const sessionToken = bearerToken || cookieToken;
|
||||||
|
|
||||||
|
if (!sessionToken) {
|
||||||
|
return NextResponse.json({ error: "Authentication required" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionData = await validateSession(sessionToken);
|
||||||
|
|
||||||
|
if (!sessionData || !sessionData.user) {
|
||||||
|
return NextResponse.json({ error: "Invalid or expired session" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user } = sessionData;
|
||||||
|
const sanitizedUser = {
|
||||||
|
telegramId: user.telegramId,
|
||||||
|
username: user.username,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
aiEnabled: user.aiEnabled,
|
||||||
|
showThinking: user.showThinking,
|
||||||
|
customAiModel: user.customAiModel,
|
||||||
|
aiTemperature: user.aiTemperature,
|
||||||
|
aiRequests: user.aiRequests,
|
||||||
|
aiCharacters: user.aiCharacters,
|
||||||
|
disabledCommands: user.disabledCommands,
|
||||||
|
languageCode: user.languageCode,
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(sanitizedUser);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in profile API:", error);
|
||||||
|
return NextResponse.json({
|
||||||
|
error: "Internal server error"
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
103
webui/app/api/user/settings/route.ts
Executable file
103
webui/app/api/user/settings/route.ts
Executable file
|
@ -0,0 +1,103 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { validateSession } from "@/lib/auth";
|
||||||
|
import { SESSION_COOKIE_NAME } from "@/lib/auth-constants";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import * as schema from "@/lib/schema";
|
||||||
|
|
||||||
|
interface UserUpdates {
|
||||||
|
aiEnabled?: boolean;
|
||||||
|
showThinking?: boolean;
|
||||||
|
customAiModel?: string;
|
||||||
|
aiTemperature?: number;
|
||||||
|
disabledCommands?: string[];
|
||||||
|
languageCode?: string;
|
||||||
|
updatedAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const cookieToken = request.cookies.get(SESSION_COOKIE_NAME)?.value;
|
||||||
|
const authHeader = request.headers.get('authorization');
|
||||||
|
const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
|
||||||
|
const sessionToken = bearerToken || cookieToken;
|
||||||
|
|
||||||
|
if (!sessionToken) {
|
||||||
|
return NextResponse.json({ error: "Authentication required" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionData = await validateSession(sessionToken);
|
||||||
|
|
||||||
|
if (!sessionData || !sessionData.user) {
|
||||||
|
return NextResponse.json({ error: "Invalid or expired session" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = request.headers.get('content-type');
|
||||||
|
if (!contentType || !contentType.includes('application/json')) {
|
||||||
|
return NextResponse.json({ error: "Invalid content type" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = await request.json();
|
||||||
|
const userId = sessionData.user.telegramId;
|
||||||
|
|
||||||
|
if (!updates || typeof updates !== 'object') {
|
||||||
|
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedFields = [
|
||||||
|
'aiEnabled',
|
||||||
|
'showThinking',
|
||||||
|
'customAiModel',
|
||||||
|
'aiTemperature',
|
||||||
|
'disabledCommands',
|
||||||
|
'languageCode'
|
||||||
|
];
|
||||||
|
|
||||||
|
const filteredUpdates: UserUpdates = {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(updates)) {
|
||||||
|
if (allowedFields.includes(key)) {
|
||||||
|
if (key === 'aiEnabled' || key === 'showThinking') {
|
||||||
|
filteredUpdates[key] = Boolean(value);
|
||||||
|
} else if (key === 'aiTemperature') {
|
||||||
|
const temp = Number(value);
|
||||||
|
if (temp >= 0.1 && temp <= 2.0) {
|
||||||
|
filteredUpdates[key] = temp;
|
||||||
|
} else {
|
||||||
|
return NextResponse.json({ error: "Temperature must be between 0.1 and 2.0" }, { status: 400 });
|
||||||
|
}
|
||||||
|
} else if (key === 'customAiModel' || key === 'languageCode') {
|
||||||
|
if (typeof value === 'string' && value.length > 0 && value.length < 100) {
|
||||||
|
filteredUpdates[key] = value;
|
||||||
|
} else {
|
||||||
|
return NextResponse.json({ error: `Invalid ${key}` }, { status: 400 });
|
||||||
|
}
|
||||||
|
} else if (key === 'disabledCommands') {
|
||||||
|
if (Array.isArray(value) && value.every(item => typeof item === 'string' && item.length < 50) && value.length < 100) {
|
||||||
|
filteredUpdates[key] = value;
|
||||||
|
} else {
|
||||||
|
return NextResponse.json({ error: "Invalid disabled commands" }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(filteredUpdates).length === 0) {
|
||||||
|
return NextResponse.json({ error: "No valid updates provided" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredUpdates.updatedAt = new Date();
|
||||||
|
|
||||||
|
await db.update(schema.usersTable)
|
||||||
|
.set(filteredUpdates)
|
||||||
|
.where(eq(schema.usersTable.telegramId, userId));
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in settings API:", error);
|
||||||
|
return NextResponse.json({
|
||||||
|
error: "Internal server error"
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
126
webui/app/globals.css
Executable file
126
webui/app/globals.css
Executable file
|
@ -0,0 +1,126 @@
|
||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--font-sans: var(--font-geist-sans);
|
||||||
|
--font-mono: var(--font-geist-mono);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sora);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.145 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.205 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.205 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.922 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.556 0 0);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
54
webui/app/layout.tsx
Executable file
54
webui/app/layout.tsx
Executable file
|
@ -0,0 +1,54 @@
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Sora } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
import { ThemeProvider } from "@/components/providers";
|
||||||
|
import { SidebarProvider, SidebarInset, SidebarTrigger } from "@/components/ui/sidebar";
|
||||||
|
import { AppSidebar } from "@/components/app-sidebar";
|
||||||
|
import { AuthProvider } from "@/contexts/auth-context";
|
||||||
|
import { HeaderAuth } from "@/components/header-auth";
|
||||||
|
|
||||||
|
const sora = Sora({
|
||||||
|
variable: "--font-sora",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Kowalski",
|
||||||
|
description: "A powerful, multi-function Telegram bot",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en" suppressHydrationWarning className="scroll-smooth">
|
||||||
|
<body className={`${sora.variable} antialiased`}>
|
||||||
|
<ThemeProvider
|
||||||
|
attribute="class"
|
||||||
|
defaultTheme="system"
|
||||||
|
enableSystem
|
||||||
|
disableTransitionOnChange
|
||||||
|
>
|
||||||
|
<AuthProvider>
|
||||||
|
<SidebarProvider>
|
||||||
|
<AppSidebar />
|
||||||
|
<SidebarInset className="h-[calc(100vh-16px)] overflow-hidden rounded-lg border bg-background flex flex-col">
|
||||||
|
<header className="flex h-16 shrink-0 items-center gap-2 px-4 border-b bg-background">
|
||||||
|
<SidebarTrigger className="-ml-1" />
|
||||||
|
<div className="ml-auto">
|
||||||
|
<HeaderAuth />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="flex-1 overflow-auto scroll-smooth">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</SidebarInset>
|
||||||
|
</SidebarProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
311
webui/app/login/page.tsx
Executable file
311
webui/app/login/page.tsx
Executable file
|
@ -0,0 +1,311 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { RiTelegram2Line } from "react-icons/ri";
|
||||||
|
import { TbLoader } from "react-icons/tb";
|
||||||
|
import { useState, Suspense } from "react";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
type FormStep = "username" | "twofa";
|
||||||
|
|
||||||
|
type VerifyResponse = {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
redirectTo?: string;
|
||||||
|
sessionToken?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buttonVariants = {
|
||||||
|
initial: { scale: 1 },
|
||||||
|
tap: { scale: 0.98 },
|
||||||
|
};
|
||||||
|
|
||||||
|
function LoginForm() {
|
||||||
|
const [step, setStep] = useState<FormStep>("username");
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [twoFaCode, setTwoFaCode] = useState("");
|
||||||
|
const [userId, setUserId] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const returnTo = searchParams.get('returnTo') || '/account';
|
||||||
|
|
||||||
|
const handleUsernameSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!username.trim()) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/auth/username", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ username: username.trim() }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setUserId(result.userId);
|
||||||
|
setStep("twofa");
|
||||||
|
} else {
|
||||||
|
setError(result.error || "Failed to find user");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Username submission error:", err);
|
||||||
|
setError("Network error. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTwoFaSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!twoFaCode.trim() || twoFaCode.length !== 6) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/auth/verify", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ userId, code: twoFaCode }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result: VerifyResponse = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
const redirectTo = result.redirectTo || returnTo;
|
||||||
|
if (result.sessionToken) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('kowalski-session', result.sessionToken);
|
||||||
|
} catch (storageError) {
|
||||||
|
console.error('localStorage error:', storageError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.href = redirectTo;
|
||||||
|
} else {
|
||||||
|
setError(result.error || "Invalid 2FA code");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("2FA verification error:", err);
|
||||||
|
console.log("Error details:", {
|
||||||
|
message: err instanceof Error ? err.message : 'Unknown error',
|
||||||
|
stack: err instanceof Error ? err.stack : 'No stack trace',
|
||||||
|
name: err instanceof Error ? err.name : 'Unknown error type'
|
||||||
|
});
|
||||||
|
const errorMessage = err instanceof Error ?
|
||||||
|
`Error: ${err.message}` :
|
||||||
|
"Network error. Please try again.";
|
||||||
|
setError(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setStep("username");
|
||||||
|
setUsername("");
|
||||||
|
setTwoFaCode("");
|
||||||
|
setUserId("");
|
||||||
|
setError("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const LoadingSpinner = ({ text }: { text: string }) => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<TbLoader className="w-4 h-4 animate-spin" />
|
||||||
|
<span className="text-muted-foreground">{text}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<section className="flex flex-col items-center justify-center py-24 px-6 text-center bg-gradient-to-br from-background to-muted flex-1">
|
||||||
|
<div className="max-w-4xl mx-auto space-y-8">
|
||||||
|
<div className="flex items-center justify-center mb-6">
|
||||||
|
<div className="flex items-center justify-center w-20 h-20 rounded-full bg-primary/10 p-4">
|
||||||
|
<RiTelegram2Line className="w-10 h-10" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{step === "username" && (
|
||||||
|
<motion.div
|
||||||
|
key="username-form"
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: 20 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="max-w-md mx-auto"
|
||||||
|
>
|
||||||
|
<h1 className="text-6xl font-bold tracking-tight bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent mb-4">
|
||||||
|
Login to Kowalski
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-xl text-muted-foreground max-w-3xl mx-auto leading-relaxed mb-8">
|
||||||
|
Please enter your Telegram username to continue.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleUsernameSubmit} className="max-w-md mx-auto space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter your Telegram username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="text-center text-lg py-6"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{error && (
|
||||||
|
<motion.p
|
||||||
|
className="text-red-500 text-sm"
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: "auto" }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</motion.p>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
variants={buttonVariants}
|
||||||
|
initial="initial"
|
||||||
|
whileTap={!isLoading && username.trim() ? "tap" : undefined}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!username.trim() || isLoading}
|
||||||
|
className="w-full py-6 text-lg"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<LoadingSpinner text="Finding your account..." />
|
||||||
|
) : (
|
||||||
|
"Continue"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
</form>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "twofa" && (
|
||||||
|
<motion.div
|
||||||
|
key="twofa-form"
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: 20 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="max-w-md mx-auto"
|
||||||
|
>
|
||||||
|
<h1 className="text-6xl font-bold tracking-tight bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent mb-4">
|
||||||
|
Enter 2FA Code
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-xl text-muted-foreground max-w-3xl mx-auto leading-relaxed mb-8">
|
||||||
|
We've sent a 6-digit code to your Telegram. Please enter it below.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleTwoFaSubmit} className="max-w-md mx-auto space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="000000"
|
||||||
|
value={twoFaCode}
|
||||||
|
onChange={(e) => setTwoFaCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="text-center text-2xl font-mono tracking-widest py-6"
|
||||||
|
maxLength={6}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{error && (
|
||||||
|
<motion.p
|
||||||
|
className="text-red-500 text-sm"
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: "auto" }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</motion.p>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<motion.div
|
||||||
|
variants={buttonVariants}
|
||||||
|
initial="initial"
|
||||||
|
whileTap="tap"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={resetForm}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full py-6"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
variants={buttonVariants}
|
||||||
|
initial="initial"
|
||||||
|
whileTap={!isLoading && twoFaCode.length === 6 ? "tap" : undefined}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={twoFaCode.length !== 6 || isLoading}
|
||||||
|
className="w-full py-6 text-lg"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<LoadingSpinner text="Verifying..." />
|
||||||
|
) : (
|
||||||
|
"Verify"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<LoginForm />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
251
webui/app/page.tsx
Executable file
251
webui/app/page.tsx
Executable file
|
@ -0,0 +1,251 @@
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Bot,
|
||||||
|
Sparkles,
|
||||||
|
Users,
|
||||||
|
Settings,
|
||||||
|
Download,
|
||||||
|
Brain,
|
||||||
|
Shield,
|
||||||
|
Zap,
|
||||||
|
Tv,
|
||||||
|
Trash,
|
||||||
|
Lock,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { SiYoutube, SiForgejo } from "react-icons/si";
|
||||||
|
import { RiTelegram2Line } from "react-icons/ri";
|
||||||
|
import { TbEyeSpark } from "react-icons/tb";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Footer from "@/components/footer";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<section className="flex flex-col items-center justify-center py-24 px-6 text-center bg-gradient-to-br from-background to-muted">
|
||||||
|
<div className="max-w-4xl mx-auto space-y-8">
|
||||||
|
<div className="flex items-center justify-center mb-6">
|
||||||
|
<div className="flex items-center justify-center w-20 h-20 rounded-full bg-primary/10 p-4">
|
||||||
|
<Image
|
||||||
|
src="/kowalski.svg"
|
||||||
|
alt="Kowalski Logo"
|
||||||
|
width={48}
|
||||||
|
height={48}
|
||||||
|
className="dark:invert -mt-3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-6xl font-bold tracking-tight bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent">
|
||||||
|
Kowalski
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-xl text-muted-foreground max-w-2xl mx-auto leading-relaxed">
|
||||||
|
A powerful, multi-function Telegram bot with AI capabilities, media downloading,
|
||||||
|
user management, and much more. Built for communities and power users.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center pt-8">
|
||||||
|
<Button size="lg" className="min-w-32" asChild>
|
||||||
|
<Link href="https://t.me/KowalskiNodeBot">
|
||||||
|
<RiTelegram2Line />
|
||||||
|
Try on Telegram
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="lg" className="min-w-32">
|
||||||
|
<Settings />
|
||||||
|
Documentation
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="lg" className="min-w-32" asChild>
|
||||||
|
<Link href="https://git.p0ntus.com/ABOCN/TelegramBot">
|
||||||
|
<SiForgejo />
|
||||||
|
View on Forgejo
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="py-24 px-6">
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
<div className="text-center mb-16" id="ai-features">
|
||||||
|
<h2 className="text-4xl font-bold mb-4">Features You'll Love</h2>
|
||||||
|
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||||
|
Powered by TypeScript, Telegraf, Next.js, and AI.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center justify-center w-12 h-12 rounded-lg bg-primary/10 text-primary">
|
||||||
|
<Sparkles className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-2xl font-semibold">AI Commands</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground leading-relaxed">
|
||||||
|
Interact with over 50 AI models through simple commands. Get intelligent responses,
|
||||||
|
assistance, or problem-solving help right in Telegram.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||||
|
<Bot className="w-5 h-5 mx-3 text-primary" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">/ai</div>
|
||||||
|
<div className="text-sm text-muted-foreground">Ask questions to a custom AI model of your choice</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||||
|
<Zap className="w-5 h-5 mx-3 text-primary" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">/ask</div>
|
||||||
|
<div className="text-sm text-muted-foreground">Quick AI responses for everyday questions</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||||
|
<Brain className="w-5 h-5 mx-3 text-primary" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">/think</div>
|
||||||
|
<div className="text-sm text-muted-foreground">Deep reasoning with optional visible thinking</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-3" id="youtube-features">
|
||||||
|
<div className="flex items-center justify-center w-12 h-12 rounded-lg bg-red-500/10 text-red-500">
|
||||||
|
<SiYoutube className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-2xl font-semibold">YouTube/Video Downloads</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground leading-relaxed">
|
||||||
|
Download videos directly from YouTube and other platforms and watch them in Telegram.
|
||||||
|
Supports thousands of sites with integrated yt-dlp.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||||
|
<Download className="w-5 h-5 mx-3 text-red-500" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">/yt [URL]</div>
|
||||||
|
<div className="text-sm text-muted-foreground">Quickly download videos up to 50MB</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||||
|
<Shield className="w-5 h-5 mx-3 text-red-500" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">Automatic Ratelimit Detection</div>
|
||||||
|
<div className="text-sm text-muted-foreground">We'll notify you if something goes wrong</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||||
|
<Tv className="w-5 h-5 mx-3 text-red-500" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">High Quality Downloads</div>
|
||||||
|
<div className="text-sm text-muted-foreground">Kowalski automatically chooses the best quality for you</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="user-features" className="py-24 px-6 bg-muted/30">
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
<div className="text-center mb-16">
|
||||||
|
<h2 className="text-4xl font-bold mb-4">
|
||||||
|
Control <span className="italic mr-1.5">and</span> Fun
|
||||||
|
</h2>
|
||||||
|
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||||
|
Your user data is always minimized and under your control. That certainly
|
||||||
|
doesn't mean the experience is lacking!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center justify-center w-12 h-12 rounded-lg bg-blue-500/10 text-blue-500">
|
||||||
|
<Users className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-2xl font-semibold">User Accounts</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground leading-relaxed">
|
||||||
|
Your user data is linked only by your Telegram ID. No data is ever sent to third parties
|
||||||
|
or used for anything other than providing you with the best experience.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-background border">
|
||||||
|
<Settings className="w-5 h-5 mx-3 text-blue-500" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">Personal Settings</div>
|
||||||
|
<div className="text-sm text-muted-foreground">Custom AI models, temperature, and language preferences</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-background border">
|
||||||
|
<Brain className="w-5 h-5 mx-3 text-blue-500" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">Account Statistics</div>
|
||||||
|
<div className="text-sm text-muted-foreground">Track AI requests, characters processed, and more</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-background border">
|
||||||
|
<Trash className="w-5 h-5 mx-3 text-blue-500" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">Leave at Any Time</div>
|
||||||
|
<div className="text-sm text-muted-foreground">We make it easy to delete your account at any time</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center justify-center w-12 h-12 rounded-lg bg-green-500/10 text-green-500">
|
||||||
|
<Bot className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-2xl font-semibold">Web Interface</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground leading-relaxed">
|
||||||
|
Kowalski includes a web interface, made with Next.js, to make it easier to manage your
|
||||||
|
bot, user account, and more. It's tailored to both users and admins.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-background border">
|
||||||
|
<TbEyeSpark className="w-5 h-5 mx-3 text-green-500" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">Everything's Clean</div>
|
||||||
|
<div className="text-sm text-muted-foreground">We don't clutter your view with ads or distractions.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-background border">
|
||||||
|
<Sparkles className="w-5 h-5 mx-3 text-green-500" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">Do Everything!</div>
|
||||||
|
<div className="text-sm text-muted-foreground">We aim to integrate every feature into the web interface.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-background border">
|
||||||
|
<Lock className="w-5 h-5 mx-3 text-green-500" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">Private</div>
|
||||||
|
<div className="text-sm text-muted-foreground">We don't use any analytics, tracking, or third-party scripts.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
21
webui/components.json
Executable file
21
webui/components.json
Executable file
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
434
webui/components/account/ai.ts
Executable file
434
webui/components/account/ai.ts
Executable file
|
@ -0,0 +1,434 @@
|
||||||
|
export interface ModelInfo {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
descriptionEn: string;
|
||||||
|
descriptionPt: string;
|
||||||
|
models: Array<{
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
parameterSize: string;
|
||||||
|
thinking: boolean;
|
||||||
|
uncensored: boolean;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultFlashModel = "gemma3:4b"
|
||||||
|
export const defaultThinkingModel = "qwen3:4b"
|
||||||
|
export const unloadModelAfterB = 12 // how many billion params until model is auto-unloaded
|
||||||
|
export const maxUserQueueSize = 3
|
||||||
|
|
||||||
|
export const models: ModelInfo[] = [
|
||||||
|
{
|
||||||
|
name: 'gemma3n',
|
||||||
|
label: 'gemma3n',
|
||||||
|
descriptionEn: 'Gemma3n is a family of open, light on-device models for general tasks.',
|
||||||
|
descriptionPt: 'Gemma3n é uma família de modelos abertos, leves e para dispositivos locais, para tarefas gerais.',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
name: 'gemma3n:e2b',
|
||||||
|
label: 'Gemma3n e2b',
|
||||||
|
parameterSize: '2B',
|
||||||
|
thinking: false,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'gemma3n:e4b',
|
||||||
|
label: 'Gemma3n e4b',
|
||||||
|
parameterSize: '4B',
|
||||||
|
thinking: false,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'gemma3',
|
||||||
|
label: 'gemma3 [ & Uncensored ]',
|
||||||
|
descriptionEn: 'Gemma3-abliterated is a family of open, uncensored models for general tasks.',
|
||||||
|
descriptionPt: 'Gemma3-abliterated é uma família de modelos abertos, não censurados, para tarefas gerais.',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
name: 'huihui_ai/gemma3-abliterated:1b',
|
||||||
|
label: 'Gemma3 Uncensored 1B',
|
||||||
|
parameterSize: '1B',
|
||||||
|
thinking: false,
|
||||||
|
uncensored: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'huihui_ai/gemma3-abliterated:4b',
|
||||||
|
label: 'Gemma3 Uncensored 4B',
|
||||||
|
parameterSize: '4B',
|
||||||
|
thinking: false,
|
||||||
|
uncensored: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'gemma3:1b',
|
||||||
|
label: 'Gemma3 1B',
|
||||||
|
parameterSize: '1B',
|
||||||
|
thinking: false,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'gemma3:4b',
|
||||||
|
label: 'Gemma3 4B',
|
||||||
|
parameterSize: '4B',
|
||||||
|
thinking: false,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'qwen3',
|
||||||
|
label: 'Qwen3',
|
||||||
|
descriptionEn: 'Qwen3 is a multilingual reasoning model series.',
|
||||||
|
descriptionPt: 'Qwen3 é uma série de modelos multilingues.',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
name: 'qwen3:0.6b',
|
||||||
|
label: 'Qwen3 0.6B',
|
||||||
|
parameterSize: '0.6B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'qwen3:1.7b',
|
||||||
|
label: 'Qwen3 1.7B',
|
||||||
|
parameterSize: '1.7B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'qwen3:4b',
|
||||||
|
label: 'Qwen3 4B',
|
||||||
|
parameterSize: '4B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'qwen3:8b',
|
||||||
|
label: 'Qwen3 8B',
|
||||||
|
parameterSize: '8B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'qwen3:14b',
|
||||||
|
label: 'Qwen3 14B',
|
||||||
|
parameterSize: '14B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'qwen3:30b',
|
||||||
|
label: 'Qwen3 30B',
|
||||||
|
parameterSize: '30B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'qwen3:32b',
|
||||||
|
label: 'Qwen3 32B',
|
||||||
|
parameterSize: '32B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'qwen3-abliterated',
|
||||||
|
label: 'Qwen3 [ Uncensored ]',
|
||||||
|
descriptionEn: 'Qwen3-abliterated is a multilingual reasoning model series.',
|
||||||
|
descriptionPt: 'Qwen3-abliterated é uma série de modelos multilingues.',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
name: 'huihui_ai/qwen3-abliterated:0.6b',
|
||||||
|
label: 'Qwen3 Uncensored 0.6B',
|
||||||
|
parameterSize: '0.6B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'huihui_ai/qwen3-abliterated:1.7b',
|
||||||
|
label: 'Qwen3 Uncensored 1.7B',
|
||||||
|
parameterSize: '1.7B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'huihui_ai/qwen3-abliterated:4b',
|
||||||
|
label: 'Qwen3 Uncensored 4B',
|
||||||
|
parameterSize: '4B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'huihui_ai/qwen3-abliterated:8b',
|
||||||
|
label: 'Qwen3 Uncensored 8B',
|
||||||
|
parameterSize: '8B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'huihui_ai/qwen3-abliterated:14b',
|
||||||
|
label: 'Qwen3 Uncensored 14B',
|
||||||
|
parameterSize: '14B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'huihui_ai/qwen3-abliterated:30b',
|
||||||
|
label: 'Qwen3 Uncensored 30B',
|
||||||
|
parameterSize: '30B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'huihui_ai/qwen3-abliterated:32b',
|
||||||
|
label: 'Qwen3 Uncensored 32B',
|
||||||
|
parameterSize: '32B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: true
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'qwq',
|
||||||
|
label: 'QwQ',
|
||||||
|
descriptionEn: 'QwQ is the reasoning model of the Qwen series.',
|
||||||
|
descriptionPt: 'QwQ é o modelo de raciocínio da série Qwen.',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
name: 'qwq:32b',
|
||||||
|
label: 'QwQ 32B',
|
||||||
|
parameterSize: '32B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'huihui_ai/qwq-abliterated:32b',
|
||||||
|
label: 'QwQ Uncensored 32B',
|
||||||
|
parameterSize: '32B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: true
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'llama4',
|
||||||
|
label: 'Llama4',
|
||||||
|
descriptionEn: 'The latest collection of multimodal models from Meta.',
|
||||||
|
descriptionPt: 'A coleção mais recente de modelos multimodais da Meta.',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
name: 'llama4:scout',
|
||||||
|
label: 'Llama4 109B A17B',
|
||||||
|
parameterSize: '109B',
|
||||||
|
thinking: false,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'deepseek',
|
||||||
|
label: 'DeepSeek [ & Uncensored ]',
|
||||||
|
descriptionEn: 'DeepSeek is a research model for reasoning tasks.',
|
||||||
|
descriptionPt: 'DeepSeek é um modelo de pesquisa para tarefas de raciocínio.',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
name: 'deepseek-r1:1.5b',
|
||||||
|
label: 'DeepSeek 1.5B',
|
||||||
|
parameterSize: '1.5B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'deepseek-r1:7b',
|
||||||
|
label: 'DeepSeek 7B',
|
||||||
|
parameterSize: '7B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'deepseek-r1:8b',
|
||||||
|
label: 'DeepSeek 8B',
|
||||||
|
parameterSize: '8B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'huihui_ai/deepseek-r1-abliterated:1.5b',
|
||||||
|
label: 'DeepSeek Uncensored 1.5B',
|
||||||
|
parameterSize: '1.5B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'huihui_ai/deepseek-r1-abliterated:7b',
|
||||||
|
label: 'DeepSeek Uncensored 7B',
|
||||||
|
parameterSize: '7B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'huihui_ai/deepseek-r1-abliterated:8b',
|
||||||
|
label: 'DeepSeek Uncensored 8B',
|
||||||
|
parameterSize: '8B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'huihui_ai/deepseek-r1-abliterated:14b',
|
||||||
|
label: 'DeepSeek Uncensored 14B',
|
||||||
|
parameterSize: '14B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: true
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'hermes3',
|
||||||
|
label: 'Hermes3',
|
||||||
|
descriptionEn: 'Hermes 3 is the latest version of the flagship Hermes series of LLMs by Nous Research.',
|
||||||
|
descriptionPt: 'Hermes 3 é a versão mais recente da série Hermes de LLMs da Nous Research.',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
name: 'hermes3:3b',
|
||||||
|
label: 'Hermes3 3B',
|
||||||
|
parameterSize: '3B',
|
||||||
|
thinking: false,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'hermes3:8b',
|
||||||
|
label: 'Hermes3 8B',
|
||||||
|
parameterSize: '8B',
|
||||||
|
thinking: false,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'mistral',
|
||||||
|
label: 'Mistral',
|
||||||
|
descriptionEn: 'The 7B model released by Mistral AI, updated to version 0.3.',
|
||||||
|
descriptionPt: 'O modelo 7B lançado pela Mistral AI, atualizado para a versão 0.3.',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
name: 'mistral:7b',
|
||||||
|
label: 'Mistral 7B',
|
||||||
|
parameterSize: '7B',
|
||||||
|
thinking: false,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'phi4 [ & Uncensored ]',
|
||||||
|
label: 'Phi4',
|
||||||
|
descriptionEn: 'Phi-4 is a 14B parameter, state-of-the-art open model from Microsoft. ',
|
||||||
|
descriptionPt: 'Phi-4 é um modelo de 14B de última geração, aberto pela Microsoft.',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
name: 'hf.co/unsloth/Phi-4-mini-reasoning-GGUF',
|
||||||
|
label: 'Phi4 Mini Reasoning',
|
||||||
|
parameterSize: '4B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'phi4:14b',
|
||||||
|
label: 'Phi4 14B',
|
||||||
|
parameterSize: '14B',
|
||||||
|
thinking: false,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'hf.co/unsloth/Phi-4-reasoning-plus-GGUF',
|
||||||
|
label: 'Phi4 Reasoning Plus',
|
||||||
|
parameterSize: '14B',
|
||||||
|
thinking: true,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'huihui_ai/phi4-abliterated:14b',
|
||||||
|
label: 'Phi4 Uncensored 14B',
|
||||||
|
parameterSize: '14B',
|
||||||
|
thinking: false,
|
||||||
|
uncensored: true
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'phi3',
|
||||||
|
label: 'Phi3',
|
||||||
|
descriptionEn: 'Phi-3 is a family of lightweight 3B (Mini) and 14B (Medium) state-of-the-art open models by Microsoft.',
|
||||||
|
descriptionPt: 'Phi-3 é uma família de modelos leves de 3B (Mini) e 14B (Médio) de última geração, abertos pela Microsoft.',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
name: 'phi3:3.8b',
|
||||||
|
label: 'Phi3 3.8B',
|
||||||
|
parameterSize: '3.8B',
|
||||||
|
thinking: false,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'llama3',
|
||||||
|
label: 'Llama4',
|
||||||
|
descriptionEn: 'Llama 3, a lightweight model from Meta.',
|
||||||
|
descriptionPt: 'Llama 3, um modelo leve da Meta.',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
name: 'llama3:8b',
|
||||||
|
label: 'Llama3 8B',
|
||||||
|
parameterSize: '8B',
|
||||||
|
thinking: false,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'llama3.1 [ Uncensored ]',
|
||||||
|
label: 'Llama3.1',
|
||||||
|
descriptionEn: 'Ablitered v3 llama-3.1 8b with uncensored prompt ',
|
||||||
|
descriptionPt: 'Llama3.1 é um modelo aberto, leve e para dispositivos locais, com prompt não censurado.',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
name: 'mannix/llama3.1-8b-abliterated:latest',
|
||||||
|
label: 'Llama3.1 8B',
|
||||||
|
parameterSize: '8B',
|
||||||
|
thinking: false,
|
||||||
|
uncensored: true
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'llama3.2 [ & Uncensored ]',
|
||||||
|
label: 'Llama3.2',
|
||||||
|
descriptionEn: 'Llama3.2 is a family of open, lightweight models for general tasks.',
|
||||||
|
descriptionPt: 'Llama3.2 é uma família de modelos abertos, leves e para dispositivos locais, para tarefas gerais.',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
name: 'llama3.2:1b',
|
||||||
|
label: 'Llama3.2 1B',
|
||||||
|
parameterSize: '1B',
|
||||||
|
thinking: false,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'llama3.2:3b',
|
||||||
|
label: 'Llama3.2 3B',
|
||||||
|
parameterSize: '3B',
|
||||||
|
thinking: false,
|
||||||
|
uncensored: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'socialnetwooky/llama3.2-abliterated:3b_q8_0',
|
||||||
|
label: 'Llama3.2 Uncensored 3B',
|
||||||
|
parameterSize: '3B',
|
||||||
|
thinking: false,
|
||||||
|
uncensored: true
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
];
|
155
webui/components/account/model-picker.tsx
Executable file
155
webui/components/account/model-picker.tsx
Executable file
|
@ -0,0 +1,155 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
/*
|
||||||
|
Adapted from https://ui.shadcn.com/docs/components/combobox
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { CheckIcon, ChevronsUpDownIcon, Cpu, Brain, ShieldOff } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command"
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover"
|
||||||
|
import { models } from "./ai"
|
||||||
|
|
||||||
|
interface ModelPickerProps {
|
||||||
|
value?: string
|
||||||
|
onValueChange?: (value: string) => void
|
||||||
|
disabled?: boolean
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelPicker({ value, onValueChange, disabled = false, className }: ModelPickerProps) {
|
||||||
|
const [open, setOpen] = React.useState(false)
|
||||||
|
|
||||||
|
const currentModel = React.useMemo(() => {
|
||||||
|
for (const category of models) {
|
||||||
|
const model = category.models.find(m => m.name === value)
|
||||||
|
if (model) {
|
||||||
|
return {
|
||||||
|
model,
|
||||||
|
category: category.label,
|
||||||
|
categoryDescription: category.descriptionEn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
const handleSelect = (modelName: string) => {
|
||||||
|
onValueChange?.(modelName)
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn("w-full justify-between h-auto p-4", className)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3 text-left flex-1">
|
||||||
|
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 mt-0.5">
|
||||||
|
<Cpu className="w-4 h-4 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{currentModel ? (
|
||||||
|
<>
|
||||||
|
<div className="font-medium text-sm">{currentModel.model.label}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{currentModel.category}</div>
|
||||||
|
<div className="text-xs text-muted-foreground/70 mt-1 break-words">
|
||||||
|
{currentModel.categoryDescription}
|
||||||
|
</div>
|
||||||
|
<div className="inline-flex items-center gap-1 mt-2 flex-wrap">
|
||||||
|
<span className="px-2 py-0.5 bg-muted rounded text-xs font-mono">
|
||||||
|
{currentModel.model.parameterSize}
|
||||||
|
</span>
|
||||||
|
{currentModel.model.thinking && (
|
||||||
|
<span className="px-2 py-0.5 bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300 rounded text-xs font-medium flex items-center gap-1">
|
||||||
|
<Brain />
|
||||||
|
Thinking
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{currentModel.model.uncensored && (
|
||||||
|
<span className="px-2 py-0.5 bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300 rounded text-xs font-medium flex items-center gap-1">
|
||||||
|
<ShieldOff />
|
||||||
|
Uncensored
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground">Select a model...</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronsUpDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[400px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="Search models..." />
|
||||||
|
<CommandList className="max-h-[300px]">
|
||||||
|
<CommandEmpty>No model found.</CommandEmpty>
|
||||||
|
{models.map((category) => (
|
||||||
|
<CommandGroup key={category.name} heading={category.label}>
|
||||||
|
<div className="pb-2 ml-2 mb-1 text-xs text-muted-foreground/70">
|
||||||
|
{category.descriptionEn}
|
||||||
|
</div>
|
||||||
|
{category.models.map((model) => (
|
||||||
|
<CommandItem
|
||||||
|
key={model.name}
|
||||||
|
value={`${category.label} ${model.label} ${model.parameterSize}`}
|
||||||
|
onSelect={() => handleSelect(model.name)}
|
||||||
|
className="flex items-center gap-3 py-3"
|
||||||
|
>
|
||||||
|
<CheckIcon
|
||||||
|
className={cn(
|
||||||
|
"h-4 w-4",
|
||||||
|
value === model.name ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium text-sm">{model.label}</div>
|
||||||
|
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||||
|
<span className="px-2 py-0.5 bg-muted rounded text-xs font-mono">
|
||||||
|
{model.parameterSize}
|
||||||
|
</span>
|
||||||
|
{model.thinking && (
|
||||||
|
<span className="px-2 py-0.5 bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300 rounded text-xs font-medium flex items-center gap-1">
|
||||||
|
<Brain />
|
||||||
|
Thinking
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{model.uncensored && (
|
||||||
|
<span className="px-2 py-0.5 bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300 rounded text-xs font-medium flex items-center gap-1">
|
||||||
|
<ShieldOff />
|
||||||
|
Uncensored
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
))}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
232
webui/components/app-sidebar.tsx
Executable file
232
webui/components/app-sidebar.tsx
Executable file
|
@ -0,0 +1,232 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import {
|
||||||
|
Home,
|
||||||
|
MessageSquare,
|
||||||
|
Users,
|
||||||
|
Sparkles,
|
||||||
|
User,
|
||||||
|
Trash2,
|
||||||
|
LogOut
|
||||||
|
} from "lucide-react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import Image from "next/image"
|
||||||
|
import {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
useSidebar,
|
||||||
|
} from "@/components/ui/sidebar"
|
||||||
|
import { ThemeToggle } from "@/components/theme-toggle"
|
||||||
|
import { SiYoutube } from "react-icons/si"
|
||||||
|
import { RiTelegram2Line } from "react-icons/ri"
|
||||||
|
import { useAuth } from "@/contexts/auth-context"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
|
||||||
|
interface AccountItem {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||||||
|
danger?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigation = [
|
||||||
|
{
|
||||||
|
title: "Home",
|
||||||
|
url: "/",
|
||||||
|
icon: Home,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "About",
|
||||||
|
url: "/about",
|
||||||
|
icon: MessageSquare,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
title: "AI Commands",
|
||||||
|
url: "/#ai-features",
|
||||||
|
icon: Sparkles,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Video Download",
|
||||||
|
url: "/#youtube-features",
|
||||||
|
icon: SiYoutube,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "User Accounts & UI",
|
||||||
|
url: "/#user-features",
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function AppSidebar() {
|
||||||
|
const { isAuthenticated, loading, logout } = useAuth();
|
||||||
|
const { setOpenMobile, isMobile } = useSidebar();
|
||||||
|
|
||||||
|
const handleMenuItemClick = () => {
|
||||||
|
if (isMobile) {
|
||||||
|
setOpenMobile(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const accountItems: AccountItem[] = React.useMemo(() => {
|
||||||
|
if (loading) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: "My Account",
|
||||||
|
url: "/account",
|
||||||
|
icon: User,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Logout",
|
||||||
|
url: "#",
|
||||||
|
icon: LogOut,
|
||||||
|
danger: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Delete Account",
|
||||||
|
url: "/account/delete",
|
||||||
|
icon: Trash2,
|
||||||
|
danger: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: "Sign in with Telegram",
|
||||||
|
url: "/login",
|
||||||
|
icon: RiTelegram2Line,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, loading]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sidebar variant="inset">
|
||||||
|
<SidebarHeader>
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton size="lg" asChild>
|
||||||
|
<div className="flex flex-row justify-between gap-3">
|
||||||
|
<Link href="/" className="flex flex-row gap-2" onClick={handleMenuItemClick}>
|
||||||
|
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary/10 p-1">
|
||||||
|
<Image
|
||||||
|
src="/kowalski.svg"
|
||||||
|
alt="Kowalski Logo"
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
className="dark:invert -mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
|
<span className="truncate font-semibold text-lg">Kowalski</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<Badge className="text-xs">Beta</Badge>
|
||||||
|
</div>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarHeader>
|
||||||
|
|
||||||
|
<SidebarContent>
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
{navigation.map((item) => (
|
||||||
|
<SidebarMenuItem key={item.title}>
|
||||||
|
<SidebarMenuButton asChild>
|
||||||
|
<Link href={item.url} onClick={handleMenuItemClick}>
|
||||||
|
<item.icon />
|
||||||
|
<span>{item.title}</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
))}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
|
||||||
|
{!loading && (
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupLabel>Account</SidebarGroupLabel>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
{accountItems.map((item) => (
|
||||||
|
<SidebarMenuItem key={item.title}>
|
||||||
|
{item.title === "Logout" ? (
|
||||||
|
<SidebarMenuButton
|
||||||
|
onClick={() => {
|
||||||
|
logout();
|
||||||
|
handleMenuItemClick();
|
||||||
|
}}
|
||||||
|
className={item.danger ? "text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" : ""}
|
||||||
|
>
|
||||||
|
<item.icon />
|
||||||
|
<span>{item.title}</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
) : (
|
||||||
|
<SidebarMenuButton asChild>
|
||||||
|
<Link
|
||||||
|
href={item.url}
|
||||||
|
onClick={handleMenuItemClick}
|
||||||
|
className={item.danger ? "text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" : ""}
|
||||||
|
>
|
||||||
|
<item.icon />
|
||||||
|
<span>{item.title}</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
)}
|
||||||
|
</SidebarMenuItem>
|
||||||
|
))}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupLabel>Features</SidebarGroupLabel>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
{features.map((item) => (
|
||||||
|
<SidebarMenuItem key={item.title}>
|
||||||
|
<SidebarMenuButton asChild>
|
||||||
|
<Link href={item.url} onClick={handleMenuItemClick}>
|
||||||
|
<item.icon />
|
||||||
|
<span>{item.title}</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
))}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
</SidebarContent>
|
||||||
|
|
||||||
|
<SidebarFooter>
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarFooter>
|
||||||
|
</Sidebar>
|
||||||
|
)
|
||||||
|
}
|
24
webui/components/footer.tsx
Executable file
24
webui/components/footer.tsx
Executable file
|
@ -0,0 +1,24 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
export default function Footer() {
|
||||||
|
return (
|
||||||
|
<footer className="py-12 px-6 border-t bg-background">
|
||||||
|
<div className="max-w-6xl mx-auto text-center">
|
||||||
|
<div className="flex items-center justify-center mb-4">
|
||||||
|
<Image
|
||||||
|
src="/kowalski.svg"
|
||||||
|
alt="Kowalski"
|
||||||
|
width={22}
|
||||||
|
height={22}
|
||||||
|
className="mr-2 dark:invert -mt-1"
|
||||||
|
/>
|
||||||
|
<span className="text-xl font-semibold">Kowalski</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Built with ❤️ by <Link href="https://git.p0ntus.com/ABOCN" className="underline hover:text-primary transition-all duration-300">ABOCN</Link> and contributors under open source licenses.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
29
webui/components/header-auth.tsx
Executable file
29
webui/components/header-auth.tsx
Executable file
|
@ -0,0 +1,29 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { RiTelegram2Line } from "react-icons/ri";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
|
|
||||||
|
export function HeaderAuth() {
|
||||||
|
const { isAuthenticated, loading } = useAuth();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="w-8 h-8 animate-pulse bg-muted rounded-md" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link href="/login">
|
||||||
|
<RiTelegram2Line />
|
||||||
|
Sign in with Telegram
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
21
webui/components/providers.tsx
Executable file
21
webui/components/providers.tsx
Executable file
|
@ -0,0 +1,21 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||||
|
|
||||||
|
export function ThemeProvider({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NextThemesProvider>) {
|
||||||
|
return (
|
||||||
|
<NextThemesProvider
|
||||||
|
attribute="class"
|
||||||
|
defaultTheme="system"
|
||||||
|
enableSystem
|
||||||
|
disableTransitionOnChange
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</NextThemesProvider>
|
||||||
|
)
|
||||||
|
}
|
37
webui/components/theme-toggle.tsx
Executable file
37
webui/components/theme-toggle.tsx
Executable file
|
@ -0,0 +1,37 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Moon, Sun } from "lucide-react"
|
||||||
|
import { useTheme } from "next-themes"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { theme, setTheme } = useTheme()
|
||||||
|
const [mounted, setMounted] = React.useState(false)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setMounted(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<Sun className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Toggle theme</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
|
||||||
|
>
|
||||||
|
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||||
|
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||||
|
<span className="sr-only">Toggle theme</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
46
webui/components/ui/badge.tsx
Normal file
46
webui/components/ui/badge.tsx
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span"> &
|
||||||
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : "span"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="badge"
|
||||||
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
59
webui/components/ui/button.tsx
Executable file
59
webui/components/ui/button.tsx
Executable file
|
@ -0,0 +1,59 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
184
webui/components/ui/command.tsx
Executable file
184
webui/components/ui/command.tsx
Executable file
|
@ -0,0 +1,184 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Command as CommandPrimitive } from "cmdk"
|
||||||
|
import { SearchIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
|
||||||
|
function Command({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive
|
||||||
|
data-slot="command"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandDialog({
|
||||||
|
title = "Command Palette",
|
||||||
|
description = "Search for a command to run...",
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Dialog> & {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
className?: string
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog {...props}>
|
||||||
|
<DialogHeader className="sr-only">
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogContent
|
||||||
|
className={cn("overflow-hidden p-0", className)}
|
||||||
|
showCloseButton={showCloseButton}
|
||||||
|
>
|
||||||
|
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||||
|
{children}
|
||||||
|
</Command>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="command-input-wrapper"
|
||||||
|
className="flex h-9 items-center gap-2 border-b px-3"
|
||||||
|
>
|
||||||
|
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
data-slot="command-input"
|
||||||
|
className={cn(
|
||||||
|
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.List
|
||||||
|
data-slot="command-list"
|
||||||
|
className={cn(
|
||||||
|
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandEmpty({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Empty
|
||||||
|
data-slot="command-empty"
|
||||||
|
className="py-6 text-center text-sm"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
data-slot="command-group"
|
||||||
|
className={cn(
|
||||||
|
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Separator
|
||||||
|
data-slot="command-separator"
|
||||||
|
className={cn("bg-border -mx-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
data-slot="command-item"
|
||||||
|
className={cn(
|
||||||
|
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="command-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandShortcut,
|
||||||
|
CommandSeparator,
|
||||||
|
}
|
143
webui/components/ui/dialog.tsx
Executable file
143
webui/components/ui/dialog.tsx
Executable file
|
@ -0,0 +1,143 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal data-slot="dialog-portal">
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn("text-lg leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
21
webui/components/ui/input.tsx
Executable file
21
webui/components/ui/input.tsx
Executable file
|
@ -0,0 +1,21 @@
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
48
webui/components/ui/popover.tsx
Executable file
48
webui/components/ui/popover.tsx
Executable file
|
@ -0,0 +1,48 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Popover({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||||
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||||
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverContent({
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
data-slot="popover-content"
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverAnchor({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||||
|
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
28
webui/components/ui/separator.tsx
Executable file
28
webui/components/ui/separator.tsx
Executable file
|
@ -0,0 +1,28 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
decorative = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
data-slot="separator"
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator }
|
139
webui/components/ui/sheet.tsx
Executable file
139
webui/components/ui/sheet.tsx
Executable file
|
@ -0,0 +1,139 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||||
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||||
|
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||||
|
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||||
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Overlay
|
||||||
|
data-slot="sheet-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "right",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||||
|
side?: "top" | "right" | "bottom" | "left"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Content
|
||||||
|
data-slot="sheet-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||||
|
side === "right" &&
|
||||||
|
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||||
|
side === "left" &&
|
||||||
|
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||||
|
side === "top" &&
|
||||||
|
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||||
|
side === "bottom" &&
|
||||||
|
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||||
|
<XIcon className="size-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-header"
|
||||||
|
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-footer"
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
data-slot="sheet-title"
|
||||||
|
className={cn("text-foreground font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
data-slot="sheet-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
}
|
726
webui/components/ui/sidebar.tsx
Executable file
726
webui/components/ui/sidebar.tsx
Executable file
|
@ -0,0 +1,726 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, VariantProps } from "class-variance-authority"
|
||||||
|
import { PanelLeftIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from "@/components/ui/sheet"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip"
|
||||||
|
|
||||||
|
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||||
|
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||||
|
const SIDEBAR_WIDTH = "16rem"
|
||||||
|
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||||
|
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||||
|
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||||
|
|
||||||
|
type SidebarContextProps = {
|
||||||
|
state: "expanded" | "collapsed"
|
||||||
|
open: boolean
|
||||||
|
setOpen: (open: boolean) => void
|
||||||
|
openMobile: boolean
|
||||||
|
setOpenMobile: (open: boolean) => void
|
||||||
|
isMobile: boolean
|
||||||
|
toggleSidebar: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||||
|
|
||||||
|
function useSidebar() {
|
||||||
|
const context = React.useContext(SidebarContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useSidebar must be used within a SidebarProvider.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarProvider({
|
||||||
|
defaultOpen = true,
|
||||||
|
open: openProp,
|
||||||
|
onOpenChange: setOpenProp,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
defaultOpen?: boolean
|
||||||
|
open?: boolean
|
||||||
|
onOpenChange?: (open: boolean) => void
|
||||||
|
}) {
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
const [openMobile, setOpenMobile] = React.useState(false)
|
||||||
|
|
||||||
|
// This is the internal state of the sidebar.
|
||||||
|
// We use openProp and setOpenProp for control from outside the component.
|
||||||
|
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||||
|
const open = openProp ?? _open
|
||||||
|
const setOpen = React.useCallback(
|
||||||
|
(value: boolean | ((value: boolean) => boolean)) => {
|
||||||
|
const openState = typeof value === "function" ? value(open) : value
|
||||||
|
if (setOpenProp) {
|
||||||
|
setOpenProp(openState)
|
||||||
|
} else {
|
||||||
|
_setOpen(openState)
|
||||||
|
}
|
||||||
|
|
||||||
|
// This sets the cookie to keep the sidebar state.
|
||||||
|
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||||
|
},
|
||||||
|
[setOpenProp, open]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Helper to toggle the sidebar.
|
||||||
|
const toggleSidebar = React.useCallback(() => {
|
||||||
|
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
||||||
|
}, [isMobile, setOpen, setOpenMobile])
|
||||||
|
|
||||||
|
// Adds a keyboard shortcut to toggle the sidebar.
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (
|
||||||
|
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||||
|
(event.metaKey || event.ctrlKey)
|
||||||
|
) {
|
||||||
|
event.preventDefault()
|
||||||
|
toggleSidebar()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown)
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||||
|
}, [toggleSidebar])
|
||||||
|
|
||||||
|
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||||
|
// This makes it easier to style the sidebar with Tailwind classes.
|
||||||
|
const state = open ? "expanded" : "collapsed"
|
||||||
|
|
||||||
|
const contextValue = React.useMemo<SidebarContextProps>(
|
||||||
|
() => ({
|
||||||
|
state,
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
isMobile,
|
||||||
|
openMobile,
|
||||||
|
setOpenMobile,
|
||||||
|
toggleSidebar,
|
||||||
|
}),
|
||||||
|
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarContext.Provider value={contextValue}>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-wrapper"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--sidebar-width": SIDEBAR_WIDTH,
|
||||||
|
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||||
|
...style,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
</SidebarContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sidebar({
|
||||||
|
side = "left",
|
||||||
|
variant = "sidebar",
|
||||||
|
collapsible = "offcanvas",
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
side?: "left" | "right"
|
||||||
|
variant?: "sidebar" | "floating" | "inset"
|
||||||
|
collapsible?: "offcanvas" | "icon" | "none"
|
||||||
|
}) {
|
||||||
|
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||||
|
|
||||||
|
if (collapsible === "none") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar"
|
||||||
|
className={cn(
|
||||||
|
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||||
|
<SheetContent
|
||||||
|
data-sidebar="sidebar"
|
||||||
|
data-slot="sidebar"
|
||||||
|
data-mobile="true"
|
||||||
|
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
side={side}
|
||||||
|
>
|
||||||
|
<SheetHeader className="sr-only">
|
||||||
|
<SheetTitle>Sidebar</SheetTitle>
|
||||||
|
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className="flex h-full w-full flex-col">{children}</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="group peer text-sidebar-foreground hidden md:block"
|
||||||
|
data-state={state}
|
||||||
|
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||||
|
data-variant={variant}
|
||||||
|
data-side={side}
|
||||||
|
data-slot="sidebar"
|
||||||
|
>
|
||||||
|
{/* This is what handles the sidebar gap on desktop */}
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-gap"
|
||||||
|
className={cn(
|
||||||
|
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||||
|
"group-data-[collapsible=offcanvas]:w-0",
|
||||||
|
"group-data-[side=right]:rotate-180",
|
||||||
|
variant === "floating" || variant === "inset"
|
||||||
|
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||||
|
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-container"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
|
||||||
|
side === "left"
|
||||||
|
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||||
|
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||||
|
// Adjust the padding for floating and inset variants.
|
||||||
|
variant === "floating" || variant === "inset"
|
||||||
|
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||||
|
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-sidebar="sidebar"
|
||||||
|
data-slot="sidebar-inner"
|
||||||
|
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarTrigger({
|
||||||
|
className,
|
||||||
|
onClick,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Button>) {
|
||||||
|
const { toggleSidebar } = useSidebar()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-sidebar="trigger"
|
||||||
|
data-slot="sidebar-trigger"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn("size-7", className)}
|
||||||
|
onClick={(event) => {
|
||||||
|
onClick?.(event)
|
||||||
|
toggleSidebar()
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<PanelLeftIcon />
|
||||||
|
<span className="sr-only">Toggle Sidebar</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||||
|
const { toggleSidebar } = useSidebar()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
data-sidebar="rail"
|
||||||
|
data-slot="sidebar-rail"
|
||||||
|
aria-label="Toggle Sidebar"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
title="Toggle Sidebar"
|
||||||
|
className={cn(
|
||||||
|
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
|
||||||
|
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||||
|
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||||
|
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
|
||||||
|
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||||
|
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
data-slot="sidebar-inset"
|
||||||
|
className={cn(
|
||||||
|
"bg-background relative flex w-full flex-1 flex-col",
|
||||||
|
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Input>) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
data-slot="sidebar-input"
|
||||||
|
data-sidebar="input"
|
||||||
|
className={cn("bg-background h-8 w-full shadow-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-header"
|
||||||
|
data-sidebar="header"
|
||||||
|
className={cn("flex flex-col gap-2 p-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-footer"
|
||||||
|
data-sidebar="footer"
|
||||||
|
className={cn("flex flex-col gap-2 p-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Separator>) {
|
||||||
|
return (
|
||||||
|
<Separator
|
||||||
|
data-slot="sidebar-separator"
|
||||||
|
data-sidebar="separator"
|
||||||
|
className={cn("bg-sidebar-border mx-2 w-auto", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-content"
|
||||||
|
data-sidebar="content"
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-group"
|
||||||
|
data-sidebar="group"
|
||||||
|
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarGroupLabel({
|
||||||
|
className,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : "div"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="sidebar-group-label"
|
||||||
|
data-sidebar="group-label"
|
||||||
|
className={cn(
|
||||||
|
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarGroupAction({
|
||||||
|
className,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="sidebar-group-action"
|
||||||
|
data-sidebar="group-action"
|
||||||
|
className={cn(
|
||||||
|
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
// Increases the hit area of the button on mobile.
|
||||||
|
"after:absolute after:-inset-2 md:after:hidden",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarGroupContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-group-content"
|
||||||
|
data-sidebar="group-content"
|
||||||
|
className={cn("w-full text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
data-slot="sidebar-menu"
|
||||||
|
data-sidebar="menu"
|
||||||
|
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="sidebar-menu-item"
|
||||||
|
data-sidebar="menu-item"
|
||||||
|
className={cn("group/menu-item relative", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidebarMenuButtonVariants = cva(
|
||||||
|
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||||
|
outline:
|
||||||
|
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-8 text-sm",
|
||||||
|
sm: "h-7 text-xs",
|
||||||
|
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function SidebarMenuButton({
|
||||||
|
asChild = false,
|
||||||
|
isActive = false,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
tooltip,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
isActive?: boolean
|
||||||
|
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||||
|
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
const { isMobile, state } = useSidebar()
|
||||||
|
|
||||||
|
const button = (
|
||||||
|
<Comp
|
||||||
|
data-slot="sidebar-menu-button"
|
||||||
|
data-sidebar="menu-button"
|
||||||
|
data-size={size}
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!tooltip) {
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof tooltip === "string") {
|
||||||
|
tooltip = {
|
||||||
|
children: tooltip,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="right"
|
||||||
|
align="center"
|
||||||
|
hidden={state !== "collapsed" || isMobile}
|
||||||
|
{...tooltip}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuAction({
|
||||||
|
className,
|
||||||
|
asChild = false,
|
||||||
|
showOnHover = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
showOnHover?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="sidebar-menu-action"
|
||||||
|
data-sidebar="menu-action"
|
||||||
|
className={cn(
|
||||||
|
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
// Increases the hit area of the button on mobile.
|
||||||
|
"after:absolute after:-inset-2 md:after:hidden",
|
||||||
|
"peer-data-[size=sm]/menu-button:top-1",
|
||||||
|
"peer-data-[size=default]/menu-button:top-1.5",
|
||||||
|
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
showOnHover &&
|
||||||
|
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuBadge({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-menu-badge"
|
||||||
|
data-sidebar="menu-badge"
|
||||||
|
className={cn(
|
||||||
|
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
|
||||||
|
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||||
|
"peer-data-[size=sm]/menu-button:top-1",
|
||||||
|
"peer-data-[size=default]/menu-button:top-1.5",
|
||||||
|
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuSkeleton({
|
||||||
|
className,
|
||||||
|
showIcon = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
showIcon?: boolean
|
||||||
|
}) {
|
||||||
|
// Random width between 50 to 90%.
|
||||||
|
const width = React.useMemo(() => {
|
||||||
|
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-menu-skeleton"
|
||||||
|
data-sidebar="menu-skeleton"
|
||||||
|
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{showIcon && (
|
||||||
|
<Skeleton
|
||||||
|
className="size-4 rounded-md"
|
||||||
|
data-sidebar="menu-skeleton-icon"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Skeleton
|
||||||
|
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||||
|
data-sidebar="menu-skeleton-text"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--skeleton-width": width,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
data-slot="sidebar-menu-sub"
|
||||||
|
data-sidebar="menu-sub"
|
||||||
|
className={cn(
|
||||||
|
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuSubItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="sidebar-menu-sub-item"
|
||||||
|
data-sidebar="menu-sub-item"
|
||||||
|
className={cn("group/menu-sub-item relative", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuSubButton({
|
||||||
|
asChild = false,
|
||||||
|
size = "md",
|
||||||
|
isActive = false,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"a"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
size?: "sm" | "md"
|
||||||
|
isActive?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "a"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="sidebar-menu-sub-button"
|
||||||
|
data-sidebar="menu-sub-button"
|
||||||
|
data-size={size}
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(
|
||||||
|
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||||
|
size === "sm" && "text-xs",
|
||||||
|
size === "md" && "text-sm",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupAction,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarInput,
|
||||||
|
SidebarInset,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuAction,
|
||||||
|
SidebarMenuBadge,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarMenuSkeleton,
|
||||||
|
SidebarMenuSub,
|
||||||
|
SidebarMenuSubButton,
|
||||||
|
SidebarMenuSubItem,
|
||||||
|
SidebarProvider,
|
||||||
|
SidebarRail,
|
||||||
|
SidebarSeparator,
|
||||||
|
SidebarTrigger,
|
||||||
|
useSidebar,
|
||||||
|
}
|
13
webui/components/ui/skeleton.tsx
Executable file
13
webui/components/ui/skeleton.tsx
Executable file
|
@ -0,0 +1,13 @@
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="skeleton"
|
||||||
|
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton }
|
66
webui/components/ui/tabs.sh.tsx
Executable file
66
webui/components/ui/tabs.sh.tsx
Executable file
|
@ -0,0 +1,66 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Tabs({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Root
|
||||||
|
data-slot="tabs"
|
||||||
|
className={cn("flex flex-col gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
data-slot="tabs-list"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsTrigger({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
data-slot="tabs-trigger"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
data-slot="tabs-content"
|
||||||
|
className={cn("flex-1 outline-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
127
webui/components/ui/tabs.tsx
Executable file
127
webui/components/ui/tabs.tsx
Executable file
|
@ -0,0 +1,127 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const TabsContext = React.createContext<{
|
||||||
|
value: string
|
||||||
|
onValueChange: (value: string) => void
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
interface TabsProps {
|
||||||
|
value: string
|
||||||
|
onValueChange: (value: string) => void
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const Tabs = React.forwardRef<HTMLDivElement, TabsProps>(
|
||||||
|
({ className, value, onValueChange, children, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<TabsContext.Provider value={{ value, onValueChange }}>
|
||||||
|
<div ref={ref} className={cn("", className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</TabsContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Tabs.displayName = "Tabs"
|
||||||
|
|
||||||
|
interface TabsListProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<HTMLDivElement, TabsListProps>(
|
||||||
|
({ className, children, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
TabsList.displayName = "TabsList"
|
||||||
|
|
||||||
|
interface TabsTriggerProps {
|
||||||
|
value: string
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<HTMLButtonElement, TabsTriggerProps>(
|
||||||
|
({ className, children, value, ...props }, ref) => {
|
||||||
|
const context = React.useContext(TabsContext)
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("TabsTrigger must be used within Tabs")
|
||||||
|
}
|
||||||
|
|
||||||
|
const { value: currentValue, onValueChange } = context
|
||||||
|
const isActive = currentValue === value
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
isActive
|
||||||
|
? "bg-background text-foreground shadow-sm"
|
||||||
|
: "text-muted-foreground hover:bg-background/50 hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={() => onValueChange(value)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
TabsTrigger.displayName = "TabsTrigger"
|
||||||
|
|
||||||
|
interface TabsContentProps {
|
||||||
|
value: string
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<HTMLDivElement, TabsContentProps>(
|
||||||
|
({ className, children, value, ...props }, ref) => {
|
||||||
|
const context = React.useContext(TabsContext)
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("TabsContent must be used within Tabs")
|
||||||
|
}
|
||||||
|
|
||||||
|
const { value: currentValue } = context
|
||||||
|
|
||||||
|
if (currentValue !== value) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
TabsContent.displayName = "TabsContent"
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
61
webui/components/ui/tooltip.tsx
Executable file
61
webui/components/ui/tooltip.tsx
Executable file
|
@ -0,0 +1,61 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function TooltipProvider({
|
||||||
|
delayDuration = 0,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Provider
|
||||||
|
data-slot="tooltip-provider"
|
||||||
|
delayDuration={delayDuration}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||||
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 0,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||||
|
</TooltipPrimitive.Content>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
130
webui/contexts/auth-context.tsx
Executable file
130
webui/contexts/auth-context.tsx
Executable file
|
@ -0,0 +1,130 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface UserData {
|
||||||
|
telegramId: string;
|
||||||
|
username: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
aiEnabled: boolean;
|
||||||
|
showThinking: boolean;
|
||||||
|
customAiModel: string;
|
||||||
|
aiTemperature: number;
|
||||||
|
aiRequests: number;
|
||||||
|
aiCharacters: number;
|
||||||
|
disabledCommands: string[];
|
||||||
|
languageCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: UserData | null;
|
||||||
|
loading: boolean;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
refreshUser: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [user, setUser] = useState<UserData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const isAuthenticated = !!user;
|
||||||
|
|
||||||
|
const fetchUser = async () => {
|
||||||
|
try {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
setUser(null);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionToken = localStorage.getItem('kowalski-session');
|
||||||
|
|
||||||
|
if (!sessionToken) {
|
||||||
|
setUser(null);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('/api/user/profile', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${sessionToken}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const userData = await response.json();
|
||||||
|
setUser(userData);
|
||||||
|
} else {
|
||||||
|
setUser(null);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.removeItem('kowalski-session');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching user data:', error);
|
||||||
|
setUser(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
try {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const sessionToken = localStorage.getItem('kowalski-session');
|
||||||
|
if (sessionToken) {
|
||||||
|
await fetch('/api/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${sessionToken}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
localStorage.removeItem('kowalski-session');
|
||||||
|
}
|
||||||
|
setUser(null);
|
||||||
|
window.location.href = '/login';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout error:', error);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.removeItem('kowalski-session');
|
||||||
|
}
|
||||||
|
setUser(null);
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshUser = async () => {
|
||||||
|
await fetchUser();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUser();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
user,
|
||||||
|
loading,
|
||||||
|
isAuthenticated,
|
||||||
|
logout,
|
||||||
|
refreshUser,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
11
webui/drizzle.config.ts
Executable file
11
webui/drizzle.config.ts
Executable file
|
@ -0,0 +1,11 @@
|
||||||
|
import 'dotenv/config';
|
||||||
|
import { defineConfig } from 'drizzle-kit';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
out: './drizzle',
|
||||||
|
schema: './lib/schema.ts',
|
||||||
|
dialect: 'postgresql',
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.databaseUrl!,
|
||||||
|
},
|
||||||
|
});
|
16
webui/eslint.config.mjs
Executable file
16
webui/eslint.config.mjs
Executable file
|
@ -0,0 +1,16 @@
|
||||||
|
import { dirname } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { FlatCompat } from "@eslint/eslintrc";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const compat = new FlatCompat({
|
||||||
|
baseDirectory: __dirname,
|
||||||
|
});
|
||||||
|
|
||||||
|
const eslintConfig = [
|
||||||
|
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||||
|
];
|
||||||
|
|
||||||
|
export default eslintConfig;
|
19
webui/hooks/use-mobile.ts
Executable file
19
webui/hooks/use-mobile.ts
Executable file
|
@ -0,0 +1,19 @@
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
const MOBILE_BREAKPOINT = 768
|
||||||
|
|
||||||
|
export function useIsMobile() {
|
||||||
|
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||||
|
const onChange = () => {
|
||||||
|
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||||
|
}
|
||||||
|
mql.addEventListener("change", onChange)
|
||||||
|
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||||
|
return () => mql.removeEventListener("change", onChange)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return !!isMobile
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue