mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
merge(github): merge github/main, keep our code and docs/spec
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# Documentation
|
||||
*.md
|
||||
docs/
|
||||
assets/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.cursor/
|
||||
.idea/
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
**/node_modules/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
**/dist/
|
||||
build/
|
||||
**/build/
|
||||
out/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Test
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# Mise
|
||||
.mise.toml.local
|
||||
|
||||
# Data
|
||||
data/
|
||||
*.db
|
||||
*.sqlite
|
||||
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
+394
@@ -0,0 +1,394 @@
|
||||
# Memoh Docker Deployment Guide
|
||||
|
||||
Deploy Memoh AI Agent System with Docker Compose in one command.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Clone the Repository
|
||||
```bash
|
||||
git clone https://github.com/memohai/Memoh.git
|
||||
cd Memoh
|
||||
```
|
||||
|
||||
### 2. One-Click Deployment
|
||||
```bash
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
The script will automatically:
|
||||
- Check Docker and Docker Compose installation
|
||||
- Create `config.toml` configuration file (if not exists)
|
||||
- Build MCP image
|
||||
- Start all services
|
||||
|
||||
### 3. Access the Application
|
||||
- Web UI: http://localhost
|
||||
- API Service: http://localhost:8080
|
||||
- Agent Gateway: http://localhost:8081
|
||||
|
||||
Default admin credentials:
|
||||
- Username: `admin`
|
||||
- Password: `admin123` (change in `config.toml`)
|
||||
|
||||
## Manual Deployment
|
||||
|
||||
If you prefer not to use the automated script:
|
||||
|
||||
```bash
|
||||
# 1. Create configuration file
|
||||
cp docker/config/config.docker.toml config.toml
|
||||
|
||||
# 2. Edit configuration (Important!)
|
||||
nano config.toml
|
||||
|
||||
# 3. Build MCP image
|
||||
docker build -f docker/Dockerfile.mcp -t memoh-mcp:latest .
|
||||
|
||||
# 4. Start services
|
||||
docker compose up -d
|
||||
|
||||
# 5. View logs
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
This deployment uses the host's Docker daemon to manage Bot containers:
|
||||
|
||||
```
|
||||
Host Docker
|
||||
├── memoh-postgres (PostgreSQL)
|
||||
├── memoh-qdrant (Qdrant)
|
||||
├── memoh-server (Main Service) ← Manages Bot containers via /var/run/docker.sock
|
||||
├── memoh-agent (Agent Gateway)
|
||||
├── memoh-web (Web Frontend)
|
||||
└── memoh-bot-* (Bot containers, dynamically created by main service)
|
||||
```
|
||||
|
||||
Advantages:
|
||||
- ✅ Lightweight, no additional Docker daemon needed
|
||||
- ✅ Better performance, uses host container runtime directly
|
||||
- ✅ Easier to manage and debug
|
||||
- ✅ Lower resource consumption
|
||||
|
||||
## Common Commands
|
||||
|
||||
### Using Docker Compose
|
||||
```bash
|
||||
docker compose up -d # Start services
|
||||
docker compose down # Stop services
|
||||
docker compose logs -f # View logs
|
||||
docker compose ps # View status
|
||||
docker compose restart # Restart services
|
||||
```
|
||||
|
||||
### Bot Container Management
|
||||
|
||||
View all Bot containers:
|
||||
```bash
|
||||
docker ps -a | grep memoh-bot
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Configuration is managed through `config.toml` file. Key configuration items:
|
||||
|
||||
```toml
|
||||
# Admin account
|
||||
[admin]
|
||||
username = "admin"
|
||||
password = "admin123" # Must change
|
||||
email = "admin@yourdomain.com"
|
||||
|
||||
# Auth configuration
|
||||
[auth]
|
||||
jwt_secret = "YZq8kXrW5dFpNt9mLxQvHbRjKsMnOePw" # Must change
|
||||
jwt_expires_in = "168h"
|
||||
|
||||
# PostgreSQL password
|
||||
[postgres]
|
||||
host = "postgres"
|
||||
port = 5432
|
||||
user = "memoh"
|
||||
password = "memoh123" # Must change
|
||||
database = "memoh"
|
||||
sslmode = "disable"
|
||||
```
|
||||
|
||||
### Application Configuration (config.toml)
|
||||
|
||||
Main configuration items:
|
||||
|
||||
```toml
|
||||
[postgres]
|
||||
host = "postgres"
|
||||
password = "your_secure_password" # Must change in config.toml
|
||||
|
||||
[containerd]
|
||||
socket_path = "unix:///var/run/docker.sock" # Use host Docker
|
||||
|
||||
[qdrant]
|
||||
base_url = "http://qdrant:6334"
|
||||
```
|
||||
|
||||
## Service Overview
|
||||
|
||||
| Service | Container Name | Ports | Description |
|
||||
|---------|---------------|-------|-------------|
|
||||
| postgres | memoh-postgres | - | PostgreSQL database (internal only) |
|
||||
| qdrant | memoh-qdrant | - | Qdrant vector database (internal only) |
|
||||
| docker-cli | memoh-docker-cli | - | Docker CLI (uses host Docker) |
|
||||
| server | memoh-server | 8080 | Main service (Go) |
|
||||
| agent | memoh-agent | 8081 | Agent Gateway (Bun) |
|
||||
| web | memoh-web | 80 | Web frontend (Nginx) |
|
||||
|
||||
## Data Persistence
|
||||
|
||||
Data is stored in Docker volumes:
|
||||
|
||||
```bash
|
||||
# View volumes
|
||||
docker volume ls | grep memoh
|
||||
|
||||
# Backup database
|
||||
docker compose exec postgres pg_dump -U memoh memoh > backup.sql
|
||||
```
|
||||
|
||||
### Bot Container Management
|
||||
|
||||
Bot containers are dynamically created by the main service and run directly on the host:
|
||||
|
||||
```bash
|
||||
# View all Bot containers
|
||||
docker ps -a | grep memoh-bot
|
||||
|
||||
# View Bot logs
|
||||
docker logs <bot-container-id>
|
||||
|
||||
# Enter Bot container
|
||||
docker exec -it <bot-container-id> sh
|
||||
|
||||
# Stop Bot container
|
||||
docker stop <bot-container-id>
|
||||
```
|
||||
|
||||
## Backup and Restore
|
||||
|
||||
### Backup
|
||||
```bash
|
||||
# Create backup directory
|
||||
mkdir -p backups
|
||||
|
||||
# Backup database
|
||||
docker compose exec postgres pg_dump -U memoh memoh > backups/postgres_$(date +%Y%m%d).sql
|
||||
|
||||
# Backup Bot data
|
||||
docker run --rm -v memoh_memoh_bot_data:/data -v $(pwd)/backups:/backup alpine \
|
||||
tar czf /backup/bot_data_$(date +%Y%m%d).tar.gz -C /data .
|
||||
|
||||
# Backup configuration files
|
||||
tar czf backups/config_$(date +%Y%m%d).tar.gz config.toml
|
||||
```
|
||||
|
||||
### Restore
|
||||
```bash
|
||||
# Restore database
|
||||
docker compose exec -T postgres psql -U memoh memoh < backups/postgres_20240101.sql
|
||||
|
||||
# Restore Bot data
|
||||
docker run --rm -v memoh_memoh_bot_data:/data -v $(pwd)/backups:/backup alpine \
|
||||
tar xzf /backup/bot_data_20240101.tar.gz -C /data
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Services Won't Start
|
||||
```bash
|
||||
# View detailed logs
|
||||
docker compose logs server
|
||||
|
||||
# Check configuration
|
||||
docker compose config
|
||||
|
||||
# Rebuild
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Database Connection Failed
|
||||
```bash
|
||||
# Check if database is ready
|
||||
docker compose exec postgres pg_isready -U memoh
|
||||
|
||||
# Test connection
|
||||
docker compose exec postgres psql -U memoh -d memoh
|
||||
|
||||
# View database logs
|
||||
docker compose logs postgres
|
||||
```
|
||||
|
||||
### Port Conflicts
|
||||
```bash
|
||||
# Check port usage
|
||||
sudo netstat -tlnp | grep :8080
|
||||
sudo netstat -tlnp | grep :80
|
||||
|
||||
# Modify port mapping in docker-compose.yml
|
||||
# Example: change "80:80" to "8000:80"
|
||||
```
|
||||
|
||||
### Docker Socket Permission Issues
|
||||
```bash
|
||||
# Add user to docker group
|
||||
sudo usermod -aG docker $USER
|
||||
newgrp docker
|
||||
|
||||
# Check permissions
|
||||
ls -la /var/run/docker.sock
|
||||
```
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### 1. Use HTTPS
|
||||
|
||||
Create `docker-compose.override.yml`:
|
||||
```yaml
|
||||
services:
|
||||
web:
|
||||
ports:
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./ssl:/etc/nginx/ssl:ro
|
||||
- ./docker/config/nginx-https.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
```
|
||||
|
||||
Create `docker/config/nginx-https.conf`:
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name your-domain.com;
|
||||
|
||||
ssl_certificate /etc/nginx/ssl/cert.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/key.pem;
|
||||
|
||||
# SSL configuration
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
|
||||
# Other configurations same as docker/config/nginx.conf
|
||||
# ...
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Resource Limits
|
||||
|
||||
Edit `docker-compose.yml` to add resource limits:
|
||||
```yaml
|
||||
services:
|
||||
server:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2'
|
||||
memory: 2G
|
||||
reservations:
|
||||
cpus: '1'
|
||||
memory: 1G
|
||||
```
|
||||
|
||||
### 3. Security Recommendations
|
||||
|
||||
Production environment recommendations:
|
||||
- Change all default passwords in `config.toml`
|
||||
- Use strong JWT secret
|
||||
- Configure firewall rules
|
||||
- Use HTTPS
|
||||
- Regular data backups
|
||||
- Limit containerd socket access permissions
|
||||
- Run services as non-root user
|
||||
- Configure log rotation
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### PostgreSQL Optimization
|
||||
Create `postgres-custom.conf`:
|
||||
```
|
||||
shared_buffers = 2GB
|
||||
effective_cache_size = 6GB
|
||||
maintenance_work_mem = 512MB
|
||||
checkpoint_completion_target = 0.9
|
||||
wal_buffers = 16MB
|
||||
```
|
||||
|
||||
Mount in `docker-compose.yml`:
|
||||
```yaml
|
||||
postgres:
|
||||
volumes:
|
||||
- ./postgres-custom.conf:/etc/postgresql/postgresql.conf:ro
|
||||
command: postgres -c config_file=/etc/postgresql/postgresql.conf
|
||||
```
|
||||
|
||||
### Network Optimization
|
||||
```yaml
|
||||
networks:
|
||||
memoh-network:
|
||||
driver: bridge
|
||||
driver_opts:
|
||||
com.docker.network.driver.mtu: 1500
|
||||
```
|
||||
|
||||
## Update Application
|
||||
|
||||
```bash
|
||||
# Pull latest code
|
||||
git pull
|
||||
|
||||
# Rebuild and restart
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
## Complete Uninstall
|
||||
|
||||
```bash
|
||||
# Stop and remove all containers
|
||||
docker compose down
|
||||
|
||||
# Remove data volumes (Warning! This deletes all data)
|
||||
docker compose down -v
|
||||
|
||||
# Remove images
|
||||
docker rmi memoh-mcp:latest
|
||||
docker rmi $(docker images | grep memoh | awk '{print $3}')
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
⚠️ Important Security Notes:
|
||||
|
||||
1. **Docker Socket Access**: The main service container has access to the host Docker socket, which means the application can manage other containers on the host. Only run in trusted environments.
|
||||
2. **Change Default Passwords**: Must change all default passwords in `config.toml`
|
||||
3. **Strong JWT Secret**: Use a strong random JWT secret (generate with `openssl rand -base64 32`)
|
||||
4. **Firewall**: Configure firewall to only open necessary ports
|
||||
5. **HTTPS**: Use HTTPS in production
|
||||
6. **Regular Backups**: Regularly backup data
|
||||
7. **Updates**: Regularly update images and dependencies
|
||||
|
||||
## Get Help
|
||||
|
||||
- Detailed Documentation: [DOCKER_DEPLOYMENT_CN.md](DOCKER_DEPLOYMENT_CN.md) (Chinese)
|
||||
- GitHub Issues: https://github.com/memohai/Memoh/issues
|
||||
- Telegram Group: https://t.me/memohai
|
||||
- Email: business@memoh.net
|
||||
|
||||
---
|
||||
|
||||
**That's it! Deploy Memoh in minutes!**
|
||||
@@ -15,6 +15,11 @@
|
||||
<img src="https://img.shields.io/github/last-commit/memohai/Memoh" alt="Last Commit" />
|
||||
<img src="https://img.shields.io/github/issues/memohai/Memoh" alt="Issues" />
|
||||
</div>
|
||||
<div align="center">
|
||||
[<a href="https://t.me/memohai">Telegram Group</a>]
|
||||
[<a href="https://docs.memoh.ai">Documentation</a>]
|
||||
[<a href="mailto:business@memoh.net">Cooperation</a>]
|
||||
</div>
|
||||
<hr>
|
||||
</div>
|
||||
|
||||
@@ -43,9 +48,23 @@ Memoh Bot can distinguish and remember requests from multiple humans and bots, w
|
||||
|
||||
Please refer to the [Roadmap Version 0.1](https://github.com/memohai/Memoh/issues/2) for more details.
|
||||
|
||||
## Development
|
||||
## Quick Start
|
||||
|
||||
Refer to [CONTRIBUTING.md](CONTRIBUTING.md) for more details.
|
||||
### Docker Deployment (Recommended)
|
||||
|
||||
The fastest way to deploy Memoh:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/memohai/Memoh.git
|
||||
cd Memoh
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
Visit http://localhost after deployment. See [Docker Deployment Guide](README_DOCKER.md) for details.
|
||||
|
||||
### Development
|
||||
|
||||
Refer to [CONTRIBUTING.md](CONTRIBUTING.md) for development setup.
|
||||
|
||||
## Star History
|
||||
|
||||
@@ -57,8 +76,6 @@ Refer to [CONTRIBUTING.md](CONTRIBUTING.md) for more details.
|
||||
<img src="https://contrib.rocks/image?repo=memohai/Memoh" />
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
**LICENSE**: AGPLv3
|
||||
|
||||
Copyright (C) 2026 Memoh. All rights reserved.
|
||||
|
||||
+20
-3
@@ -15,6 +15,11 @@
|
||||
<img src="https://img.shields.io/github/last-commit/memohai/Memoh" alt="Last Commit" />
|
||||
<img src="https://img.shields.io/github/issues/memohai/Memoh" alt="Issues" />
|
||||
</div>
|
||||
<div align="center">
|
||||
[<a href="https://t.me/memohai">Telegram 群组</a>]
|
||||
[<a href="https://docs.memoh.ai">文档</a>]
|
||||
[<a href="mailto:business@memoh.net">合作</a>]
|
||||
</div>
|
||||
<hr>
|
||||
</div>
|
||||
|
||||
@@ -43,7 +48,21 @@ Memoh Bot 能够区分并记忆来自多个人类/Bot 的请求,可在任意
|
||||
|
||||
详情请参阅 [Roadmap Version 0.1](https://github.com/memohai/Memoh/issues/2)。
|
||||
|
||||
## 开发
|
||||
## 快速开始
|
||||
|
||||
### Docker 部署(推荐)
|
||||
|
||||
最快的部署方式:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/memohai/Memoh.git
|
||||
cd Memoh
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
部署完成后访问 http://localhost。详见 [Docker 部署指南](README_DOCKER.md)。
|
||||
|
||||
### 开发环境
|
||||
|
||||
详见 [CONTRIBUTING.md](CONTRIBUTING.md)。
|
||||
|
||||
@@ -57,8 +76,6 @@ Memoh Bot 能够区分并记忆来自多个人类/Bot 的请求,可在任意
|
||||
<img src="https://contrib.rocks/image?repo=memohai/Memoh" />
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
**LICENSE**: AGPLv3
|
||||
|
||||
Copyright (C) 2026 Memoh. All rights reserved.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 269 KiB |
@@ -0,0 +1,81 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo -e "${GREEN} Memoh Docker Compose Deployment${NC}"
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo ""
|
||||
|
||||
# Check Docker
|
||||
if ! command -v docker &> /dev/null; then
|
||||
echo -e "${RED}Error: Docker is not installed${NC}"
|
||||
echo "Please install Docker first:"
|
||||
echo " - Linux: curl -fsSL https://get.docker.com | sh"
|
||||
echo " - macOS: brew install --cask docker"
|
||||
echo " - Windows: https://docs.docker.com/desktop/install/windows-install/"
|
||||
echo " - Official guide: https://docs.docker.com/get-docker/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check Docker Compose
|
||||
if ! docker compose version &> /dev/null; then
|
||||
echo -e "${RED}Error: Docker Compose is not installed or version is too old${NC}"
|
||||
echo "Docker Compose v2.0+ is required (bundled with Docker Desktop)"
|
||||
echo " - Linux: sudo apt-get install docker-compose-plugin"
|
||||
echo " - Or follow: https://docs.docker.com/compose/install/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}✓ Docker and Docker Compose are installed${NC}"
|
||||
echo ""
|
||||
|
||||
|
||||
# Check config.toml
|
||||
if [ ! -f config.toml ]; then
|
||||
echo -e "${YELLOW}⚠ config.toml does not exist, creating...${NC}"
|
||||
cp docker/config/config.docker.toml config.toml
|
||||
echo -e "${GREEN}✓ config.toml created${NC}"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Build MCP image
|
||||
echo -e "${GREEN}Building MCP image...${NC}"
|
||||
if docker build -f docker/Dockerfile.mcp -t memoh-mcp:latest . > /dev/null 2>&1; then
|
||||
echo -e "${GREEN}✓ MCP image built successfully${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}⚠ MCP image build failed, will try to pull at runtime${NC}"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Start services
|
||||
echo -e "${GREEN}Starting services...${NC}"
|
||||
docker compose up -d
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo -e "${GREEN} Deployment Complete!${NC}"
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo ""
|
||||
echo "Service URLs:"
|
||||
echo " - Web UI: http://localhost"
|
||||
echo " - API Service: http://localhost:8080"
|
||||
echo " - Agent Gateway: http://localhost:8081"
|
||||
echo ""
|
||||
echo "View service status:"
|
||||
echo " docker compose ps"
|
||||
echo ""
|
||||
echo "View logs:"
|
||||
echo " docker compose logs -f"
|
||||
echo ""
|
||||
echo "Stop services:"
|
||||
echo " docker compose down"
|
||||
echo ""
|
||||
echo -e "${YELLOW}⚠ First startup may take 1-2 minutes, please be patient${NC}"
|
||||
echo ""
|
||||
echo "View detailed documentation: DEPLOYMENT.md"
|
||||
@@ -0,0 +1,114 @@
|
||||
services:
|
||||
|
||||
postgres:
|
||||
image: postgres:18.1-alpine
|
||||
container_name: memoh-postgres
|
||||
environment:
|
||||
POSTGRES_DB: memoh
|
||||
POSTGRES_USER: memoh
|
||||
POSTGRES_PASSWORD: memoh123
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./db/migrations:/docker-entrypoint-initdb.d:ro
|
||||
expose:
|
||||
- "5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U memoh"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- memoh-network
|
||||
|
||||
qdrant:
|
||||
image: qdrant/qdrant:latest
|
||||
container_name: memoh-qdrant
|
||||
volumes:
|
||||
- qdrant_data:/qdrant/storage
|
||||
expose:
|
||||
- "6333"
|
||||
- "6334"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "timeout 10s bash -c ':> /dev/tcp/127.0.0.1/6333' || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- memoh-network
|
||||
|
||||
docker-cli:
|
||||
image: docker:27-cli
|
||||
container_name: memoh-docker-cli
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- memoh_bot_data:/var/lib/memoh/data
|
||||
command: ["tail", "-f", "/dev/null"]
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- memoh-network
|
||||
|
||||
server:
|
||||
build:
|
||||
context: ./docker
|
||||
dockerfile: Dockerfile.server
|
||||
container_name: memoh-server
|
||||
volumes:
|
||||
- ./config.toml:/app/config.toml:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- memoh_bot_data:/var/lib/memoh/data
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
qdrant:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- memoh-network
|
||||
|
||||
agent:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile.agent
|
||||
container_name: memoh-agent
|
||||
volumes:
|
||||
- ./config.toml:/config.toml:ro
|
||||
ports:
|
||||
- "8081:8081"
|
||||
depends_on:
|
||||
- server
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- memoh-network
|
||||
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile.web
|
||||
args:
|
||||
- VITE_API_URL=http://localhost:8080
|
||||
- VITE_AGENT_URL=http://localhost:8081
|
||||
container_name: memoh-web
|
||||
ports:
|
||||
- "80:80"
|
||||
depends_on:
|
||||
- server
|
||||
- agent
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- memoh-network
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
qdrant_data:
|
||||
driver: local
|
||||
memoh_bot_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
memoh-network:
|
||||
driver: bridge
|
||||
@@ -0,0 +1,28 @@
|
||||
FROM oven/bun:1 AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
COPY agent/package.json agent/bun.lock* ./
|
||||
|
||||
RUN bun install
|
||||
|
||||
COPY agent/ ./
|
||||
|
||||
RUN bun run build --external jsdom
|
||||
|
||||
FROM oven/bun:1-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache ca-certificates wget
|
||||
|
||||
COPY --from=builder /build/dist /app/dist
|
||||
COPY --from=builder /build/node_modules /app/node_modules
|
||||
COPY --from=builder /build/package.json /app/package.json
|
||||
|
||||
EXPOSE 8081
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8081/health || exit 1
|
||||
|
||||
CMD ["bun", "run", "dist/index.js"]
|
||||
@@ -0,0 +1,31 @@
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
RUN apk add --no-cache git make
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||
go build -trimpath -ldflags "-s -w" \
|
||||
-o memoh-server ./cmd/agent/main.go
|
||||
|
||||
FROM alpine:latest
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata wget
|
||||
|
||||
COPY --from=builder /build/memoh-server /app/memoh-server
|
||||
|
||||
RUN mkdir -p /var/lib/memoh/data
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
|
||||
|
||||
CMD ["/app/memoh-server"]
|
||||
@@ -0,0 +1,33 @@
|
||||
FROM node:25-alpine AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
RUN npm install -g pnpm@10
|
||||
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||
|
||||
COPY packages ./packages
|
||||
|
||||
RUN pnpm install
|
||||
|
||||
ARG VITE_API_URL=http://localhost:8080
|
||||
ARG VITE_AGENT_URL=http://localhost:8081
|
||||
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
ENV VITE_AGENT_URL=$VITE_AGENT_URL
|
||||
|
||||
WORKDIR /build/packages/web
|
||||
RUN pnpm build
|
||||
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY --from=builder /build/packages/web/dist /usr/share/nginx/html
|
||||
|
||||
COPY docker/config/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost/ || exit 1
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,54 @@
|
||||
## Service configuration
|
||||
[log]
|
||||
level = "info"
|
||||
format = "text"
|
||||
|
||||
[server]
|
||||
addr = ":8080"
|
||||
|
||||
## Admin
|
||||
[admin]
|
||||
username = "admin"
|
||||
password = "admin123"
|
||||
email = "admin@memoh.local"
|
||||
|
||||
## Auth configuration
|
||||
[auth]
|
||||
jwt_secret = "YZq8kXrW5dFpNt9mLxQvHbRjKsMnOePw"
|
||||
jwt_expires_in = "168h"
|
||||
|
||||
## Docker configuration
|
||||
[containerd]
|
||||
socket_path = "unix:///var/run/docker.sock"
|
||||
namespace = "default"
|
||||
|
||||
[mcp]
|
||||
busybox_image = "memoh-mcp:latest"
|
||||
snapshotter = "overlayfs"
|
||||
data_root = "/var/lib/memoh/data"
|
||||
data_mount = "/data"
|
||||
|
||||
## Postgres configuration
|
||||
[postgres]
|
||||
host = "postgres"
|
||||
port = 5432
|
||||
user = "memoh"
|
||||
password = "memoh123"
|
||||
database = "memoh"
|
||||
sslmode = "disable"
|
||||
|
||||
## Qdrant configuration
|
||||
[qdrant]
|
||||
base_url = "http://qdrant:6334"
|
||||
api_key = ""
|
||||
collection = "memory"
|
||||
timeout_seconds = 10
|
||||
|
||||
## Agent Gateway
|
||||
[agent_gateway]
|
||||
host = "agent"
|
||||
port = 8081
|
||||
|
||||
[brave]
|
||||
api_key = ""
|
||||
base_url = "https://api.search.brave.com/res/v1/"
|
||||
@@ -0,0 +1,64 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip 压缩
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript;
|
||||
|
||||
# 前端路由
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API 代理
|
||||
location /api/ {
|
||||
proxy_pass http://memoh-server:8080/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 超时设置
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# Agent Gateway 代理
|
||||
location /agent/ {
|
||||
proxy_pass http://memoh-agent:8081/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 超时设置
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# 静态资源缓存
|
||||
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# 安全头
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
}
|
||||
+1
-1
@@ -6,7 +6,7 @@ import vue from 'eslint-plugin-vue'
|
||||
export default [
|
||||
...tseslint.configs.recommended,
|
||||
...vue.configs['flat/recommended'],
|
||||
{ ignores: ['**/node_modules/**', '**/dist/**'] },
|
||||
{ ignores: ['**/node_modules/**', '**/dist/**', 'packages/sdk/src/**'] },
|
||||
{
|
||||
files: ['packages/**/*.{js,jsx,ts,tsx}', 'agent/**/*.{js,jsx,ts,tsx}'],
|
||||
languageOptions: {
|
||||
|
||||
@@ -7,15 +7,15 @@ import (
|
||||
|
||||
// Bot represents a bot entity.
|
||||
type Bot struct {
|
||||
ID string `json:"id"`
|
||||
OwnerUserID string `json:"owner_user_id"`
|
||||
Type string `json:"type"`
|
||||
DisplayName string `json:"display_name"`
|
||||
ID string `json:"id" validate:"required"`
|
||||
OwnerUserID string `json:"owner_user_id" validate:"required"`
|
||||
Type string `json:"type" validate:"required"`
|
||||
DisplayName string `json:"display_name" validate:"required"`
|
||||
AvatarURL string `json:"avatar_url,omitempty"`
|
||||
IsActive bool `json:"is_active"`
|
||||
IsActive bool `json:"is_active" validate:"required"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedAt time.Time `json:"created_at" validate:"required"`
|
||||
UpdatedAt time.Time `json:"updated_at" validate:"required"`
|
||||
}
|
||||
|
||||
// BotMember represents a bot membership record.
|
||||
@@ -56,7 +56,7 @@ type UpsertMemberRequest struct {
|
||||
|
||||
// ListBotsResponse wraps a list of bots.
|
||||
type ListBotsResponse struct {
|
||||
Items []Bot `json:"items"`
|
||||
Items []Bot `json:"items" validate:"required"`
|
||||
}
|
||||
|
||||
// ListMembersResponse wraps a list of bot members.
|
||||
|
||||
@@ -21,18 +21,18 @@ type AuthHandler struct {
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Username string `json:"username" validate:"required"`
|
||||
Password string `json:"password" validate:"required"`
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
UserID string `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Username string `json:"username"`
|
||||
AccessToken string `json:"access_token" validate:"required"`
|
||||
TokenType string `json:"token_type" validate:"required"`
|
||||
ExpiresAt string `json:"expires_at" validate:"required"`
|
||||
UserID string `json:"user_id" validate:"required"`
|
||||
Role string `json:"role" validate:"required"`
|
||||
DisplayName string `json:"display_name" validate:"required"`
|
||||
Username string `json:"username" validate:"required"`
|
||||
}
|
||||
|
||||
func NewAuthHandler(log *slog.Logger, accountService *accounts.Service, jwtSecret string, expiresIn time.Duration) *AuthHandler {
|
||||
|
||||
@@ -94,11 +94,11 @@ func (h *ChannelHandler) UpsertChannelIdentityConfig(c echo.Context) error {
|
||||
}
|
||||
|
||||
type ChannelMeta struct {
|
||||
Type string `json:"type"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Type string `json:"type" validate:"required"`
|
||||
DisplayName string `json:"display_name" validate:"required"`
|
||||
Configless bool `json:"configless"`
|
||||
Capabilities channel.ChannelCapabilities `json:"capabilities"`
|
||||
ConfigSchema channel.ConfigSchema `json:"config_schema"`
|
||||
Capabilities channel.ChannelCapabilities `json:"capabilities" validate:"required"`
|
||||
ConfigSchema channel.ConfigSchema `json:"config_schema" validate:"required"`
|
||||
UserConfigSchema channel.ConfigSchema `json:"user_config_schema"`
|
||||
TargetSpec channel.TargetSpec `json:"target_spec"`
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ func (h *MCPHandler) Register(e *echo.Echo) {
|
||||
// @Summary List MCP connections
|
||||
// @Description List MCP connections for a bot
|
||||
// @Tags mcp
|
||||
// @Param bot_id path string true "Bot ID"
|
||||
// @Success 200 {object} mcp.ListResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 403 {object} ErrorResponse
|
||||
@@ -75,6 +76,7 @@ func (h *MCPHandler) List(c echo.Context) error {
|
||||
// @Summary Create MCP connection
|
||||
// @Description Create a MCP connection for a bot
|
||||
// @Tags mcp
|
||||
// @Param bot_id path string true "Bot ID"
|
||||
// @Param payload body mcp.UpsertRequest true "MCP payload"
|
||||
// @Success 201 {object} mcp.Connection
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
@@ -109,6 +111,7 @@ func (h *MCPHandler) Create(c echo.Context) error {
|
||||
// @Summary Get MCP connection
|
||||
// @Description Get a MCP connection by ID
|
||||
// @Tags mcp
|
||||
// @Param bot_id path string true "Bot ID"
|
||||
// @Param id path string true "MCP ID"
|
||||
// @Success 200 {object} mcp.Connection
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
@@ -146,6 +149,7 @@ func (h *MCPHandler) Get(c echo.Context) error {
|
||||
// @Summary Update MCP connection
|
||||
// @Description Update a MCP connection by ID
|
||||
// @Tags mcp
|
||||
// @Param bot_id path string true "Bot ID"
|
||||
// @Param id path string true "MCP ID"
|
||||
// @Param payload body mcp.UpsertRequest true "MCP payload"
|
||||
// @Success 200 {object} mcp.Connection
|
||||
@@ -188,6 +192,7 @@ func (h *MCPHandler) Update(c echo.Context) error {
|
||||
// @Summary Delete MCP connection
|
||||
// @Description Delete a MCP connection by ID
|
||||
// @Tags mcp
|
||||
// @Param bot_id path string true "Bot ID"
|
||||
// @Param id path string true "MCP ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
|
||||
@@ -45,6 +45,7 @@ func (h *ScheduleHandler) Register(e *echo.Echo) {
|
||||
// @Summary Create schedule
|
||||
// @Description Create a schedule for current user
|
||||
// @Tags schedule
|
||||
// @Param bot_id path string true "Bot ID"
|
||||
// @Param payload body schedule.CreateRequest true "Schedule payload"
|
||||
// @Success 201 {object} schedule.Schedule
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
@@ -77,6 +78,7 @@ func (h *ScheduleHandler) Create(c echo.Context) error {
|
||||
// @Summary List schedules
|
||||
// @Description List schedules for current user
|
||||
// @Tags schedule
|
||||
// @Param bot_id path string true "Bot ID"
|
||||
// @Success 200 {object} schedule.ListResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
@@ -104,6 +106,7 @@ func (h *ScheduleHandler) List(c echo.Context) error {
|
||||
// @Summary Get schedule
|
||||
// @Description Get a schedule by ID
|
||||
// @Tags schedule
|
||||
// @Param bot_id path string true "Bot ID"
|
||||
// @Param id path string true "Schedule ID"
|
||||
// @Success 200 {object} schedule.Schedule
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
@@ -140,6 +143,7 @@ func (h *ScheduleHandler) Get(c echo.Context) error {
|
||||
// @Summary Update schedule
|
||||
// @Description Update a schedule by ID
|
||||
// @Tags schedule
|
||||
// @Param bot_id path string true "Bot ID"
|
||||
// @Param id path string true "Schedule ID"
|
||||
// @Param payload body schedule.UpdateRequest true "Schedule payload"
|
||||
// @Success 200 {object} schedule.Schedule
|
||||
@@ -184,6 +188,7 @@ func (h *ScheduleHandler) Update(c echo.Context) error {
|
||||
// @Summary Delete schedule
|
||||
// @Description Delete a schedule by ID
|
||||
// @Tags schedule
|
||||
// @Param bot_id path string true "Bot ID"
|
||||
// @Param id path string true "Schedule ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
|
||||
@@ -44,6 +44,7 @@ func (h *SettingsHandler) Register(e *echo.Echo) {
|
||||
// @Summary Get user settings
|
||||
// @Description Get agent settings for current user
|
||||
// @Tags settings
|
||||
// @Param bot_id path string true "Bot ID"
|
||||
// @Success 200 {object} settings.Settings
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
@@ -71,6 +72,7 @@ func (h *SettingsHandler) Get(c echo.Context) error {
|
||||
// @Summary Update user settings
|
||||
// @Description Update or create agent settings for current user
|
||||
// @Tags settings
|
||||
// @Param bot_id path string true "Bot ID"
|
||||
// @Param payload body settings.UpsertRequest true "Settings payload"
|
||||
// @Success 200 {object} settings.Settings
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
@@ -107,6 +109,7 @@ func (h *SettingsHandler) Upsert(c echo.Context) error {
|
||||
// @Summary Delete user settings
|
||||
// @Description Remove agent settings for current user
|
||||
// @Tags settings
|
||||
// @Param bot_id path string true "Bot ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
|
||||
@@ -50,6 +50,7 @@ func (h *SubagentHandler) Register(e *echo.Echo) {
|
||||
// @Summary Create subagent
|
||||
// @Description Create a subagent for current user
|
||||
// @Tags subagent
|
||||
// @Param bot_id path string true "Bot ID"
|
||||
// @Param payload body subagent.CreateRequest true "Subagent payload"
|
||||
// @Success 201 {object} subagent.Subagent
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
@@ -82,6 +83,7 @@ func (h *SubagentHandler) Create(c echo.Context) error {
|
||||
// @Summary List subagents
|
||||
// @Description List subagents for current user
|
||||
// @Tags subagent
|
||||
// @Param bot_id path string true "Bot ID"
|
||||
// @Success 200 {object} subagent.ListResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
@@ -109,6 +111,7 @@ func (h *SubagentHandler) List(c echo.Context) error {
|
||||
// @Summary Get subagent
|
||||
// @Description Get a subagent by ID
|
||||
// @Tags subagent
|
||||
// @Param bot_id path string true "Bot ID"
|
||||
// @Param id path string true "Subagent ID"
|
||||
// @Success 200 {object} subagent.Subagent
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
@@ -145,6 +148,7 @@ func (h *SubagentHandler) Get(c echo.Context) error {
|
||||
// @Summary Update subagent
|
||||
// @Description Update a subagent by ID
|
||||
// @Tags subagent
|
||||
// @Param bot_id path string true "Bot ID"
|
||||
// @Param id path string true "Subagent ID"
|
||||
// @Param payload body subagent.UpdateRequest true "Subagent payload"
|
||||
// @Success 200 {object} subagent.Subagent
|
||||
@@ -190,6 +194,7 @@ func (h *SubagentHandler) Update(c echo.Context) error {
|
||||
// @Summary Delete subagent
|
||||
// @Description Delete a subagent by ID
|
||||
// @Tags subagent
|
||||
// @Param bot_id path string true "Bot ID"
|
||||
// @Param id path string true "Subagent ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
@@ -229,6 +234,7 @@ func (h *SubagentHandler) Delete(c echo.Context) error {
|
||||
// @Summary Get subagent context
|
||||
// @Description Get a subagent's message context
|
||||
// @Tags subagent
|
||||
// @Param bot_id path string true "Bot ID"
|
||||
// @Param id path string true "Subagent ID"
|
||||
// @Success 200 {object} subagent.ContextResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
@@ -265,6 +271,7 @@ func (h *SubagentHandler) GetContext(c echo.Context) error {
|
||||
// @Summary Update subagent context
|
||||
// @Description Update a subagent's message context
|
||||
// @Tags subagent
|
||||
// @Param bot_id path string true "Bot ID"
|
||||
// @Param id path string true "Subagent ID"
|
||||
// @Param payload body subagent.UpdateContextRequest true "Context payload"
|
||||
// @Success 200 {object} subagent.ContextResponse
|
||||
@@ -310,6 +317,7 @@ func (h *SubagentHandler) UpdateContext(c echo.Context) error {
|
||||
// @Summary Get subagent skills
|
||||
// @Description Get a subagent's skills
|
||||
// @Tags subagent
|
||||
// @Param bot_id path string true "Bot ID"
|
||||
// @Param id path string true "Subagent ID"
|
||||
// @Success 200 {object} subagent.SkillsResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
@@ -346,6 +354,7 @@ func (h *SubagentHandler) GetSkills(c echo.Context) error {
|
||||
// @Summary Update subagent skills
|
||||
// @Description Replace a subagent's skills
|
||||
// @Tags subagent
|
||||
// @Param bot_id path string true "Bot ID"
|
||||
// @Param id path string true "Subagent ID"
|
||||
// @Param payload body subagent.UpdateSkillsRequest true "Skills payload"
|
||||
// @Success 200 {object} subagent.SkillsResponse
|
||||
@@ -391,6 +400,7 @@ func (h *SubagentHandler) UpdateSkills(c echo.Context) error {
|
||||
// @Summary Add subagent skills
|
||||
// @Description Add skills to a subagent
|
||||
// @Tags subagent
|
||||
// @Param bot_id path string true "Bot ID"
|
||||
// @Param id path string true "Subagent ID"
|
||||
// @Param payload body subagent.AddSkillsRequest true "Skills payload"
|
||||
// @Success 200 {object} subagent.SkillsResponse
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
//go:generate go run github.com/swaggo/swag/cmd/swag@latest init -g swagger.go -o ../../docs --parseDependency --parseInternal
|
||||
//go:generate go run github.com/swaggo/swag/cmd/swag@latest init -g swagger.go -o ../../spec --parseDependency --parseInternal
|
||||
|
||||
var (
|
||||
swaggerSpec []byte
|
||||
@@ -36,7 +36,7 @@ func (h *SwaggerHandler) Register(e *echo.Echo) {
|
||||
|
||||
func (h *SwaggerHandler) Spec(c echo.Context) error {
|
||||
swaggerOnce.Do(func() {
|
||||
swaggerSpec, swaggerErr = os.ReadFile("docs/swagger.json")
|
||||
swaggerSpec, swaggerErr = os.ReadFile("spec/swagger.json")
|
||||
})
|
||||
if swaggerErr != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, swaggerErr.Error())
|
||||
|
||||
@@ -14,14 +14,14 @@ import (
|
||||
|
||||
// Connection represents a stored MCP connection for a bot.
|
||||
type Connection struct {
|
||||
ID string `json:"id"`
|
||||
BotID string `json:"bot_id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Config map[string]any `json:"config"`
|
||||
Active bool `json:"active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID string `json:"id" validate:"required"`
|
||||
BotID string `json:"bot_id" validate:"required"`
|
||||
Name string `json:"name" validate:"required"`
|
||||
Type string `json:"type" validate:"required"`
|
||||
Config map[string]any `json:"config" validate:"required"`
|
||||
Active bool `json:"active" validate:"required"`
|
||||
CreatedAt time.Time `json:"created_at" validate:"required"`
|
||||
UpdatedAt time.Time `json:"updated_at" validate:"required"`
|
||||
}
|
||||
|
||||
// UpsertRequest is the payload for creating or updating MCP connections.
|
||||
|
||||
@@ -32,13 +32,13 @@ const (
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
ModelID string `json:"model_id"`
|
||||
Name string `json:"name"`
|
||||
LlmProviderID string `json:"llm_provider_id"`
|
||||
IsMultimodal bool `json:"is_multimodal"`
|
||||
Input []string `json:"input"`
|
||||
Type ModelType `json:"type"`
|
||||
Dimensions int `json:"dimensions"`
|
||||
ModelID string `json:"model_id" validate:"required"`
|
||||
Name string `json:"name" validate:"required"`
|
||||
LlmProviderID string `json:"llm_provider_id" validate:"required"`
|
||||
IsMultimodal bool `json:"is_multimodal"`
|
||||
Input []string `json:"input"`
|
||||
Type ModelType `json:"type" validate:"required"`
|
||||
Dimensions int `json:"dimensions"`
|
||||
}
|
||||
|
||||
func (m *Model) Validate() error {
|
||||
|
||||
@@ -33,14 +33,14 @@ type UpdateRequest struct {
|
||||
|
||||
// GetResponse represents the response for getting a provider
|
||||
type GetResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ClientType string `json:"client_type"`
|
||||
BaseURL string `json:"base_url"`
|
||||
ID string `json:"id" validate:"required"`
|
||||
Name string `json:"name" validate:"required"`
|
||||
ClientType string `json:"client_type" validate:"required"`
|
||||
BaseURL string `json:"base_url" validate:"required"`
|
||||
APIKey string `json:"api_key,omitempty"` // masked in response
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedAt time.Time `json:"created_at" validate:"required"`
|
||||
UpdatedAt time.Time `json:"updated_at" validate:"required"`
|
||||
}
|
||||
|
||||
// ListResponse represents the response for listing providers
|
||||
|
||||
@@ -6,12 +6,12 @@ const (
|
||||
)
|
||||
|
||||
type Settings struct {
|
||||
ChatModelID string `json:"chat_model_id"`
|
||||
MemoryModelID string `json:"memory_model_id"`
|
||||
EmbeddingModelID string `json:"embedding_model_id"`
|
||||
MaxContextLoadTime int `json:"max_context_load_time"`
|
||||
Language string `json:"language"`
|
||||
AllowGuest bool `json:"allow_guest"`
|
||||
ChatModelID string `json:"chat_model_id" validate:"required"`
|
||||
MemoryModelID string `json:"memory_model_id" validate:"required"`
|
||||
EmbeddingModelID string `json:"embedding_model_id" validate:"required"`
|
||||
MaxContextLoadTime int `json:"max_context_load_time" validate:"required"`
|
||||
Language string `json:"language" validate:"required"`
|
||||
AllowGuest bool `json:"allow_guest" validate:"required"`
|
||||
}
|
||||
|
||||
type UpsertRequest struct {
|
||||
|
||||
@@ -11,6 +11,8 @@ bun = "latest"
|
||||
pnpm = "10"
|
||||
# sqlc for sql management
|
||||
sqlc = "latest"
|
||||
# typos for spell check
|
||||
typos = "latest"
|
||||
# Lima for macOS
|
||||
lima = { version = "system", platform = "darwin" }
|
||||
|
||||
@@ -46,6 +48,11 @@ run = "scripts/containerd-install.sh"
|
||||
description = "Generate Swagger documentation"
|
||||
run = "cd internal/handlers && go generate"
|
||||
|
||||
[tasks.sdk-generate]
|
||||
description = "Generate SDK code"
|
||||
run = "pnpm run generate-sdk"
|
||||
depends = ["//:swagger-generate"]
|
||||
|
||||
[tasks.sqlc-generate]
|
||||
description = "Generate SQL code"
|
||||
run = "sqlc generate"
|
||||
@@ -88,6 +95,7 @@ run = "scripts/compile-mcp.sh"
|
||||
description = "Start development environment"
|
||||
depends = [
|
||||
"//:swagger-generate",
|
||||
"//:sdk-generate",
|
||||
"//agent:dev",
|
||||
"//cmd/agent:start",
|
||||
"//packages/web:dev",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from '@hey-api/openapi-ts';
|
||||
|
||||
export default defineConfig({
|
||||
input: './spec/swagger.json',
|
||||
output: 'packages/sdk/src',
|
||||
plugins: [
|
||||
'@hey-api/typescript',
|
||||
{
|
||||
name: '@hey-api/transformers',
|
||||
dates: true,
|
||||
bigInt: true,
|
||||
},
|
||||
{
|
||||
name: '@hey-api/sdk',
|
||||
transformer: true
|
||||
},
|
||||
'@hey-api/client-fetch',
|
||||
'@pinia/colada',
|
||||
],
|
||||
})
|
||||
+3
-1
@@ -12,6 +12,7 @@
|
||||
"agent:dev": "pnpm --filter @memoh/agent-gateway dev",
|
||||
"agent:build": "pnpm --filter @memoh/agent-gateway build",
|
||||
"agent:start": "pnpm --filter @memoh/agent-gateway start",
|
||||
"generate-sdk": "openapi-ts",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "vitest"
|
||||
@@ -20,6 +21,7 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.92.3",
|
||||
"@types/node": "^25.0.3",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-vue": "^10.6.2",
|
||||
@@ -38,5 +40,5 @@
|
||||
"@algolia/client-search"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@elysiajs/eden": "^1.4.6",
|
||||
"@memoh/shared": "workspace:*",
|
||||
"elysia": "latest",
|
||||
"commander": "^12.1.0",
|
||||
"chalk": "^5.4.1",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# @memoh/sdk
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@memoh/sdk",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./client": "./src/client.gen.ts",
|
||||
"./colada": "./src/@pinia/colada.gen.ts"
|
||||
},
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
},
|
||||
"packageManager": "pnpm@10.27.0",
|
||||
"peerDependencies": {
|
||||
"@pinia/colada": ">=0.21.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@pinia/colada": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,16 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { type ClientOptions, type Config, createClient, createConfig } from './client';
|
||||
import type { ClientOptions as ClientOptions2 } from './types.gen';
|
||||
|
||||
/**
|
||||
* The `createClientConfig()` function will be called on client initialization
|
||||
* and the returned object will become the client's initial configuration.
|
||||
*
|
||||
* You may want to initialize your client this way instead of calling
|
||||
* `setConfig()`. This is useful for example if you're using Next.js
|
||||
* to ensure your client always has the correct values.
|
||||
*/
|
||||
export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (override?: Config<ClientOptions & T>) => Config<Required<ClientOptions> & T>;
|
||||
|
||||
export const client = createClient(createConfig<ClientOptions2>());
|
||||
@@ -0,0 +1,288 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { createSseClient } from '../core/serverSentEvents.gen';
|
||||
import type { HttpMethod } from '../core/types.gen';
|
||||
import { getValidRequestBody } from '../core/utils.gen';
|
||||
import type { Client, Config, RequestOptions, ResolvedRequestOptions } from './types.gen';
|
||||
import {
|
||||
buildUrl,
|
||||
createConfig,
|
||||
createInterceptors,
|
||||
getParseAs,
|
||||
mergeConfigs,
|
||||
mergeHeaders,
|
||||
setAuthParams,
|
||||
} from './utils.gen';
|
||||
|
||||
type ReqInit = Omit<RequestInit, 'body' | 'headers'> & {
|
||||
body?: any;
|
||||
headers: ReturnType<typeof mergeHeaders>;
|
||||
};
|
||||
|
||||
export const createClient = (config: Config = {}): Client => {
|
||||
let _config = mergeConfigs(createConfig(), config);
|
||||
|
||||
const getConfig = (): Config => ({ ..._config });
|
||||
|
||||
const setConfig = (config: Config): Config => {
|
||||
_config = mergeConfigs(_config, config);
|
||||
return getConfig();
|
||||
};
|
||||
|
||||
const interceptors = createInterceptors<Request, Response, unknown, ResolvedRequestOptions>();
|
||||
|
||||
const beforeRequest = async (options: RequestOptions) => {
|
||||
const opts = {
|
||||
..._config,
|
||||
...options,
|
||||
fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
|
||||
headers: mergeHeaders(_config.headers, options.headers),
|
||||
serializedBody: undefined,
|
||||
};
|
||||
|
||||
if (opts.security) {
|
||||
await setAuthParams({
|
||||
...opts,
|
||||
security: opts.security,
|
||||
});
|
||||
}
|
||||
|
||||
if (opts.requestValidator) {
|
||||
await opts.requestValidator(opts);
|
||||
}
|
||||
|
||||
if (opts.body !== undefined && opts.bodySerializer) {
|
||||
opts.serializedBody = opts.bodySerializer(opts.body);
|
||||
}
|
||||
|
||||
// remove Content-Type header if body is empty to avoid sending invalid requests
|
||||
if (opts.body === undefined || opts.serializedBody === '') {
|
||||
opts.headers.delete('Content-Type');
|
||||
}
|
||||
|
||||
const url = buildUrl(opts);
|
||||
|
||||
return { opts, url };
|
||||
};
|
||||
|
||||
const request: Client['request'] = async (options) => {
|
||||
// @ts-expect-error
|
||||
const { opts, url } = await beforeRequest(options);
|
||||
const requestInit: ReqInit = {
|
||||
redirect: 'follow',
|
||||
...opts,
|
||||
body: getValidRequestBody(opts),
|
||||
};
|
||||
|
||||
let request = new Request(url, requestInit);
|
||||
|
||||
for (const fn of interceptors.request.fns) {
|
||||
if (fn) {
|
||||
request = await fn(request, opts);
|
||||
}
|
||||
}
|
||||
|
||||
// fetch must be assigned here, otherwise it would throw the error:
|
||||
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
|
||||
const _fetch = opts.fetch!;
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await _fetch(request);
|
||||
} catch (error) {
|
||||
// Handle fetch exceptions (AbortError, network errors, etc.)
|
||||
let finalError = error;
|
||||
|
||||
for (const fn of interceptors.error.fns) {
|
||||
if (fn) {
|
||||
finalError = (await fn(error, undefined as any, request, opts)) as unknown;
|
||||
}
|
||||
}
|
||||
|
||||
finalError = finalError || ({} as unknown);
|
||||
|
||||
if (opts.throwOnError) {
|
||||
throw finalError;
|
||||
}
|
||||
|
||||
// Return error response
|
||||
return opts.responseStyle === 'data'
|
||||
? undefined
|
||||
: {
|
||||
error: finalError,
|
||||
request,
|
||||
response: undefined as any,
|
||||
};
|
||||
}
|
||||
|
||||
for (const fn of interceptors.response.fns) {
|
||||
if (fn) {
|
||||
response = await fn(response, request, opts);
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
request,
|
||||
response,
|
||||
};
|
||||
|
||||
if (response.ok) {
|
||||
const parseAs =
|
||||
(opts.parseAs === 'auto'
|
||||
? getParseAs(response.headers.get('Content-Type'))
|
||||
: opts.parseAs) ?? 'json';
|
||||
|
||||
if (response.status === 204 || response.headers.get('Content-Length') === '0') {
|
||||
let emptyData: any;
|
||||
switch (parseAs) {
|
||||
case 'arrayBuffer':
|
||||
case 'blob':
|
||||
case 'text':
|
||||
emptyData = await response[parseAs]();
|
||||
break;
|
||||
case 'formData':
|
||||
emptyData = new FormData();
|
||||
break;
|
||||
case 'stream':
|
||||
emptyData = response.body;
|
||||
break;
|
||||
case 'json':
|
||||
default:
|
||||
emptyData = {};
|
||||
break;
|
||||
}
|
||||
return opts.responseStyle === 'data'
|
||||
? emptyData
|
||||
: {
|
||||
data: emptyData,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
let data: any;
|
||||
switch (parseAs) {
|
||||
case 'arrayBuffer':
|
||||
case 'blob':
|
||||
case 'formData':
|
||||
case 'text':
|
||||
data = await response[parseAs]();
|
||||
break;
|
||||
case 'json': {
|
||||
// Some servers return 200 with no Content-Length and empty body.
|
||||
// response.json() would throw; read as text and parse if non-empty.
|
||||
const text = await response.text();
|
||||
data = text ? JSON.parse(text) : {};
|
||||
break;
|
||||
}
|
||||
case 'stream':
|
||||
return opts.responseStyle === 'data'
|
||||
? response.body
|
||||
: {
|
||||
data: response.body,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
if (parseAs === 'json') {
|
||||
if (opts.responseValidator) {
|
||||
await opts.responseValidator(data);
|
||||
}
|
||||
|
||||
if (opts.responseTransformer) {
|
||||
data = await opts.responseTransformer(data);
|
||||
}
|
||||
}
|
||||
|
||||
return opts.responseStyle === 'data'
|
||||
? data
|
||||
: {
|
||||
data,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
const textError = await response.text();
|
||||
let jsonError: unknown;
|
||||
|
||||
try {
|
||||
jsonError = JSON.parse(textError);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
|
||||
const error = jsonError ?? textError;
|
||||
let finalError = error;
|
||||
|
||||
for (const fn of interceptors.error.fns) {
|
||||
if (fn) {
|
||||
finalError = (await fn(error, response, request, opts)) as string;
|
||||
}
|
||||
}
|
||||
|
||||
finalError = finalError || ({} as string);
|
||||
|
||||
if (opts.throwOnError) {
|
||||
throw finalError;
|
||||
}
|
||||
|
||||
// TODO: we probably want to return error and improve types
|
||||
return opts.responseStyle === 'data'
|
||||
? undefined
|
||||
: {
|
||||
error: finalError,
|
||||
...result,
|
||||
};
|
||||
};
|
||||
|
||||
const makeMethodFn = (method: Uppercase<HttpMethod>) => (options: RequestOptions) =>
|
||||
request({ ...options, method });
|
||||
|
||||
const makeSseFn = (method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
|
||||
const { opts, url } = await beforeRequest(options);
|
||||
return createSseClient({
|
||||
...opts,
|
||||
body: opts.body as BodyInit | null | undefined,
|
||||
headers: opts.headers as unknown as Record<string, string>,
|
||||
method,
|
||||
onRequest: async (url, init) => {
|
||||
let request = new Request(url, init);
|
||||
for (const fn of interceptors.request.fns) {
|
||||
if (fn) {
|
||||
request = await fn(request, opts);
|
||||
}
|
||||
}
|
||||
return request;
|
||||
},
|
||||
serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined,
|
||||
url,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
buildUrl,
|
||||
connect: makeMethodFn('CONNECT'),
|
||||
delete: makeMethodFn('DELETE'),
|
||||
get: makeMethodFn('GET'),
|
||||
getConfig,
|
||||
head: makeMethodFn('HEAD'),
|
||||
interceptors,
|
||||
options: makeMethodFn('OPTIONS'),
|
||||
patch: makeMethodFn('PATCH'),
|
||||
post: makeMethodFn('POST'),
|
||||
put: makeMethodFn('PUT'),
|
||||
request,
|
||||
setConfig,
|
||||
sse: {
|
||||
connect: makeSseFn('CONNECT'),
|
||||
delete: makeSseFn('DELETE'),
|
||||
get: makeSseFn('GET'),
|
||||
head: makeSseFn('HEAD'),
|
||||
options: makeSseFn('OPTIONS'),
|
||||
patch: makeSseFn('PATCH'),
|
||||
post: makeSseFn('POST'),
|
||||
put: makeSseFn('PUT'),
|
||||
trace: makeSseFn('TRACE'),
|
||||
},
|
||||
trace: makeMethodFn('TRACE'),
|
||||
} as Client;
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
export type { Auth } from '../core/auth.gen';
|
||||
export type { QuerySerializerOptions } from '../core/bodySerializer.gen';
|
||||
export {
|
||||
formDataBodySerializer,
|
||||
jsonBodySerializer,
|
||||
urlSearchParamsBodySerializer,
|
||||
} from '../core/bodySerializer.gen';
|
||||
export { buildClientParams } from '../core/params.gen';
|
||||
export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen';
|
||||
export { createClient } from './client.gen';
|
||||
export type {
|
||||
Client,
|
||||
ClientOptions,
|
||||
Config,
|
||||
CreateClientConfig,
|
||||
Options,
|
||||
RequestOptions,
|
||||
RequestResult,
|
||||
ResolvedRequestOptions,
|
||||
ResponseStyle,
|
||||
TDataShape,
|
||||
} from './types.gen';
|
||||
export { createConfig, mergeHeaders } from './utils.gen';
|
||||
@@ -0,0 +1,213 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { Auth } from '../core/auth.gen';
|
||||
import type {
|
||||
ServerSentEventsOptions,
|
||||
ServerSentEventsResult,
|
||||
} from '../core/serverSentEvents.gen';
|
||||
import type { Client as CoreClient, Config as CoreConfig } from '../core/types.gen';
|
||||
import type { Middleware } from './utils.gen';
|
||||
|
||||
export type ResponseStyle = 'data' | 'fields';
|
||||
|
||||
export interface Config<T extends ClientOptions = ClientOptions>
|
||||
extends Omit<RequestInit, 'body' | 'headers' | 'method'>, CoreConfig {
|
||||
/**
|
||||
* Base URL for all requests made by this client.
|
||||
*/
|
||||
baseUrl?: T['baseUrl'];
|
||||
/**
|
||||
* Fetch API implementation. You can use this option to provide a custom
|
||||
* fetch instance.
|
||||
*
|
||||
* @default globalThis.fetch
|
||||
*/
|
||||
fetch?: typeof fetch;
|
||||
/**
|
||||
* Please don't use the Fetch client for Next.js applications. The `next`
|
||||
* options won't have any effect.
|
||||
*
|
||||
* Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead.
|
||||
*/
|
||||
next?: never;
|
||||
/**
|
||||
* Return the response data parsed in a specified format. By default, `auto`
|
||||
* will infer the appropriate method from the `Content-Type` response header.
|
||||
* You can override this behavior with any of the {@link Body} methods.
|
||||
* Select `stream` if you don't want to parse response data at all.
|
||||
*
|
||||
* @default 'auto'
|
||||
*/
|
||||
parseAs?: 'arrayBuffer' | 'auto' | 'blob' | 'formData' | 'json' | 'stream' | 'text';
|
||||
/**
|
||||
* Should we return only data or multiple fields (data, error, response, etc.)?
|
||||
*
|
||||
* @default 'fields'
|
||||
*/
|
||||
responseStyle?: ResponseStyle;
|
||||
/**
|
||||
* Throw an error instead of returning it in the response?
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
throwOnError?: T['throwOnError'];
|
||||
}
|
||||
|
||||
export interface RequestOptions<
|
||||
TData = unknown,
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
ThrowOnError extends boolean = boolean,
|
||||
Url extends string = string,
|
||||
>
|
||||
extends
|
||||
Config<{
|
||||
responseStyle: TResponseStyle;
|
||||
throwOnError: ThrowOnError;
|
||||
}>,
|
||||
Pick<
|
||||
ServerSentEventsOptions<TData>,
|
||||
| 'onSseError'
|
||||
| 'onSseEvent'
|
||||
| 'sseDefaultRetryDelay'
|
||||
| 'sseMaxRetryAttempts'
|
||||
| 'sseMaxRetryDelay'
|
||||
> {
|
||||
/**
|
||||
* Any body that you want to add to your request.
|
||||
*
|
||||
* {@link https://developer.mozilla.org/docs/Web/API/fetch#body}
|
||||
*/
|
||||
body?: unknown;
|
||||
path?: Record<string, unknown>;
|
||||
query?: Record<string, unknown>;
|
||||
/**
|
||||
* Security mechanism(s) to use for the request.
|
||||
*/
|
||||
security?: ReadonlyArray<Auth>;
|
||||
url: Url;
|
||||
}
|
||||
|
||||
export interface ResolvedRequestOptions<
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
ThrowOnError extends boolean = boolean,
|
||||
Url extends string = string,
|
||||
> extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> {
|
||||
serializedBody?: string;
|
||||
}
|
||||
|
||||
export type RequestResult<
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
ThrowOnError extends boolean = boolean,
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
> = ThrowOnError extends true
|
||||
? Promise<
|
||||
TResponseStyle extends 'data'
|
||||
? TData extends Record<string, unknown>
|
||||
? TData[keyof TData]
|
||||
: TData
|
||||
: {
|
||||
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData;
|
||||
request: Request;
|
||||
response: Response;
|
||||
}
|
||||
>
|
||||
: Promise<
|
||||
TResponseStyle extends 'data'
|
||||
? (TData extends Record<string, unknown> ? TData[keyof TData] : TData) | undefined
|
||||
: (
|
||||
| {
|
||||
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData;
|
||||
error: undefined;
|
||||
}
|
||||
| {
|
||||
data: undefined;
|
||||
error: TError extends Record<string, unknown> ? TError[keyof TError] : TError;
|
||||
}
|
||||
) & {
|
||||
request: Request;
|
||||
response: Response;
|
||||
}
|
||||
>;
|
||||
|
||||
export interface ClientOptions {
|
||||
baseUrl?: string;
|
||||
responseStyle?: ResponseStyle;
|
||||
throwOnError?: boolean;
|
||||
}
|
||||
|
||||
type MethodFn = <
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
ThrowOnError extends boolean = false,
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
>(
|
||||
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>,
|
||||
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
|
||||
|
||||
type SseFn = <
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
ThrowOnError extends boolean = false,
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
>(
|
||||
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>,
|
||||
) => Promise<ServerSentEventsResult<TData, TError>>;
|
||||
|
||||
type RequestFn = <
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
ThrowOnError extends boolean = false,
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
>(
|
||||
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'> &
|
||||
Pick<Required<RequestOptions<TData, TResponseStyle, ThrowOnError>>, 'method'>,
|
||||
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
|
||||
|
||||
type BuildUrlFn = <
|
||||
TData extends {
|
||||
body?: unknown;
|
||||
path?: Record<string, unknown>;
|
||||
query?: Record<string, unknown>;
|
||||
url: string;
|
||||
},
|
||||
>(
|
||||
options: TData & Options<TData>,
|
||||
) => string;
|
||||
|
||||
export type Client = CoreClient<RequestFn, Config, MethodFn, BuildUrlFn, SseFn> & {
|
||||
interceptors: Middleware<Request, Response, unknown, ResolvedRequestOptions>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The `createClientConfig()` function will be called on client initialization
|
||||
* and the returned object will become the client's initial configuration.
|
||||
*
|
||||
* You may want to initialize your client this way instead of calling
|
||||
* `setConfig()`. This is useful for example if you're using Next.js
|
||||
* to ensure your client always has the correct values.
|
||||
*/
|
||||
export type CreateClientConfig<T extends ClientOptions = ClientOptions> = (
|
||||
override?: Config<ClientOptions & T>,
|
||||
) => Config<Required<ClientOptions> & T>;
|
||||
|
||||
export interface TDataShape {
|
||||
body?: unknown;
|
||||
headers?: unknown;
|
||||
path?: unknown;
|
||||
query?: unknown;
|
||||
url: string;
|
||||
}
|
||||
|
||||
type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>;
|
||||
|
||||
export type Options<
|
||||
TData extends TDataShape = TDataShape,
|
||||
ThrowOnError extends boolean = boolean,
|
||||
TResponse = unknown,
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
> = OmitKeys<
|
||||
RequestOptions<TResponse, TResponseStyle, ThrowOnError>,
|
||||
'body' | 'path' | 'query' | 'url'
|
||||
> &
|
||||
([TData] extends [never] ? unknown : Omit<TData, 'url'>);
|
||||
@@ -0,0 +1,316 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { getAuthToken } from '../core/auth.gen';
|
||||
import type { QuerySerializerOptions } from '../core/bodySerializer.gen';
|
||||
import { jsonBodySerializer } from '../core/bodySerializer.gen';
|
||||
import {
|
||||
serializeArrayParam,
|
||||
serializeObjectParam,
|
||||
serializePrimitiveParam,
|
||||
} from '../core/pathSerializer.gen';
|
||||
import { getUrl } from '../core/utils.gen';
|
||||
import type { Client, ClientOptions, Config, RequestOptions } from './types.gen';
|
||||
|
||||
export const createQuerySerializer = <T = unknown>({
|
||||
parameters = {},
|
||||
...args
|
||||
}: QuerySerializerOptions = {}) => {
|
||||
const querySerializer = (queryParams: T) => {
|
||||
const search: string[] = [];
|
||||
if (queryParams && typeof queryParams === 'object') {
|
||||
for (const name in queryParams) {
|
||||
const value = queryParams[name];
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const options = parameters[name] || args;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const serializedArray = serializeArrayParam({
|
||||
allowReserved: options.allowReserved,
|
||||
explode: true,
|
||||
name,
|
||||
style: 'form',
|
||||
value,
|
||||
...options.array,
|
||||
});
|
||||
if (serializedArray) search.push(serializedArray);
|
||||
} else if (typeof value === 'object') {
|
||||
const serializedObject = serializeObjectParam({
|
||||
allowReserved: options.allowReserved,
|
||||
explode: true,
|
||||
name,
|
||||
style: 'deepObject',
|
||||
value: value as Record<string, unknown>,
|
||||
...options.object,
|
||||
});
|
||||
if (serializedObject) search.push(serializedObject);
|
||||
} else {
|
||||
const serializedPrimitive = serializePrimitiveParam({
|
||||
allowReserved: options.allowReserved,
|
||||
name,
|
||||
value: value as string,
|
||||
});
|
||||
if (serializedPrimitive) search.push(serializedPrimitive);
|
||||
}
|
||||
}
|
||||
}
|
||||
return search.join('&');
|
||||
};
|
||||
return querySerializer;
|
||||
};
|
||||
|
||||
/**
|
||||
* Infers parseAs value from provided Content-Type header.
|
||||
*/
|
||||
export const getParseAs = (contentType: string | null): Exclude<Config['parseAs'], 'auto'> => {
|
||||
if (!contentType) {
|
||||
// If no Content-Type header is provided, the best we can do is return the raw response body,
|
||||
// which is effectively the same as the 'stream' option.
|
||||
return 'stream';
|
||||
}
|
||||
|
||||
const cleanContent = contentType.split(';')[0]?.trim();
|
||||
|
||||
if (!cleanContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cleanContent.startsWith('application/json') || cleanContent.endsWith('+json')) {
|
||||
return 'json';
|
||||
}
|
||||
|
||||
if (cleanContent === 'multipart/form-data') {
|
||||
return 'formData';
|
||||
}
|
||||
|
||||
if (
|
||||
['application/', 'audio/', 'image/', 'video/'].some((type) => cleanContent.startsWith(type))
|
||||
) {
|
||||
return 'blob';
|
||||
}
|
||||
|
||||
if (cleanContent.startsWith('text/')) {
|
||||
return 'text';
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
const checkForExistence = (
|
||||
options: Pick<RequestOptions, 'auth' | 'query'> & {
|
||||
headers: Headers;
|
||||
},
|
||||
name?: string,
|
||||
): boolean => {
|
||||
if (!name) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
options.headers.has(name) ||
|
||||
options.query?.[name] ||
|
||||
options.headers.get('Cookie')?.includes(`${name}=`)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const setAuthParams = async ({
|
||||
security,
|
||||
...options
|
||||
}: Pick<Required<RequestOptions>, 'security'> &
|
||||
Pick<RequestOptions, 'auth' | 'query'> & {
|
||||
headers: Headers;
|
||||
}) => {
|
||||
for (const auth of security) {
|
||||
if (checkForExistence(options, auth.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const token = await getAuthToken(auth, options.auth);
|
||||
|
||||
if (!token) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = auth.name ?? 'Authorization';
|
||||
|
||||
switch (auth.in) {
|
||||
case 'query':
|
||||
if (!options.query) {
|
||||
options.query = {};
|
||||
}
|
||||
options.query[name] = token;
|
||||
break;
|
||||
case 'cookie':
|
||||
options.headers.append('Cookie', `${name}=${token}`);
|
||||
break;
|
||||
case 'header':
|
||||
default:
|
||||
options.headers.set(name, token);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const buildUrl: Client['buildUrl'] = (options) =>
|
||||
getUrl({
|
||||
baseUrl: options.baseUrl as string,
|
||||
path: options.path,
|
||||
query: options.query,
|
||||
querySerializer:
|
||||
typeof options.querySerializer === 'function'
|
||||
? options.querySerializer
|
||||
: createQuerySerializer(options.querySerializer),
|
||||
url: options.url,
|
||||
});
|
||||
|
||||
export const mergeConfigs = (a: Config, b: Config): Config => {
|
||||
const config = { ...a, ...b };
|
||||
if (config.baseUrl?.endsWith('/')) {
|
||||
config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1);
|
||||
}
|
||||
config.headers = mergeHeaders(a.headers, b.headers);
|
||||
return config;
|
||||
};
|
||||
|
||||
const headersEntries = (headers: Headers): Array<[string, string]> => {
|
||||
const entries: Array<[string, string]> = [];
|
||||
headers.forEach((value, key) => {
|
||||
entries.push([key, value]);
|
||||
});
|
||||
return entries;
|
||||
};
|
||||
|
||||
export const mergeHeaders = (
|
||||
...headers: Array<Required<Config>['headers'] | undefined>
|
||||
): Headers => {
|
||||
const mergedHeaders = new Headers();
|
||||
for (const header of headers) {
|
||||
if (!header) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header);
|
||||
|
||||
for (const [key, value] of iterator) {
|
||||
if (value === null) {
|
||||
mergedHeaders.delete(key);
|
||||
} else if (Array.isArray(value)) {
|
||||
for (const v of value) {
|
||||
mergedHeaders.append(key, v as string);
|
||||
}
|
||||
} else if (value !== undefined) {
|
||||
// assume object headers are meant to be JSON stringified, i.e. their
|
||||
// content value in OpenAPI specification is 'application/json'
|
||||
mergedHeaders.set(
|
||||
key,
|
||||
typeof value === 'object' ? JSON.stringify(value) : (value as string),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return mergedHeaders;
|
||||
};
|
||||
|
||||
type ErrInterceptor<Err, Res, Req, Options> = (
|
||||
error: Err,
|
||||
response: Res,
|
||||
request: Req,
|
||||
options: Options,
|
||||
) => Err | Promise<Err>;
|
||||
|
||||
type ReqInterceptor<Req, Options> = (request: Req, options: Options) => Req | Promise<Req>;
|
||||
|
||||
type ResInterceptor<Res, Req, Options> = (
|
||||
response: Res,
|
||||
request: Req,
|
||||
options: Options,
|
||||
) => Res | Promise<Res>;
|
||||
|
||||
class Interceptors<Interceptor> {
|
||||
fns: Array<Interceptor | null> = [];
|
||||
|
||||
clear(): void {
|
||||
this.fns = [];
|
||||
}
|
||||
|
||||
eject(id: number | Interceptor): void {
|
||||
const index = this.getInterceptorIndex(id);
|
||||
if (this.fns[index]) {
|
||||
this.fns[index] = null;
|
||||
}
|
||||
}
|
||||
|
||||
exists(id: number | Interceptor): boolean {
|
||||
const index = this.getInterceptorIndex(id);
|
||||
return Boolean(this.fns[index]);
|
||||
}
|
||||
|
||||
getInterceptorIndex(id: number | Interceptor): number {
|
||||
if (typeof id === 'number') {
|
||||
return this.fns[id] ? id : -1;
|
||||
}
|
||||
return this.fns.indexOf(id);
|
||||
}
|
||||
|
||||
update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false {
|
||||
const index = this.getInterceptorIndex(id);
|
||||
if (this.fns[index]) {
|
||||
this.fns[index] = fn;
|
||||
return id;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
use(fn: Interceptor): number {
|
||||
this.fns.push(fn);
|
||||
return this.fns.length - 1;
|
||||
}
|
||||
}
|
||||
|
||||
export interface Middleware<Req, Res, Err, Options> {
|
||||
error: Interceptors<ErrInterceptor<Err, Res, Req, Options>>;
|
||||
request: Interceptors<ReqInterceptor<Req, Options>>;
|
||||
response: Interceptors<ResInterceptor<Res, Req, Options>>;
|
||||
}
|
||||
|
||||
export const createInterceptors = <Req, Res, Err, Options>(): Middleware<
|
||||
Req,
|
||||
Res,
|
||||
Err,
|
||||
Options
|
||||
> => ({
|
||||
error: new Interceptors<ErrInterceptor<Err, Res, Req, Options>>(),
|
||||
request: new Interceptors<ReqInterceptor<Req, Options>>(),
|
||||
response: new Interceptors<ResInterceptor<Res, Req, Options>>(),
|
||||
});
|
||||
|
||||
const defaultQuerySerializer = createQuerySerializer({
|
||||
allowReserved: false,
|
||||
array: {
|
||||
explode: true,
|
||||
style: 'form',
|
||||
},
|
||||
object: {
|
||||
explode: true,
|
||||
style: 'deepObject',
|
||||
},
|
||||
});
|
||||
|
||||
const defaultHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
export const createConfig = <T extends ClientOptions = ClientOptions>(
|
||||
override: Config<Omit<ClientOptions, keyof T> & T> = {},
|
||||
): Config<Omit<ClientOptions, keyof T> & T> => ({
|
||||
...jsonBodySerializer,
|
||||
headers: defaultHeaders,
|
||||
parseAs: 'auto',
|
||||
querySerializer: defaultQuerySerializer,
|
||||
...override,
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
export type AuthToken = string | undefined;
|
||||
|
||||
export interface Auth {
|
||||
/**
|
||||
* Which part of the request do we use to send the auth?
|
||||
*
|
||||
* @default 'header'
|
||||
*/
|
||||
in?: 'header' | 'query' | 'cookie';
|
||||
/**
|
||||
* Header or query parameter name.
|
||||
*
|
||||
* @default 'Authorization'
|
||||
*/
|
||||
name?: string;
|
||||
scheme?: 'basic' | 'bearer';
|
||||
type: 'apiKey' | 'http';
|
||||
}
|
||||
|
||||
export const getAuthToken = async (
|
||||
auth: Auth,
|
||||
callback: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken,
|
||||
): Promise<string | undefined> => {
|
||||
const token = typeof callback === 'function' ? await callback(auth) : callback;
|
||||
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (auth.scheme === 'bearer') {
|
||||
return `Bearer ${token}`;
|
||||
}
|
||||
|
||||
if (auth.scheme === 'basic') {
|
||||
return `Basic ${btoa(token)}`;
|
||||
}
|
||||
|
||||
return token;
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { ArrayStyle, ObjectStyle, SerializerOptions } from './pathSerializer.gen';
|
||||
|
||||
export type QuerySerializer = (query: Record<string, unknown>) => string;
|
||||
|
||||
export type BodySerializer = (body: any) => any;
|
||||
|
||||
type QuerySerializerOptionsObject = {
|
||||
allowReserved?: boolean;
|
||||
array?: Partial<SerializerOptions<ArrayStyle>>;
|
||||
object?: Partial<SerializerOptions<ObjectStyle>>;
|
||||
};
|
||||
|
||||
export type QuerySerializerOptions = QuerySerializerOptionsObject & {
|
||||
/**
|
||||
* Per-parameter serialization overrides. When provided, these settings
|
||||
* override the global array/object settings for specific parameter names.
|
||||
*/
|
||||
parameters?: Record<string, QuerySerializerOptionsObject>;
|
||||
};
|
||||
|
||||
const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => {
|
||||
if (typeof value === 'string' || value instanceof Blob) {
|
||||
data.append(key, value);
|
||||
} else if (value instanceof Date) {
|
||||
data.append(key, value.toISOString());
|
||||
} else {
|
||||
data.append(key, JSON.stringify(value));
|
||||
}
|
||||
};
|
||||
|
||||
const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: unknown): void => {
|
||||
if (typeof value === 'string') {
|
||||
data.append(key, value);
|
||||
} else {
|
||||
data.append(key, JSON.stringify(value));
|
||||
}
|
||||
};
|
||||
|
||||
export const formDataBodySerializer = {
|
||||
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(
|
||||
body: T,
|
||||
): FormData => {
|
||||
const data = new FormData();
|
||||
|
||||
Object.entries(body).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null) {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v) => serializeFormDataPair(data, key, v));
|
||||
} else {
|
||||
serializeFormDataPair(data, key, value);
|
||||
}
|
||||
});
|
||||
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
export const jsonBodySerializer = {
|
||||
bodySerializer: <T>(body: T): string =>
|
||||
JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)),
|
||||
};
|
||||
|
||||
export const urlSearchParamsBodySerializer = {
|
||||
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(body: T): string => {
|
||||
const data = new URLSearchParams();
|
||||
|
||||
Object.entries(body).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null) {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v) => serializeUrlSearchParamsPair(data, key, v));
|
||||
} else {
|
||||
serializeUrlSearchParamsPair(data, key, value);
|
||||
}
|
||||
});
|
||||
|
||||
return data.toString();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,169 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
type Slot = 'body' | 'headers' | 'path' | 'query';
|
||||
|
||||
export type Field =
|
||||
| {
|
||||
in: Exclude<Slot, 'body'>;
|
||||
/**
|
||||
* Field name. This is the name we want the user to see and use.
|
||||
*/
|
||||
key: string;
|
||||
/**
|
||||
* Field mapped name. This is the name we want to use in the request.
|
||||
* If omitted, we use the same value as `key`.
|
||||
*/
|
||||
map?: string;
|
||||
}
|
||||
| {
|
||||
in: Extract<Slot, 'body'>;
|
||||
/**
|
||||
* Key isn't required for bodies.
|
||||
*/
|
||||
key?: string;
|
||||
map?: string;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Field name. This is the name we want the user to see and use.
|
||||
*/
|
||||
key: string;
|
||||
/**
|
||||
* Field mapped name. This is the name we want to use in the request.
|
||||
* If `in` is omitted, `map` aliases `key` to the transport layer.
|
||||
*/
|
||||
map: Slot;
|
||||
};
|
||||
|
||||
export interface Fields {
|
||||
allowExtra?: Partial<Record<Slot, boolean>>;
|
||||
args?: ReadonlyArray<Field>;
|
||||
}
|
||||
|
||||
export type FieldsConfig = ReadonlyArray<Field | Fields>;
|
||||
|
||||
const extraPrefixesMap: Record<string, Slot> = {
|
||||
$body_: 'body',
|
||||
$headers_: 'headers',
|
||||
$path_: 'path',
|
||||
$query_: 'query',
|
||||
};
|
||||
const extraPrefixes = Object.entries(extraPrefixesMap);
|
||||
|
||||
type KeyMap = Map<
|
||||
string,
|
||||
| {
|
||||
in: Slot;
|
||||
map?: string;
|
||||
}
|
||||
| {
|
||||
in?: never;
|
||||
map: Slot;
|
||||
}
|
||||
>;
|
||||
|
||||
const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
|
||||
if (!map) {
|
||||
map = new Map();
|
||||
}
|
||||
|
||||
for (const config of fields) {
|
||||
if ('in' in config) {
|
||||
if (config.key) {
|
||||
map.set(config.key, {
|
||||
in: config.in,
|
||||
map: config.map,
|
||||
});
|
||||
}
|
||||
} else if ('key' in config) {
|
||||
map.set(config.key, {
|
||||
map: config.map,
|
||||
});
|
||||
} else if (config.args) {
|
||||
buildKeyMap(config.args, map);
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
};
|
||||
|
||||
interface Params {
|
||||
body: unknown;
|
||||
headers: Record<string, unknown>;
|
||||
path: Record<string, unknown>;
|
||||
query: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const stripEmptySlots = (params: Params) => {
|
||||
for (const [slot, value] of Object.entries(params)) {
|
||||
if (value && typeof value === 'object' && !Object.keys(value).length) {
|
||||
delete params[slot as Slot];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const buildClientParams = (args: ReadonlyArray<unknown>, fields: FieldsConfig) => {
|
||||
const params: Params = {
|
||||
body: {},
|
||||
headers: {},
|
||||
path: {},
|
||||
query: {},
|
||||
};
|
||||
|
||||
const map = buildKeyMap(fields);
|
||||
|
||||
let config: FieldsConfig[number] | undefined;
|
||||
|
||||
for (const [index, arg] of args.entries()) {
|
||||
if (fields[index]) {
|
||||
config = fields[index];
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ('in' in config) {
|
||||
if (config.key) {
|
||||
const field = map.get(config.key)!;
|
||||
const name = field.map || config.key;
|
||||
if (field.in) {
|
||||
(params[field.in] as Record<string, unknown>)[name] = arg;
|
||||
}
|
||||
} else {
|
||||
params.body = arg;
|
||||
}
|
||||
} else {
|
||||
for (const [key, value] of Object.entries(arg ?? {})) {
|
||||
const field = map.get(key);
|
||||
|
||||
if (field) {
|
||||
if (field.in) {
|
||||
const name = field.map || key;
|
||||
(params[field.in] as Record<string, unknown>)[name] = value;
|
||||
} else {
|
||||
params[field.map] = value;
|
||||
}
|
||||
} else {
|
||||
const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix));
|
||||
|
||||
if (extra) {
|
||||
const [prefix, slot] = extra;
|
||||
(params[slot] as Record<string, unknown>)[key.slice(prefix.length)] = value;
|
||||
} else if ('allowExtra' in config && config.allowExtra) {
|
||||
for (const [slot, allowed] of Object.entries(config.allowExtra)) {
|
||||
if (allowed) {
|
||||
(params[slot as Slot] as Record<string, unknown>)[key] = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stripEmptySlots(params);
|
||||
|
||||
return params;
|
||||
};
|
||||
@@ -0,0 +1,171 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
interface SerializeOptions<T> extends SerializePrimitiveOptions, SerializerOptions<T> {}
|
||||
|
||||
interface SerializePrimitiveOptions {
|
||||
allowReserved?: boolean;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SerializerOptions<T> {
|
||||
/**
|
||||
* @default true
|
||||
*/
|
||||
explode: boolean;
|
||||
style: T;
|
||||
}
|
||||
|
||||
export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited';
|
||||
export type ArraySeparatorStyle = ArrayStyle | MatrixStyle;
|
||||
type MatrixStyle = 'label' | 'matrix' | 'simple';
|
||||
export type ObjectStyle = 'form' | 'deepObject';
|
||||
type ObjectSeparatorStyle = ObjectStyle | MatrixStyle;
|
||||
|
||||
interface SerializePrimitiveParam extends SerializePrimitiveOptions {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const separatorArrayExplode = (style: ArraySeparatorStyle) => {
|
||||
switch (style) {
|
||||
case 'label':
|
||||
return '.';
|
||||
case 'matrix':
|
||||
return ';';
|
||||
case 'simple':
|
||||
return ',';
|
||||
default:
|
||||
return '&';
|
||||
}
|
||||
};
|
||||
|
||||
export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => {
|
||||
switch (style) {
|
||||
case 'form':
|
||||
return ',';
|
||||
case 'pipeDelimited':
|
||||
return '|';
|
||||
case 'spaceDelimited':
|
||||
return '%20';
|
||||
default:
|
||||
return ',';
|
||||
}
|
||||
};
|
||||
|
||||
export const separatorObjectExplode = (style: ObjectSeparatorStyle) => {
|
||||
switch (style) {
|
||||
case 'label':
|
||||
return '.';
|
||||
case 'matrix':
|
||||
return ';';
|
||||
case 'simple':
|
||||
return ',';
|
||||
default:
|
||||
return '&';
|
||||
}
|
||||
};
|
||||
|
||||
export const serializeArrayParam = ({
|
||||
allowReserved,
|
||||
explode,
|
||||
name,
|
||||
style,
|
||||
value,
|
||||
}: SerializeOptions<ArraySeparatorStyle> & {
|
||||
value: unknown[];
|
||||
}) => {
|
||||
if (!explode) {
|
||||
const joinedValues = (
|
||||
allowReserved ? value : value.map((v) => encodeURIComponent(v as string))
|
||||
).join(separatorArrayNoExplode(style));
|
||||
switch (style) {
|
||||
case 'label':
|
||||
return `.${joinedValues}`;
|
||||
case 'matrix':
|
||||
return `;${name}=${joinedValues}`;
|
||||
case 'simple':
|
||||
return joinedValues;
|
||||
default:
|
||||
return `${name}=${joinedValues}`;
|
||||
}
|
||||
}
|
||||
|
||||
const separator = separatorArrayExplode(style);
|
||||
const joinedValues = value
|
||||
.map((v) => {
|
||||
if (style === 'label' || style === 'simple') {
|
||||
return allowReserved ? v : encodeURIComponent(v as string);
|
||||
}
|
||||
|
||||
return serializePrimitiveParam({
|
||||
allowReserved,
|
||||
name,
|
||||
value: v as string,
|
||||
});
|
||||
})
|
||||
.join(separator);
|
||||
return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues;
|
||||
};
|
||||
|
||||
export const serializePrimitiveParam = ({
|
||||
allowReserved,
|
||||
name,
|
||||
value,
|
||||
}: SerializePrimitiveParam) => {
|
||||
if (value === undefined || value === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
throw new Error(
|
||||
'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.',
|
||||
);
|
||||
}
|
||||
|
||||
return `${name}=${allowReserved ? value : encodeURIComponent(value)}`;
|
||||
};
|
||||
|
||||
export const serializeObjectParam = ({
|
||||
allowReserved,
|
||||
explode,
|
||||
name,
|
||||
style,
|
||||
value,
|
||||
valueOnly,
|
||||
}: SerializeOptions<ObjectSeparatorStyle> & {
|
||||
value: Record<string, unknown> | Date;
|
||||
valueOnly?: boolean;
|
||||
}) => {
|
||||
if (value instanceof Date) {
|
||||
return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`;
|
||||
}
|
||||
|
||||
if (style !== 'deepObject' && !explode) {
|
||||
let values: string[] = [];
|
||||
Object.entries(value).forEach(([key, v]) => {
|
||||
values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)];
|
||||
});
|
||||
const joinedValues = values.join(',');
|
||||
switch (style) {
|
||||
case 'form':
|
||||
return `${name}=${joinedValues}`;
|
||||
case 'label':
|
||||
return `.${joinedValues}`;
|
||||
case 'matrix':
|
||||
return `;${name}=${joinedValues}`;
|
||||
default:
|
||||
return joinedValues;
|
||||
}
|
||||
}
|
||||
|
||||
const separator = separatorObjectExplode(style);
|
||||
const joinedValues = Object.entries(value)
|
||||
.map(([key, v]) =>
|
||||
serializePrimitiveParam({
|
||||
allowReserved,
|
||||
name: style === 'deepObject' ? `${name}[${key}]` : key,
|
||||
value: v as string,
|
||||
}),
|
||||
)
|
||||
.join(separator);
|
||||
return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues;
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
/**
|
||||
* JSON-friendly union that mirrors what Pinia Colada can hash.
|
||||
*/
|
||||
export type JsonValue =
|
||||
| null
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| JsonValue[]
|
||||
| { [key: string]: JsonValue };
|
||||
|
||||
/**
|
||||
* Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes.
|
||||
*/
|
||||
export const queryKeyJsonReplacer = (_key: string, value: unknown) => {
|
||||
if (value === undefined || typeof value === 'function' || typeof value === 'symbol') {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value === 'bigint') {
|
||||
return value.toString();
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Safely stringifies a value and parses it back into a JsonValue.
|
||||
*/
|
||||
export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => {
|
||||
try {
|
||||
const json = JSON.stringify(input, queryKeyJsonReplacer);
|
||||
if (json === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return JSON.parse(json) as JsonValue;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Detects plain objects (including objects with a null prototype).
|
||||
*/
|
||||
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
||||
if (value === null || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value as object);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Turns URLSearchParams into a sorted JSON object for deterministic keys.
|
||||
*/
|
||||
const serializeSearchParams = (params: URLSearchParams): JsonValue => {
|
||||
const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b));
|
||||
const result: Record<string, JsonValue> = {};
|
||||
|
||||
for (const [key, value] of entries) {
|
||||
const existing = result[key];
|
||||
if (existing === undefined) {
|
||||
result[key] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(existing)) {
|
||||
(existing as string[]).push(value);
|
||||
} else {
|
||||
result[key] = [existing, value];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes any accepted value into a JSON-friendly shape for query keys.
|
||||
*/
|
||||
export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined => {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value === undefined || typeof value === 'function' || typeof value === 'symbol') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof value === 'bigint') {
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return stringifyToJsonValue(value);
|
||||
}
|
||||
|
||||
if (typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams) {
|
||||
return serializeSearchParams(value);
|
||||
}
|
||||
|
||||
if (isPlainObject(value)) {
|
||||
return stringifyToJsonValue(value);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
@@ -0,0 +1,243 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { Config } from './types.gen';
|
||||
|
||||
export type ServerSentEventsOptions<TData = unknown> = Omit<RequestInit, 'method'> &
|
||||
Pick<Config, 'method' | 'responseTransformer' | 'responseValidator'> & {
|
||||
/**
|
||||
* Fetch API implementation. You can use this option to provide a custom
|
||||
* fetch instance.
|
||||
*
|
||||
* @default globalThis.fetch
|
||||
*/
|
||||
fetch?: typeof fetch;
|
||||
/**
|
||||
* Implementing clients can call request interceptors inside this hook.
|
||||
*/
|
||||
onRequest?: (url: string, init: RequestInit) => Promise<Request>;
|
||||
/**
|
||||
* Callback invoked when a network or parsing error occurs during streaming.
|
||||
*
|
||||
* This option applies only if the endpoint returns a stream of events.
|
||||
*
|
||||
* @param error The error that occurred.
|
||||
*/
|
||||
onSseError?: (error: unknown) => void;
|
||||
/**
|
||||
* Callback invoked when an event is streamed from the server.
|
||||
*
|
||||
* This option applies only if the endpoint returns a stream of events.
|
||||
*
|
||||
* @param event Event streamed from the server.
|
||||
* @returns Nothing (void).
|
||||
*/
|
||||
onSseEvent?: (event: StreamEvent<TData>) => void;
|
||||
serializedBody?: RequestInit['body'];
|
||||
/**
|
||||
* Default retry delay in milliseconds.
|
||||
*
|
||||
* This option applies only if the endpoint returns a stream of events.
|
||||
*
|
||||
* @default 3000
|
||||
*/
|
||||
sseDefaultRetryDelay?: number;
|
||||
/**
|
||||
* Maximum number of retry attempts before giving up.
|
||||
*/
|
||||
sseMaxRetryAttempts?: number;
|
||||
/**
|
||||
* Maximum retry delay in milliseconds.
|
||||
*
|
||||
* Applies only when exponential backoff is used.
|
||||
*
|
||||
* This option applies only if the endpoint returns a stream of events.
|
||||
*
|
||||
* @default 30000
|
||||
*/
|
||||
sseMaxRetryDelay?: number;
|
||||
/**
|
||||
* Optional sleep function for retry backoff.
|
||||
*
|
||||
* Defaults to using `setTimeout`.
|
||||
*/
|
||||
sseSleepFn?: (ms: number) => Promise<void>;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export interface StreamEvent<TData = unknown> {
|
||||
data: TData;
|
||||
event?: string;
|
||||
id?: string;
|
||||
retry?: number;
|
||||
}
|
||||
|
||||
export type ServerSentEventsResult<TData = unknown, TReturn = void, TNext = unknown> = {
|
||||
stream: AsyncGenerator<
|
||||
TData extends Record<string, unknown> ? TData[keyof TData] : TData,
|
||||
TReturn,
|
||||
TNext
|
||||
>;
|
||||
};
|
||||
|
||||
export const createSseClient = <TData = unknown>({
|
||||
onRequest,
|
||||
onSseError,
|
||||
onSseEvent,
|
||||
responseTransformer,
|
||||
responseValidator,
|
||||
sseDefaultRetryDelay,
|
||||
sseMaxRetryAttempts,
|
||||
sseMaxRetryDelay,
|
||||
sseSleepFn,
|
||||
url,
|
||||
...options
|
||||
}: ServerSentEventsOptions): ServerSentEventsResult<TData> => {
|
||||
let lastEventId: string | undefined;
|
||||
|
||||
const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
|
||||
|
||||
const createStream = async function* () {
|
||||
let retryDelay: number = sseDefaultRetryDelay ?? 3000;
|
||||
let attempt = 0;
|
||||
const signal = options.signal ?? new AbortController().signal;
|
||||
|
||||
while (true) {
|
||||
if (signal.aborted) break;
|
||||
|
||||
attempt++;
|
||||
|
||||
const headers =
|
||||
options.headers instanceof Headers
|
||||
? options.headers
|
||||
: new Headers(options.headers as Record<string, string> | undefined);
|
||||
|
||||
if (lastEventId !== undefined) {
|
||||
headers.set('Last-Event-ID', lastEventId);
|
||||
}
|
||||
|
||||
try {
|
||||
const requestInit: RequestInit = {
|
||||
redirect: 'follow',
|
||||
...options,
|
||||
body: options.serializedBody,
|
||||
headers,
|
||||
signal,
|
||||
};
|
||||
let request = new Request(url, requestInit);
|
||||
if (onRequest) {
|
||||
request = await onRequest(url, requestInit);
|
||||
}
|
||||
// fetch must be assigned here, otherwise it would throw the error:
|
||||
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
|
||||
const _fetch = options.fetch ?? globalThis.fetch;
|
||||
const response = await _fetch(request);
|
||||
|
||||
if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`);
|
||||
|
||||
if (!response.body) throw new Error('No body in SSE response');
|
||||
|
||||
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
|
||||
|
||||
let buffer = '';
|
||||
|
||||
const abortHandler = () => {
|
||||
try {
|
||||
reader.cancel();
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
};
|
||||
|
||||
signal.addEventListener('abort', abortHandler);
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += value;
|
||||
// Normalize line endings: CRLF -> LF, then CR -> LF
|
||||
buffer = buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
|
||||
const chunks = buffer.split('\n\n');
|
||||
buffer = chunks.pop() ?? '';
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const lines = chunk.split('\n');
|
||||
const dataLines: Array<string> = [];
|
||||
let eventName: string | undefined;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data:')) {
|
||||
dataLines.push(line.replace(/^data:\s*/, ''));
|
||||
} else if (line.startsWith('event:')) {
|
||||
eventName = line.replace(/^event:\s*/, '');
|
||||
} else if (line.startsWith('id:')) {
|
||||
lastEventId = line.replace(/^id:\s*/, '');
|
||||
} else if (line.startsWith('retry:')) {
|
||||
const parsed = Number.parseInt(line.replace(/^retry:\s*/, ''), 10);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
retryDelay = parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let data: unknown;
|
||||
let parsedJson = false;
|
||||
|
||||
if (dataLines.length) {
|
||||
const rawData = dataLines.join('\n');
|
||||
try {
|
||||
data = JSON.parse(rawData);
|
||||
parsedJson = true;
|
||||
} catch {
|
||||
data = rawData;
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedJson) {
|
||||
if (responseValidator) {
|
||||
await responseValidator(data);
|
||||
}
|
||||
|
||||
if (responseTransformer) {
|
||||
data = await responseTransformer(data);
|
||||
}
|
||||
}
|
||||
|
||||
onSseEvent?.({
|
||||
data,
|
||||
event: eventName,
|
||||
id: lastEventId,
|
||||
retry: retryDelay,
|
||||
});
|
||||
|
||||
if (dataLines.length) {
|
||||
yield data as any;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
signal.removeEventListener('abort', abortHandler);
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
break; // exit loop on normal completion
|
||||
} catch (error) {
|
||||
// connection failed or aborted; retry after delay
|
||||
onSseError?.(error);
|
||||
|
||||
if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) {
|
||||
break; // stop after firing error
|
||||
}
|
||||
|
||||
// exponential backoff: double retry each attempt, cap at 30s
|
||||
const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000);
|
||||
await sleep(backoff);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const stream = createStream();
|
||||
|
||||
return { stream };
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { Auth, AuthToken } from './auth.gen';
|
||||
import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from './bodySerializer.gen';
|
||||
|
||||
export type HttpMethod =
|
||||
| 'connect'
|
||||
| 'delete'
|
||||
| 'get'
|
||||
| 'head'
|
||||
| 'options'
|
||||
| 'patch'
|
||||
| 'post'
|
||||
| 'put'
|
||||
| 'trace';
|
||||
|
||||
export type Client<
|
||||
RequestFn = never,
|
||||
Config = unknown,
|
||||
MethodFn = never,
|
||||
BuildUrlFn = never,
|
||||
SseFn = never,
|
||||
> = {
|
||||
/**
|
||||
* Returns the final request URL.
|
||||
*/
|
||||
buildUrl: BuildUrlFn;
|
||||
getConfig: () => Config;
|
||||
request: RequestFn;
|
||||
setConfig: (config: Config) => Config;
|
||||
} & {
|
||||
[K in HttpMethod]: MethodFn;
|
||||
} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } });
|
||||
|
||||
export interface Config {
|
||||
/**
|
||||
* Auth token or a function returning auth token. The resolved value will be
|
||||
* added to the request payload as defined by its `security` array.
|
||||
*/
|
||||
auth?: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken;
|
||||
/**
|
||||
* A function for serializing request body parameter. By default,
|
||||
* {@link JSON.stringify()} will be used.
|
||||
*/
|
||||
bodySerializer?: BodySerializer | null;
|
||||
/**
|
||||
* An object containing any HTTP headers that you want to pre-populate your
|
||||
* `Headers` object with.
|
||||
*
|
||||
* {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
|
||||
*/
|
||||
headers?:
|
||||
| RequestInit['headers']
|
||||
| Record<
|
||||
string,
|
||||
string | number | boolean | (string | number | boolean)[] | null | undefined | unknown
|
||||
>;
|
||||
/**
|
||||
* The request method.
|
||||
*
|
||||
* {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more}
|
||||
*/
|
||||
method?: Uppercase<HttpMethod>;
|
||||
/**
|
||||
* A function for serializing request query parameters. By default, arrays
|
||||
* will be exploded in form style, objects will be exploded in deepObject
|
||||
* style, and reserved characters are percent-encoded.
|
||||
*
|
||||
* This method will have no effect if the native `paramsSerializer()` Axios
|
||||
* API function is used.
|
||||
*
|
||||
* {@link https://swagger.io/docs/specification/serialization/#query View examples}
|
||||
*/
|
||||
querySerializer?: QuerySerializer | QuerySerializerOptions;
|
||||
/**
|
||||
* A function validating request data. This is useful if you want to ensure
|
||||
* the request conforms to the desired shape, so it can be safely sent to
|
||||
* the server.
|
||||
*/
|
||||
requestValidator?: (data: unknown) => Promise<unknown>;
|
||||
/**
|
||||
* A function transforming response data before it's returned. This is useful
|
||||
* for post-processing data, e.g. converting ISO strings into Date objects.
|
||||
*/
|
||||
responseTransformer?: (data: unknown) => Promise<unknown>;
|
||||
/**
|
||||
* A function validating response data. This is useful if you want to ensure
|
||||
* the response conforms to the desired shape, so it can be safely passed to
|
||||
* the transformers and returned to the user.
|
||||
*/
|
||||
responseValidator?: (data: unknown) => Promise<unknown>;
|
||||
}
|
||||
|
||||
type IsExactlyNeverOrNeverUndefined<T> = [T] extends [never]
|
||||
? true
|
||||
: [T] extends [never | undefined]
|
||||
? [undefined] extends [T]
|
||||
? false
|
||||
: true
|
||||
: false;
|
||||
|
||||
export type OmitNever<T extends Record<string, unknown>> = {
|
||||
[K in keyof T as IsExactlyNeverOrNeverUndefined<T[K]> extends true ? never : K]: T[K];
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { BodySerializer, QuerySerializer } from './bodySerializer.gen';
|
||||
import {
|
||||
type ArraySeparatorStyle,
|
||||
serializeArrayParam,
|
||||
serializeObjectParam,
|
||||
serializePrimitiveParam,
|
||||
} from './pathSerializer.gen';
|
||||
|
||||
export interface PathSerializer {
|
||||
path: Record<string, unknown>;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const PATH_PARAM_RE = /\{[^{}]+\}/g;
|
||||
|
||||
export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
|
||||
let url = _url;
|
||||
const matches = _url.match(PATH_PARAM_RE);
|
||||
if (matches) {
|
||||
for (const match of matches) {
|
||||
let explode = false;
|
||||
let name = match.substring(1, match.length - 1);
|
||||
let style: ArraySeparatorStyle = 'simple';
|
||||
|
||||
if (name.endsWith('*')) {
|
||||
explode = true;
|
||||
name = name.substring(0, name.length - 1);
|
||||
}
|
||||
|
||||
if (name.startsWith('.')) {
|
||||
name = name.substring(1);
|
||||
style = 'label';
|
||||
} else if (name.startsWith(';')) {
|
||||
name = name.substring(1);
|
||||
style = 'matrix';
|
||||
}
|
||||
|
||||
const value = path[name];
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
url = url.replace(match, serializeArrayParam({ explode, name, style, value }));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
url = url.replace(
|
||||
match,
|
||||
serializeObjectParam({
|
||||
explode,
|
||||
name,
|
||||
style,
|
||||
value: value as Record<string, unknown>,
|
||||
valueOnly: true,
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (style === 'matrix') {
|
||||
url = url.replace(
|
||||
match,
|
||||
`;${serializePrimitiveParam({
|
||||
name,
|
||||
value: value as string,
|
||||
})}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const replaceValue = encodeURIComponent(
|
||||
style === 'label' ? `.${value as string}` : (value as string),
|
||||
);
|
||||
url = url.replace(match, replaceValue);
|
||||
}
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
export const getUrl = ({
|
||||
baseUrl,
|
||||
path,
|
||||
query,
|
||||
querySerializer,
|
||||
url: _url,
|
||||
}: {
|
||||
baseUrl?: string;
|
||||
path?: Record<string, unknown>;
|
||||
query?: Record<string, unknown>;
|
||||
querySerializer: QuerySerializer;
|
||||
url: string;
|
||||
}) => {
|
||||
const pathUrl = _url.startsWith('/') ? _url : `/${_url}`;
|
||||
let url = (baseUrl ?? '') + pathUrl;
|
||||
if (path) {
|
||||
url = defaultPathSerializer({ path, url });
|
||||
}
|
||||
let search = query ? querySerializer(query) : '';
|
||||
if (search.startsWith('?')) {
|
||||
search = search.substring(1);
|
||||
}
|
||||
if (search) {
|
||||
url += `?${search}`;
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
export function getValidRequestBody(options: {
|
||||
body?: unknown;
|
||||
bodySerializer?: BodySerializer | null;
|
||||
serializedBody?: unknown;
|
||||
}) {
|
||||
const hasBody = options.body !== undefined;
|
||||
const isSerializedBody = hasBody && options.bodySerializer;
|
||||
|
||||
if (isSerializedBody) {
|
||||
if ('serializedBody' in options) {
|
||||
const hasSerializedBody =
|
||||
options.serializedBody !== undefined && options.serializedBody !== '';
|
||||
|
||||
return hasSerializedBody ? options.serializedBody : null;
|
||||
}
|
||||
|
||||
// not all clients implement a serializedBody property (i.e. client-axios)
|
||||
return options.body !== '' ? options.body : null;
|
||||
}
|
||||
|
||||
// plain/text body
|
||||
if (hasBody) {
|
||||
return options.body;
|
||||
}
|
||||
|
||||
// no body was provided
|
||||
return undefined;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
||||
# @memoh/shared
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"name": "@memoh/shared",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"packageManager": "pnpm@10.27.0"
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
export interface robot{
|
||||
description: string
|
||||
time: Date,
|
||||
id: string | number,
|
||||
type: string,
|
||||
action: 'robot',
|
||||
state:'thinking'|'generate'|'complete'
|
||||
}
|
||||
|
||||
export interface user{
|
||||
description: string,
|
||||
time: Date,
|
||||
id: number | string,
|
||||
action:'user'
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export * from './model'
|
||||
export * from './schedule'
|
||||
export * from './platform'
|
||||
export * from './mcp'
|
||||
export * from './chatInfo'
|
||||
@@ -1,48 +0,0 @@
|
||||
export interface BaseMCPConnection {
|
||||
type: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface StdioMCPConnection extends BaseMCPConnection {
|
||||
type: 'stdio'
|
||||
command: string
|
||||
args: string[]
|
||||
env: Record<string, string>
|
||||
cwd: string
|
||||
}
|
||||
|
||||
export interface BaseHTTPMCPConnection extends BaseMCPConnection {
|
||||
url: string
|
||||
headers: Record<string, string>
|
||||
}
|
||||
|
||||
export interface HTTPMCPConnection extends BaseHTTPMCPConnection {
|
||||
type: 'http'
|
||||
}
|
||||
|
||||
export interface SSEMCPConnection extends BaseHTTPMCPConnection {
|
||||
type: 'sse'
|
||||
}
|
||||
|
||||
export type MCPConnection =
|
||||
| StdioMCPConnection
|
||||
| HTTPMCPConnection
|
||||
| SSEMCPConnection
|
||||
|
||||
|
||||
export interface MCPListItem{
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
config: {
|
||||
cwd: string;
|
||||
env: Record<string, string>;
|
||||
args: string[];
|
||||
type: string;
|
||||
command: string;
|
||||
};
|
||||
active: boolean;
|
||||
user: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
export enum ModelClientType {
|
||||
OPENAI = 'openai',
|
||||
ANTHROPIC = 'anthropic',
|
||||
GOOGLE = 'google',
|
||||
}
|
||||
|
||||
export enum ModelType {
|
||||
CHAT = 'chat',
|
||||
EMBEDDING = 'embedding',
|
||||
}
|
||||
|
||||
export interface BaseModel {
|
||||
/**
|
||||
* @description The unique identifier for the model
|
||||
* @example 'gpt-4o'
|
||||
*/
|
||||
modelId: string
|
||||
|
||||
/**
|
||||
* @description The base URL for the model
|
||||
* @example 'https://api.openai.com/v1'
|
||||
*/
|
||||
baseUrl: string
|
||||
|
||||
/**
|
||||
* @description The API key for the model
|
||||
* @example 'sk-1234567890'
|
||||
*/
|
||||
apiKey: string
|
||||
|
||||
/**
|
||||
* @description The client type for the model
|
||||
* @enum {ModelClientType}
|
||||
*/
|
||||
clientType: ModelClientType
|
||||
|
||||
/**
|
||||
* @description The display name for the model
|
||||
* @example 'GPT 4o'
|
||||
*/
|
||||
name?: string
|
||||
|
||||
/**
|
||||
* @description The model type
|
||||
* @enum {ModelType}
|
||||
* @default {ModelType.CHAT}
|
||||
*/
|
||||
type?: ModelType
|
||||
}
|
||||
|
||||
export interface EmbeddingModel extends BaseModel {
|
||||
type?: ModelType.EMBEDDING
|
||||
|
||||
/**
|
||||
* @description The dimensions of the embedding
|
||||
* @example 1536
|
||||
*/
|
||||
dimensions: number
|
||||
}
|
||||
|
||||
export interface ChatModel extends BaseModel {
|
||||
type?: ModelType.CHAT
|
||||
}
|
||||
|
||||
export type Model = EmbeddingModel | ChatModel
|
||||
|
||||
|
||||
// 表格当中model的类型
|
||||
export interface ModelList {
|
||||
apiKey: string,
|
||||
baseUrl: string,
|
||||
clientType: 'OpenAI' | 'Anthropic' | 'Google',
|
||||
modelId: string,
|
||||
name: string,
|
||||
type: 'chat' | 'embedding',
|
||||
id: string,
|
||||
defaultChatModel: boolean,
|
||||
defaultEmbeddingModel: boolean,
|
||||
defaultSummaryModel: boolean
|
||||
}
|
||||
|
||||
export interface ProviderInfo{
|
||||
api_key: string;
|
||||
base_url: string;
|
||||
client_type: string;
|
||||
metadata: Record<'additionalProp1',object>;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ModelInfo{
|
||||
dimensions:number
|
||||
is_multimodal:boolean
|
||||
input?: string[]
|
||||
llm_provider_id:string
|
||||
model_id:string
|
||||
name:string
|
||||
type: string
|
||||
enable_as?:string
|
||||
}
|
||||
|
||||
export const clientType = ['openai', 'anthropic', 'google', 'ollama'] as const
|
||||
@@ -1,7 +0,0 @@
|
||||
export interface Platform {
|
||||
id: string
|
||||
name: string
|
||||
// endpoint: string
|
||||
config: Record<string, unknown>
|
||||
active: boolean
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
export interface Schedule {
|
||||
id?: string
|
||||
pattern: string
|
||||
name: string
|
||||
description: string
|
||||
command: string
|
||||
maxCalls?: number | null
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
# Markdown 语法测试文档
|
||||
|
||||
## 1. 标题层级
|
||||
### 一级标题
|
||||
#### 二级标题
|
||||
##### 三级标题
|
||||
###### 六级标题
|
||||
|
||||
## 2. 文本格式
|
||||
**粗体文本**
|
||||
*斜体文本*
|
||||
~~删除线文本~~
|
||||
`代码片段`
|
||||
|
||||
> 引用文本
|
||||
> 可以多行
|
||||
|
||||
## 3. 列表
|
||||
|
||||
### 无序列表
|
||||
- 项目一
|
||||
- 项目二
|
||||
- 子项目
|
||||
- 项目三
|
||||
|
||||
### 有序列表
|
||||
1. 第一步
|
||||
2. 第二步
|
||||
3. 第三步
|
||||
|
||||
## 4. 链接与图片
|
||||
[百度](https://www.baidu.com)
|
||||

|
||||
|
||||
## 5. 表格
|
||||
| 姓名 | 年龄 | 职业 |
|
||||
|------|------|------|
|
||||
| 张三 | 25 | 开发 |
|
||||
| 李四 | 30 | 测试 |
|
||||
|
||||
## 6. 代码块
|
||||
```python
|
||||
def hello_world():
|
||||
print("Hello, World!")
|
||||
+13
-3
@@ -2,13 +2,23 @@
|
||||
alias = "dev"
|
||||
description = "Start web development server"
|
||||
run = "pnpm dev"
|
||||
depends = ["//:pnpm-install"]
|
||||
depends = [
|
||||
"//:pnpm-install",
|
||||
"//:sdk-generate",
|
||||
]
|
||||
|
||||
[tasks.build]
|
||||
description = "Build web"
|
||||
run = "pnpm build"
|
||||
depends = ["//:pnpm-install"]
|
||||
depends = [
|
||||
"//:pnpm-install",
|
||||
"//:sdk-generate",
|
||||
]
|
||||
|
||||
[tasks.start]
|
||||
description = "Start web"
|
||||
run = "pnpm start"
|
||||
run = "pnpm start"
|
||||
depends = [
|
||||
"//:pnpm-install",
|
||||
"//:sdk-generate",
|
||||
]
|
||||
@@ -9,8 +9,8 @@
|
||||
"start": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@memoh/shared": "workspace:*",
|
||||
"@memoh/ui": "workspace:*",
|
||||
"@memoh/sdk": "workspace:*",
|
||||
"@pinia/colada": "^0.21.1",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem
|
||||
v-for="type in clientType"
|
||||
v-for="type in CLIENT_TYPES"
|
||||
:key="type"
|
||||
:value="type"
|
||||
>
|
||||
@@ -152,12 +152,22 @@ import {
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import z from 'zod'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { clientType } from '@memoh/shared'
|
||||
import { useCreateProvider } from '@/composables/api/useProviders'
|
||||
import { useMutation, useQueryCache } from '@pinia/colada'
|
||||
import { postProviders } from '@memoh/sdk'
|
||||
import type { ProvidersClientType } from '@memoh/sdk'
|
||||
|
||||
const CLIENT_TYPES: ProvidersClientType[] = ['openai', 'openai-compat', 'anthropic', 'google', 'ollama']
|
||||
|
||||
const open = defineModel<boolean>('open')
|
||||
|
||||
const { mutate: providerFetch, isLoading } = useCreateProvider()
|
||||
const queryCache = useQueryCache()
|
||||
const { mutate: providerFetch, isLoading } = useMutation({
|
||||
mutation: async (data: Record<string, unknown>) => {
|
||||
const { data: result } = await postProviders({ body: data as any, throwOnError: true })
|
||||
return result
|
||||
},
|
||||
onSettled: () => queryCache.invalidateQueries({ key: ['providers'] }),
|
||||
})
|
||||
|
||||
const providerSchema = toTypedSchema(z.object({
|
||||
api_key: z.string().min(1),
|
||||
|
||||
@@ -4,16 +4,16 @@
|
||||
class="flex flex-col gap-4"
|
||||
>
|
||||
<template
|
||||
v-for="chatItem in chatList"
|
||||
v-for="chatItem in messages"
|
||||
:key="chatItem.id"
|
||||
>
|
||||
<UserChat
|
||||
v-if="chatItem.action === 'user'"
|
||||
:user-say="chatItem"
|
||||
v-if="chatItem.role === 'user'"
|
||||
:message="chatItem"
|
||||
/>
|
||||
<RobotChat
|
||||
v-if="chatItem.action === 'robot'"
|
||||
:robot-say="chatItem"
|
||||
<AssistantChat
|
||||
v-if="chatItem.role === 'assistant'"
|
||||
:message="chatItem"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
@@ -21,14 +21,15 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import UserChat from './user-chat/index.vue'
|
||||
import RobotChat from './robot-chat/index.vue'
|
||||
import AssistantChat from './assistant-chat/index.vue'
|
||||
import { inject, ref, watch } from 'vue'
|
||||
import { useChatList } from '@/store/chat-list'
|
||||
import { useChatStore } from '@/store/chat-list'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useAutoScroll } from '@/composables/useAutoScroll'
|
||||
|
||||
const { chatList, sendMessage } = useChatList()
|
||||
const { loading } = storeToRefs(useChatList())
|
||||
const store = useChatStore()
|
||||
const { messages, sendMessage } = store
|
||||
const { streaming } = storeToRefs(store)
|
||||
|
||||
// ---- 消息发送 ----
|
||||
const chatSay = inject('chatSay', ref(''))
|
||||
@@ -47,5 +48,5 @@ watch(chatSay, async () => {
|
||||
|
||||
// ---- 自动滚动 ----
|
||||
const displayContainer = ref<HTMLElement>()
|
||||
useAutoScroll(displayContainer, loading)
|
||||
useAutoScroll(displayContainer, streaming)
|
||||
</script>
|
||||
|
||||
@@ -4,14 +4,21 @@
|
||||
class="leading-7 not-first:mt-6 max-w-[90%] ml-auto text-muted-foreground bg-[#F9F9F9] p-4 rounded-xl rounded-tr-none break-all dark:bg-[#1C1917]
|
||||
"
|
||||
>
|
||||
{{ userSay.description }}
|
||||
{{ textContent }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { user } from '@memoh/shared'
|
||||
const { userSay } = defineProps<{
|
||||
userSay: user
|
||||
import { computed } from 'vue'
|
||||
import type { ChatMessage } from '@/store/chat-list'
|
||||
|
||||
const props = defineProps<{
|
||||
message: ChatMessage
|
||||
}>()
|
||||
</script>
|
||||
|
||||
const textContent = computed(() => {
|
||||
const block = props.message.blocks.find(b => b.type === 'text')
|
||||
return block?.type === 'text' ? block.content : ''
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -260,7 +260,7 @@ import z from 'zod'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { ref, inject, watch } from 'vue'
|
||||
import { type MCPListItem as MCPType } from '@memoh/shared'
|
||||
import { type MCPListItem as MCPType } from '@/composables/api/useMcp'
|
||||
import { useKeyValueTags } from '@/composables/useKeyValueTags'
|
||||
import { useCreateOrUpdateMcp } from '@/composables/api/useMcp'
|
||||
|
||||
|
||||
@@ -172,8 +172,9 @@ import { useForm } from 'vee-validate'
|
||||
import { inject, computed, watch, nextTick, type Ref, ref } from 'vue'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import z from 'zod'
|
||||
import { type ModelInfo } from '@memoh/shared'
|
||||
import { useCreateModel, useUpdateModel } from '@/composables/api/useModels'
|
||||
import { useMutation, useQueryCache } from '@pinia/colada'
|
||||
import { postModels, putModelsModelByModelId } from '@memoh/sdk'
|
||||
import type { ModelsGetResponse } from '@memoh/sdk'
|
||||
|
||||
const formSchema = toTypedSchema(z.object({
|
||||
type: z.string().min(1),
|
||||
@@ -191,7 +192,7 @@ const selectedType = computed(() => form.values.type || editInfo?.value?.type)
|
||||
|
||||
const open = inject<Ref<boolean>>('openModel', ref(false))
|
||||
const title = inject<Ref<'edit' | 'title'>>('openModelTitle', ref('title'))
|
||||
const editInfo = inject<Ref<ModelInfo | null>>('openModelState', ref(null))
|
||||
const editInfo = inject<Ref<ModelsGetResponse | null>>('openModelState', ref(null))
|
||||
|
||||
// 保存按钮:编辑模式直接可提交(表单已预填充,handleSubmit 内部会校验)
|
||||
// 新建模式需要必填字段有值
|
||||
@@ -229,8 +230,25 @@ function onNameInput(e: Event) {
|
||||
|
||||
const { id } = defineProps<{ id: string }>()
|
||||
|
||||
const { mutateAsync: createModel, isLoading: createLoading } = useCreateModel()
|
||||
const { mutateAsync: updateModel, isLoading: updateLoading } = useUpdateModel()
|
||||
const queryCache = useQueryCache()
|
||||
const { mutateAsync: createModel, isLoading: createLoading } = useMutation({
|
||||
mutation: async (data: Record<string, unknown>) => {
|
||||
const { data: result } = await postModels({ body: data as any, throwOnError: true })
|
||||
return result
|
||||
},
|
||||
onSettled: () => queryCache.invalidateQueries({ key: ['provider-models'] }),
|
||||
})
|
||||
const { mutateAsync: updateModel, isLoading: updateLoading } = useMutation({
|
||||
mutation: async ({ modelId, data }: { modelId: string; data: Record<string, unknown> }) => {
|
||||
const { data: result } = await putModelsModelByModelId({
|
||||
path: { modelId },
|
||||
body: data as any,
|
||||
throwOnError: true,
|
||||
})
|
||||
return result
|
||||
},
|
||||
onSettled: () => queryCache.invalidateQueries({ key: ['provider-models'] }),
|
||||
})
|
||||
const isLoading = computed(() => createLoading.value || updateLoading.value)
|
||||
|
||||
async function addModel(e: Event) {
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { fetchApi } from '@/utils/request'
|
||||
import { useQuery, useMutation, useQueryCache } from '@pinia/colada'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
// ---- Types ----
|
||||
|
||||
export interface BotSettings {
|
||||
chat_model_id: string
|
||||
memory_model_id: string
|
||||
embedding_model_id: string
|
||||
max_context_load_time: number
|
||||
language: string
|
||||
allow_guest: boolean
|
||||
}
|
||||
|
||||
export interface UpsertBotSettingsRequest {
|
||||
chat_model_id?: string
|
||||
memory_model_id?: string
|
||||
embedding_model_id?: string
|
||||
max_context_load_time?: number
|
||||
language?: string
|
||||
allow_guest?: boolean
|
||||
}
|
||||
|
||||
// ---- Query ----
|
||||
|
||||
export function useBotSettings(botId: Ref<string>) {
|
||||
return useQuery({
|
||||
key: () => ['bot-settings', botId.value],
|
||||
query: () => fetchApi<BotSettings>(`/bots/${botId.value}/settings`),
|
||||
enabled: () => !!botId.value,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Mutation ----
|
||||
|
||||
export function useUpdateBotSettings(botId: Ref<string>) {
|
||||
const queryCache = useQueryCache()
|
||||
return useMutation({
|
||||
mutation: (data: UpsertBotSettingsRequest) => fetchApi<BotSettings>(
|
||||
`/bots/${botId.value}/settings`,
|
||||
{ method: 'PUT', body: data },
|
||||
),
|
||||
onSettled: () => queryCache.invalidateQueries({ key: ['bot-settings', botId.value] }),
|
||||
})
|
||||
}
|
||||
@@ -1,9 +1,24 @@
|
||||
import { fetchApi } from '@/utils/request'
|
||||
import { useQuery, useMutation, useQueryCache } from '@pinia/colada'
|
||||
import { type MCPListItem } from '@memoh/shared'
|
||||
|
||||
// ---- Types ----
|
||||
|
||||
export interface MCPListItem {
|
||||
id: string
|
||||
type: string
|
||||
name: string
|
||||
config: {
|
||||
cwd: string
|
||||
env: Record<string, string>
|
||||
args: string[]
|
||||
type: string
|
||||
command: string
|
||||
}
|
||||
active: boolean
|
||||
user: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface McpListResponse {
|
||||
items: MCPListItem[]
|
||||
}
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { fetchApi } from '@/utils/request'
|
||||
import { useQuery, useMutation, useQueryCache } from '@pinia/colada'
|
||||
import { type ModelInfo } from '@memoh/shared'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
// ---- Types ----
|
||||
|
||||
export interface CreateModelRequest {
|
||||
model_id: string
|
||||
type: string
|
||||
llm_provider_id: string
|
||||
name?: string
|
||||
dimensions?: number
|
||||
is_multimodal?: boolean
|
||||
}
|
||||
|
||||
// ---- Query: 获取 Provider 下的模型列表 ----
|
||||
|
||||
export function useModelList(providerId: Ref<string | undefined>) {
|
||||
const queryCache = useQueryCache()
|
||||
|
||||
const query = useQuery({
|
||||
key: ['model'],
|
||||
query: () => fetchApi<ModelInfo[]>(
|
||||
`/providers/${providerId.value}/models`,
|
||||
),
|
||||
})
|
||||
|
||||
return {
|
||||
...query,
|
||||
/** 当 providerId 变化时手动刷新 */
|
||||
invalidate: () => queryCache.invalidateQueries({ key: ['model'] }),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Query: 获取所有模型(跨 Provider) ----
|
||||
|
||||
export function useAllModels() {
|
||||
return useQuery({
|
||||
key: ['all-models'],
|
||||
query: () => fetchApi<ModelInfo[]>('/models'),
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Mutations ----
|
||||
|
||||
export function useCreateModel() {
|
||||
const queryCache = useQueryCache()
|
||||
return useMutation({
|
||||
mutation: (data: CreateModelRequest) => fetchApi('/models', {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
}),
|
||||
onSettled: () => queryCache.invalidateQueries({ key: ['model'], exact: true }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateModel() {
|
||||
const queryCache = useQueryCache()
|
||||
return useMutation({
|
||||
mutation: ({ modelId, data }: { modelId: string; data: Partial<CreateModelRequest> }) =>
|
||||
fetchApi(`/models/model/${modelId}`, {
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
}),
|
||||
onSettled: () => queryCache.invalidateQueries({ key: ['model'] }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteModel() {
|
||||
const queryCache = useQueryCache()
|
||||
return useMutation({
|
||||
mutation: (modelName: string) => fetchApi(`/models/model/${modelName}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
onSettled: () => queryCache.invalidateQueries({ key: ['model'] }),
|
||||
})
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { fetchApi } from '@/utils/request'
|
||||
import { useQuery, useMutation, useQueryCache } from '@pinia/colada'
|
||||
import { type ProviderInfo } from '@memoh/shared'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
// ---- Types ----
|
||||
|
||||
export type ProviderWithId = ProviderInfo & { id: string }
|
||||
|
||||
export interface CreateProviderRequest {
|
||||
name: string
|
||||
api_key: string
|
||||
base_url: string
|
||||
client_type: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type UpdateProviderRequest = Partial<CreateProviderRequest>
|
||||
|
||||
// ---- Query: 获取 Provider 列表 ----
|
||||
|
||||
export function useProviderList(clientType: Ref<string>) {
|
||||
return useQuery({
|
||||
key: ['provider'],
|
||||
query: () => fetchApi<ProviderWithId[]>(
|
||||
`/providers?client_type=${clientType.value}`,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
/** 获取所有 Provider(无过滤) */
|
||||
export function useAllProviders() {
|
||||
return useQuery({
|
||||
key: ['all-providers'],
|
||||
query: () => fetchApi<ProviderWithId[]>('/providers'),
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Mutations ----
|
||||
|
||||
export function useCreateProvider() {
|
||||
const queryCache = useQueryCache()
|
||||
return useMutation({
|
||||
mutation: (data: CreateProviderRequest) => fetchApi('/providers', {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
}),
|
||||
onSettled: () => queryCache.invalidateQueries({ key: ['provider'] }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateProvider(providerId: Ref<string | undefined>) {
|
||||
const queryCache = useQueryCache()
|
||||
return useMutation({
|
||||
mutation: (data: UpdateProviderRequest) => fetchApi(`/providers/${providerId.value}`, {
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
}),
|
||||
onSettled: () => queryCache.invalidateQueries({ key: ['provider'] }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteProvider(providerId: Ref<string | undefined>) {
|
||||
const queryCache = useQueryCache()
|
||||
return useMutation({
|
||||
mutation: () => fetchApi(`/providers/${providerId.value}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
onSettled: () => queryCache.invalidateQueries({ key: ['provider'] }),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { client } from '@memoh/sdk/client'
|
||||
import router from '@/router'
|
||||
|
||||
/**
|
||||
* Configure the SDK client with base URL, auth interceptor, and 401 handling.
|
||||
* Call this once at app startup (main.ts).
|
||||
*/
|
||||
export function setupApiClient() {
|
||||
// Set base URL to match the Vite proxy
|
||||
client.setConfig({ baseUrl: '/api' })
|
||||
|
||||
// Add auth token to every request
|
||||
client.interceptors.request.use((request) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
request.headers.set('Authorization', `Bearer ${token}`)
|
||||
}
|
||||
return request
|
||||
})
|
||||
|
||||
// Handle 401 responses globally
|
||||
client.interceptors.response.use((response) => {
|
||||
if (response.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
router.replace({ name: 'Login' })
|
||||
}
|
||||
return response
|
||||
})
|
||||
}
|
||||
@@ -2,6 +2,10 @@ import { createApp } from 'vue'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { setupApiClient } from './lib/api-client'
|
||||
|
||||
// Configure SDK client before anything else
|
||||
setupApiClient()
|
||||
import { createPinia } from 'pinia'
|
||||
import i18n from './i18n'
|
||||
import { PiniaColada } from '@pinia/colada'
|
||||
|
||||
@@ -80,17 +80,17 @@ import {
|
||||
import ConfirmPopover from '@/components/confirm-popover/index.vue'
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { BotInfo } from '@/composables/api/useBots'
|
||||
import type { BotsBot } from '@memoh/sdk'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps<{
|
||||
bot: BotInfo
|
||||
bot: BotsBot
|
||||
deleteLoading: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
edit: [bot: BotInfo]
|
||||
edit: [bot: BotsBot]
|
||||
delete: [id: string]
|
||||
}>()
|
||||
|
||||
|
||||
@@ -142,15 +142,48 @@ import {
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from '@memoh/ui'
|
||||
import { useBotChannels, type BotChannelItem } from '@/composables/api/useChannels'
|
||||
import { useQuery, useQueryCache } from '@pinia/colada'
|
||||
import { getChannels, getBotsByIdChannelByPlatform } from '@memoh/sdk'
|
||||
import type { HandlersChannelMeta, ChannelChannelConfig } from '@memoh/sdk'
|
||||
import ChannelSettingsPanel from './channel-settings-panel.vue'
|
||||
|
||||
export interface BotChannelItem {
|
||||
meta: HandlersChannelMeta
|
||||
config: ChannelChannelConfig | null
|
||||
configured: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
botId: string
|
||||
}>()
|
||||
|
||||
const botIdRef = computed(() => props.botId)
|
||||
const { data: channels, isLoading, refetch } = useBotChannels(botIdRef)
|
||||
|
||||
const { data: channels, isLoading, refetch } = useQuery({
|
||||
key: () => ['bot-channels', botIdRef.value],
|
||||
query: async (): Promise<BotChannelItem[]> => {
|
||||
const { data: metas } = await getChannels({ throwOnError: true })
|
||||
if (!metas) return []
|
||||
|
||||
const configurableTypes = metas.filter((m) => !m.configless)
|
||||
|
||||
const results = await Promise.all(
|
||||
configurableTypes.map(async (meta) => {
|
||||
try {
|
||||
const { data: config } = await getBotsByIdChannelByPlatform({
|
||||
path: { id: botIdRef.value, platform: meta.type },
|
||||
throwOnError: true,
|
||||
})
|
||||
return { meta, config: config ?? null, configured: true } as BotChannelItem
|
||||
} catch {
|
||||
return { meta, config: null, configured: false } as BotChannelItem
|
||||
}
|
||||
}),
|
||||
)
|
||||
return results
|
||||
},
|
||||
enabled: () => !!botIdRef.value,
|
||||
})
|
||||
|
||||
const selectedType = ref<string | null>(null)
|
||||
const addPopoverOpen = ref(false)
|
||||
|
||||
@@ -103,9 +103,9 @@ import { reactive, computed, watch } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ModelSelect from './model-select.vue'
|
||||
import { useBotSettings, useUpdateBotSettings, type BotSettings } from '@/composables/api/useBotSettings'
|
||||
import { useAllModels } from '@/composables/api/useModels'
|
||||
import { useAllProviders } from '@/composables/api/useProviders'
|
||||
import { useQuery, useMutation, useQueryCache } from '@pinia/colada'
|
||||
import { getBotsByBotIdSettings, putBotsByBotIdSettings, getModels, getProviders } from '@memoh/sdk'
|
||||
import type { SettingsSettings } from '@memoh/sdk'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -119,16 +119,50 @@ const botIdRef = computed(() => props.botId) as Ref<string>
|
||||
const isPersonalBot = computed(() => props.botType.trim().toLowerCase() === 'personal')
|
||||
|
||||
// ---- Data ----
|
||||
const { data: settings } = useBotSettings(botIdRef)
|
||||
const { data: modelData } = useAllModels()
|
||||
const { data: providerData } = useAllProviders()
|
||||
const { mutateAsync: updateSettings, isLoading } = useUpdateBotSettings(botIdRef)
|
||||
const queryCache = useQueryCache()
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
key: () => ['bot-settings', botIdRef.value],
|
||||
query: async () => {
|
||||
const { data } = await getBotsByBotIdSettings({ path: { bot_id: botIdRef.value }, throwOnError: true })
|
||||
return data
|
||||
},
|
||||
enabled: () => !!botIdRef.value,
|
||||
})
|
||||
|
||||
const { data: modelData } = useQuery({
|
||||
key: ['all-models'],
|
||||
query: async () => {
|
||||
const { data } = await getModels({ throwOnError: true })
|
||||
return data
|
||||
},
|
||||
})
|
||||
|
||||
const { data: providerData } = useQuery({
|
||||
key: ['all-providers'],
|
||||
query: async () => {
|
||||
const { data } = await getProviders({ throwOnError: true })
|
||||
return data
|
||||
},
|
||||
})
|
||||
|
||||
const { mutateAsync: updateSettings, isLoading } = useMutation({
|
||||
mutation: async (body: Partial<SettingsSettings>) => {
|
||||
const { data } = await putBotsByBotIdSettings({
|
||||
path: { bot_id: botIdRef.value },
|
||||
body,
|
||||
throwOnError: true,
|
||||
})
|
||||
return data
|
||||
},
|
||||
onSettled: () => queryCache.invalidateQueries({ key: ['bot-settings', botIdRef.value] }),
|
||||
})
|
||||
|
||||
const models = computed(() => modelData.value ?? [])
|
||||
const providers = computed(() => providerData.value ?? [])
|
||||
|
||||
// ---- Form ----
|
||||
const form = reactive<BotSettings>({
|
||||
const form = reactive<SettingsSettings>({
|
||||
chat_model_id: '',
|
||||
memory_model_id: '',
|
||||
embedding_model_id: '',
|
||||
|
||||
@@ -151,14 +151,17 @@ import {
|
||||
import { reactive, watch, computed } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
useUpsertBotChannel,
|
||||
type BotChannelItem,
|
||||
type FieldSchema,
|
||||
} from '@/composables/api/useChannels'
|
||||
import { ApiError } from '@/utils/request'
|
||||
import { useMutation, useQueryCache } from '@pinia/colada'
|
||||
import { putBotsByIdChannelByPlatform } from '@memoh/sdk'
|
||||
import type { HandlersChannelMeta, ChannelChannelConfig, ChannelFieldSchema } from '@memoh/sdk'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
interface BotChannelItem {
|
||||
meta: HandlersChannelMeta
|
||||
config: ChannelChannelConfig | null
|
||||
configured: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
botId: string
|
||||
channelItem: BotChannelItem
|
||||
@@ -170,7 +173,18 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n()
|
||||
const botIdRef = computed(() => props.botId) as Ref<string>
|
||||
const { mutateAsync: upsertChannel, isLoading } = useUpsertBotChannel(botIdRef)
|
||||
const queryCache = useQueryCache()
|
||||
const { mutateAsync: upsertChannel, isLoading } = useMutation({
|
||||
mutation: async ({ platform, data }: { platform: string; data: Record<string, unknown> }) => {
|
||||
const { data: result } = await putBotsByIdChannelByPlatform({
|
||||
path: { id: botIdRef.value, platform },
|
||||
body: data as any,
|
||||
throwOnError: true,
|
||||
})
|
||||
return result
|
||||
},
|
||||
onSettled: () => queryCache.invalidateQueries({ key: ['bot-channels', botIdRef.value] }),
|
||||
})
|
||||
|
||||
// ---- Form state ----
|
||||
|
||||
@@ -193,7 +207,7 @@ const orderedFields = computed(() => {
|
||||
if (!a.required && b.required) return 1
|
||||
return 0
|
||||
})
|
||||
return Object.fromEntries(entries) as Record<string, FieldSchema>
|
||||
return Object.fromEntries(entries) as Record<string, ChannelFieldSchema>
|
||||
})
|
||||
|
||||
// 初始化表单
|
||||
@@ -261,10 +275,7 @@ async function handleSave() {
|
||||
emit('saved')
|
||||
} catch (err) {
|
||||
let detail = ''
|
||||
if (err instanceof ApiError && err.body) {
|
||||
const body = err.body as Record<string, unknown>
|
||||
detail = String(body.message || body.error || '')
|
||||
} else if (err instanceof Error) {
|
||||
if (err instanceof Error) {
|
||||
detail = err.message
|
||||
}
|
||||
toast.error(detail ? `${t('bots.channels.saveFailed')}: ${detail}` : t('bots.channels.saveFailed'))
|
||||
|
||||
@@ -89,12 +89,11 @@ import {
|
||||
ScrollArea,
|
||||
} from '@memoh/ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { ModelInfo } from '@memoh/shared'
|
||||
import type { ProviderWithId } from '@/composables/api/useProviders'
|
||||
import type { ModelsGetResponse, ProvidersGetResponse } from '@memoh/sdk'
|
||||
|
||||
const props = defineProps<{
|
||||
models: ModelInfo[]
|
||||
providers: ProviderWithId[]
|
||||
models: ModelsGetResponse[]
|
||||
providers: ProvidersGetResponse[]
|
||||
modelType: 'chat' | 'embedding'
|
||||
placeholder?: string
|
||||
}>()
|
||||
@@ -131,7 +130,7 @@ const filteredGroups = computed(() => {
|
||||
)
|
||||
: typeFilteredModels.value
|
||||
|
||||
const groups = new Map<string, { providerName: string; models: ModelInfo[] }>()
|
||||
const groups = new Map<string, { providerName: string; models: ModelsGetResponse[] }>()
|
||||
for (const model of models) {
|
||||
const pid = model.llm_provider_id
|
||||
const providerName = providerMap.value.get(pid) ?? pid
|
||||
|
||||
@@ -120,14 +120,22 @@ import {
|
||||
} from '@memoh/ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useBotDetail } from '@/composables/api/useBots'
|
||||
import { useQuery } from '@pinia/colada'
|
||||
import { getBotsById } from '@memoh/sdk'
|
||||
import BotSettings from './components/bot-settings.vue'
|
||||
import BotChannels from './components/bot-channels.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const botId = computed(() => route.params.botId as string)
|
||||
|
||||
const { data: bot } = useBotDetail(botId)
|
||||
const { data: bot } = useQuery({
|
||||
key: () => ['bot', botId.value],
|
||||
query: async () => {
|
||||
const { data } = await getBotsById({ path: { id: botId.value }, throwOnError: true })
|
||||
return data
|
||||
},
|
||||
enabled: () => !!botId.value,
|
||||
})
|
||||
|
||||
// 加载到 bot 数据后,用名称替换 breadcrumb 中的 botId
|
||||
watch(bot, (val) => {
|
||||
|
||||
@@ -69,19 +69,25 @@ import {
|
||||
import { ref, computed } from 'vue'
|
||||
import BotCard from './components/bot-card.vue'
|
||||
import CreateBot from './components/create-bot.vue'
|
||||
import { useBotList, useDeleteBot, type BotInfo } from '@/composables/api/useBots'
|
||||
import { useQuery, useMutation, useQueryCache } from '@pinia/colada'
|
||||
import { getBotsQuery, getBotsQueryKey, deleteBotsByIdMutation } from '@memoh/sdk/colada'
|
||||
import type { BotsBot } from '@memoh/sdk'
|
||||
|
||||
const searchText = ref('')
|
||||
const dialogOpen = ref(false)
|
||||
const editingBot = ref<BotInfo | null>(null)
|
||||
const editingBot = ref<BotsBot | null>(null)
|
||||
|
||||
const { data: botData, status } = useBotList()
|
||||
const { mutate: deleteBot, isLoading: deleteLoading } = useDeleteBot()
|
||||
const queryCache = useQueryCache()
|
||||
const { data: botData, status } = useQuery(getBotsQuery())
|
||||
const { mutate: deleteBot, isLoading: deleteLoading } = useMutation({
|
||||
...deleteBotsByIdMutation(),
|
||||
onSettled: () => queryCache.invalidateQueries({ key: getBotsQueryKey() }),
|
||||
})
|
||||
|
||||
const isLoading = computed(() => status.value === 'loading')
|
||||
|
||||
const filteredBots = computed(() => {
|
||||
const list = botData.value ?? []
|
||||
const list = botData.value?.items ?? []
|
||||
const keyword = searchText.value.trim().toLowerCase()
|
||||
if (!keyword) return list
|
||||
return list.filter((bot) =>
|
||||
@@ -91,14 +97,14 @@ const filteredBots = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
function handleEdit(bot: BotInfo) {
|
||||
function handleEdit(bot: BotsBot) {
|
||||
editingBot.value = bot
|
||||
dialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
try {
|
||||
await deleteBot(id)
|
||||
await deleteBot({ path: { id } })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -65,17 +65,19 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Avatar, AvatarImage, AvatarFallback, ScrollArea } from '@memoh/ui'
|
||||
import { useBotList, type BotInfo } from '@/composables/api/useBots'
|
||||
import { useQuery } from '@pinia/colada'
|
||||
import { getBotsQuery } from '@memoh/sdk/colada'
|
||||
import type { BotsBot } from '@memoh/sdk'
|
||||
import { useChatStore } from '@/store/chat-list'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
const chatStore = useChatStore()
|
||||
const { currentBotId } = storeToRefs(chatStore)
|
||||
|
||||
const { data: botData, isLoading } = useBotList()
|
||||
const bots = computed<BotInfo[]>(() => botData.value ?? [])
|
||||
const { data: botData, isLoading } = useQuery(getBotsQuery())
|
||||
const bots = computed<BotsBot[]>(() => botData.value?.items ?? [])
|
||||
|
||||
function handleSelect(bot: BotInfo) {
|
||||
function handleSelect(bot: BotsBot) {
|
||||
chatStore.selectBot(bot.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -43,15 +43,15 @@ import {
|
||||
Button,
|
||||
} from '@memoh/ui'
|
||||
import ConfirmPopover from '@/components/confirm-popover/index.vue'
|
||||
import { type ModelInfo } from '@memoh/shared'
|
||||
import type { ModelsGetResponse } from '@memoh/sdk'
|
||||
|
||||
defineProps<{
|
||||
model: ModelInfo
|
||||
model: ModelsGetResponse
|
||||
deleteLoading: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
edit: [model: ModelInfo]
|
||||
edit: [model: ModelsGetResponse]
|
||||
delete: [name: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -51,16 +51,16 @@ import {
|
||||
} from '@memoh/ui'
|
||||
import CreateModel from '@/components/create-model/index.vue'
|
||||
import ModelItem from './model-item.vue'
|
||||
import { type ModelInfo } from '@memoh/shared'
|
||||
import type { ModelsGetResponse } from '@memoh/sdk'
|
||||
|
||||
defineProps<{
|
||||
providerId: string | undefined
|
||||
models: ModelInfo[] | undefined
|
||||
models: ModelsGetResponse[] | undefined
|
||||
deleteModelLoading: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
edit: [model: ModelInfo]
|
||||
edit: [model: ModelsGetResponse]
|
||||
delete: [name: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -100,10 +100,10 @@ import { computed, watch } from 'vue'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import z from 'zod'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { type ProviderInfo } from '@memoh/shared'
|
||||
import type { ProvidersGetResponse } from '@memoh/sdk'
|
||||
|
||||
const props = defineProps<{
|
||||
provider: Partial<ProviderInfo & { id: string }> | undefined
|
||||
provider: Partial<ProvidersGetResponse> | undefined
|
||||
editLoading: boolean
|
||||
deleteLoading: boolean
|
||||
}>()
|
||||
|
||||
@@ -32,21 +32,15 @@ import { Separator } from '@memoh/ui'
|
||||
import ProviderForm from './components/provider-form.vue'
|
||||
import ModelList from './components/model-list.vue'
|
||||
import { computed, inject, provide, reactive, ref, toRef, watch } from 'vue'
|
||||
import { type ProviderInfo, type ModelInfo } from '@memoh/shared'
|
||||
import {
|
||||
useUpdateProvider,
|
||||
useDeleteProvider,
|
||||
} from '@/composables/api/useProviders'
|
||||
import {
|
||||
useModelList,
|
||||
useDeleteModel,
|
||||
} from '@/composables/api/useModels'
|
||||
import { useQuery, useMutation, useQueryCache } from '@pinia/colada'
|
||||
import { putProvidersById, deleteProvidersById, getProvidersByIdModels, deleteModelsModelByModelId } from '@memoh/sdk'
|
||||
import type { ModelsGetResponse, ProvidersGetResponse } from '@memoh/sdk'
|
||||
|
||||
// ---- Model 编辑状态(provide 给 CreateModel) ----
|
||||
const openModel = reactive<{
|
||||
state: boolean
|
||||
title: 'title' | 'edit'
|
||||
curState: ModelInfo | null
|
||||
curState: ModelsGetResponse | null
|
||||
}>({
|
||||
state: false,
|
||||
title: 'title',
|
||||
@@ -57,23 +51,61 @@ provide('openModel', toRef(openModel, 'state'))
|
||||
provide('openModelTitle', toRef(openModel, 'title'))
|
||||
provide('openModelState', toRef(openModel, 'curState'))
|
||||
|
||||
function handleEditModel(model: ModelInfo) {
|
||||
function handleEditModel(model: ModelsGetResponse) {
|
||||
openModel.state = true
|
||||
openModel.title = 'edit'
|
||||
openModel.curState = { ...model }
|
||||
}
|
||||
|
||||
// ---- 当前 Provider ----
|
||||
const curProvider = inject('curProvider', ref<Partial<ProviderInfo & { id: string }>>())
|
||||
const curProvider = inject('curProvider', ref<ProvidersGetResponse>())
|
||||
const curProviderId = computed(() => curProvider.value?.id)
|
||||
|
||||
// ---- API Hooks ----
|
||||
const { mutate: deleteProvider, isLoading: deleteLoading } = useDeleteProvider(curProviderId)
|
||||
const { mutate: changeProvider, isLoading: editLoading } = useUpdateProvider(curProviderId)
|
||||
const { mutate: deleteModel, isLoading: deleteModelLoading } = useDeleteModel()
|
||||
const { data: modelDataList, invalidate: invalidateModels } = useModelList(curProviderId)
|
||||
const queryCache = useQueryCache()
|
||||
|
||||
const { mutate: deleteProvider, isLoading: deleteLoading } = useMutation({
|
||||
mutation: async () => {
|
||||
if (!curProviderId.value) return
|
||||
await deleteProvidersById({ path: { id: curProviderId.value }, throwOnError: true })
|
||||
},
|
||||
onSettled: () => queryCache.invalidateQueries({ key: ['providers'] }),
|
||||
})
|
||||
|
||||
const { mutate: changeProvider, isLoading: editLoading } = useMutation({
|
||||
mutation: async (data: Record<string, unknown>) => {
|
||||
if (!curProviderId.value) return
|
||||
const { data: result } = await putProvidersById({
|
||||
path: { id: curProviderId.value },
|
||||
body: data as any,
|
||||
throwOnError: true,
|
||||
})
|
||||
return result
|
||||
},
|
||||
onSettled: () => queryCache.invalidateQueries({ key: ['providers'] }),
|
||||
})
|
||||
|
||||
const { mutate: deleteModel, isLoading: deleteModelLoading } = useMutation({
|
||||
mutation: async (modelName: string) => {
|
||||
await deleteModelsModelByModelId({ path: { modelId: modelName }, throwOnError: true })
|
||||
},
|
||||
onSettled: () => queryCache.invalidateQueries({ key: ['provider-models'] }),
|
||||
})
|
||||
|
||||
const { data: modelDataList } = useQuery({
|
||||
key: () => ['provider-models', curProviderId.value ?? ''],
|
||||
query: async () => {
|
||||
if (!curProviderId.value) return []
|
||||
const { data } = await getProvidersByIdModels({
|
||||
path: { id: curProviderId.value },
|
||||
throwOnError: true,
|
||||
})
|
||||
return data
|
||||
},
|
||||
enabled: () => !!curProviderId.value,
|
||||
})
|
||||
|
||||
watch(curProvider, () => {
|
||||
invalidateModels()
|
||||
queryCache.invalidateQueries({ key: ['provider-models'] })
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
@@ -7,25 +7,97 @@ if [ "$(uname -s)" = "Darwin" ]; then
|
||||
exit $?
|
||||
fi
|
||||
|
||||
if command -v containerd >/dev/null 2>&1 && command -v nerdctl >/dev/null 2>&1; then
|
||||
if command -v containerd >/dev/null 2>&1 && command -v nerdctl >/dev/null 2>&1 && command -v buildctl >/dev/null 2>&1 && command -v buildkitd >/dev/null 2>&1; then
|
||||
containerd --version
|
||||
nerdctl --version
|
||||
buildctl --version
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y containerd nerdctl
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
sudo dnf install -y containerd nerdctl
|
||||
elif command -v yum >/dev/null 2>&1; then
|
||||
sudo yum install -y containerd nerdctl
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
sudo apk add --no-cache containerd nerdctl
|
||||
else
|
||||
echo "No supported package manager found. Install containerd manually."
|
||||
exit 1
|
||||
if ! command -v containerd >/dev/null 2>&1; then
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y containerd
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
sudo dnf install -y containerd
|
||||
elif command -v yum >/dev/null 2>&1; then
|
||||
sudo yum install -y containerd
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
sudo apk add --no-cache containerd
|
||||
else
|
||||
echo "No supported package manager found. Install containerd manually."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! command -v nerdctl >/dev/null 2>&1 || ! command -v buildctl >/dev/null 2>&1 || ! command -v buildkitd >/dev/null 2>&1; then
|
||||
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
||||
ARCH="$(uname -m)"
|
||||
NERDCTL_VERSION="${NERDCTL_VERSION:-}"
|
||||
|
||||
if [ "$OS" != "linux" ]; then
|
||||
echo "Automatic nerdctl installation from release is only supported on Linux."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$ARCH" in
|
||||
x86_64|amd64)
|
||||
ARCH="amd64"
|
||||
;;
|
||||
aarch64|arm64)
|
||||
ARCH="arm64"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported architecture for nerdctl release: $ARCH"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -z "$NERDCTL_VERSION" ]; then
|
||||
RELEASES_API_URL="https://api.github.com/repos/containerd/nerdctl/releases/latest"
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
NERDCTL_VERSION="$(curl -fsSL "$RELEASES_API_URL" | sed -n 's/.*"tag_name":[[:space:]]*"v\{0,1\}\([^"]*\)".*/\1/p' | head -n1)"
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
NERDCTL_VERSION="$(wget -qO- "$RELEASES_API_URL" | sed -n 's/.*"tag_name":[[:space:]]*"v\{0,1\}\([^"]*\)".*/\1/p' | head -n1)"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$NERDCTL_VERSION" ]; then
|
||||
echo "Failed to detect latest nerdctl version. Set NERDCTL_VERSION manually."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NERDCTL_TARBALL="nerdctl-full-${NERDCTL_VERSION}-linux-${ARCH}.tar.gz"
|
||||
NERDCTL_URL="https://github.com/containerd/nerdctl/releases/download/v${NERDCTL_VERSION}/${NERDCTL_TARBALL}"
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
TMP_TARBALL="${TMP_DIR}/${NERDCTL_TARBALL}"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$TMP_DIR"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fsSL "$NERDCTL_URL" -o "$TMP_TARBALL"
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
wget -qO "$TMP_TARBALL" "$NERDCTL_URL"
|
||||
else
|
||||
echo "curl or wget is required to download nerdctl."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
tar -xzf "$TMP_TARBALL" -C "$TMP_DIR"
|
||||
sudo install -m 0755 "$TMP_DIR/bin/nerdctl" /usr/local/bin/nerdctl
|
||||
sudo install -m 0755 "$TMP_DIR/bin/buildctl" /usr/local/bin/buildctl
|
||||
sudo install -m 0755 "$TMP_DIR/bin/buildkitd" /usr/local/bin/buildkitd
|
||||
|
||||
if command -v systemctl >/dev/null 2>&1 && [ -f "$TMP_DIR/lib/systemd/system/buildkit.service" ]; then
|
||||
sudo install -m 0644 "$TMP_DIR/lib/systemd/system/buildkit.service" /etc/systemd/system/buildkit.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now buildkit.service || true
|
||||
fi
|
||||
fi
|
||||
|
||||
containerd --version
|
||||
nerdctl --version
|
||||
buildctl --version
|
||||
|
||||
@@ -4,7 +4,7 @@ set -e
|
||||
IMAGE="memoh-mcp:dev"
|
||||
|
||||
if [ "$(uname -s)" = "Darwin" ]; then
|
||||
limactl shell default -- nerdctl build -f cmd/mcp/Dockerfile -t "$IMAGE" .
|
||||
limactl shell default -- nerdctl build -f docker/Dockerfile.mcp -t "$IMAGE" .
|
||||
# Import into rootful containerd so the Go agent can find the image
|
||||
limactl shell default -- sh -c "nerdctl save $IMAGE | sudo nerdctl load"
|
||||
exit $?
|
||||
@@ -15,4 +15,4 @@ if ! command -v nerdctl >/dev/null 2>&1; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
nerdctl build -f cmd/mcp/Dockerfile -t "$IMAGE" .
|
||||
nerdctl build -f docker/Dockerfile.mcp -t "$IMAGE" .
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
[default.extend-words]
|
||||
|
||||
[files]
|
||||
extend-exclude = [
|
||||
"**/node_modules/**",
|
||||
|
||||
]
|
||||
Reference in New Issue
Block a user