Initial commit
This commit is contained in:
51
.gitignore
vendored
Normal file
51
.gitignore
vendored
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
standalone/index.html
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Go
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
go.work
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
*.env
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
docker-compose.override.yml
|
||||||
|
|
||||||
|
# Secrets
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
secrets/
|
||||||
115
README.md
Normal file
115
README.md
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# 🔀 Chat Switchboard
|
||||||
|
|
||||||
|
A sleek, OpenAI-compatible chat interface with per-conversation model switching, collapsible thinking blocks, and full chat history management.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Multi-Model Support**: Switch models per-chat via quick selector
|
||||||
|
- **Thinking Blocks**: Collapsible `<thinking>` tags with distinct styling
|
||||||
|
- **Streaming Responses**: Real-time token streaming with stop button
|
||||||
|
- **Chat History**: Persistent storage with export (Markdown/JSON/Text)
|
||||||
|
- **Keyboard Shortcuts**: `Enter` send, `Shift+Enter` newline, `Esc` stop, `Ctrl+B` sidebar, `Ctrl+,` settings
|
||||||
|
- **Code Highlighting**: Syntax-aware code blocks with copy buttons
|
||||||
|
- **Regenerate**: Re-run last response with one click
|
||||||
|
- **Dark Theme**: Easy on the eyes
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Standalone (No Build Required)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build the single-file version
|
||||||
|
./build.sh
|
||||||
|
|
||||||
|
# Open in browser
|
||||||
|
xdg-open standalone/index.html
|
||||||
|
# or
|
||||||
|
python3 -m http.server 8080 --directory standalone
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Serve src/ directory for development
|
||||||
|
python3 -m http.server 8080 --directory src
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
chat-switchboard/
|
||||||
|
├── build.sh # Builds standalone HTML
|
||||||
|
├── standalone/ # Built single-file output
|
||||||
|
│ └── index.html
|
||||||
|
├── src/ # Source files
|
||||||
|
│ ├── index.html
|
||||||
|
│ ├── css/
|
||||||
|
│ │ └── styles.css
|
||||||
|
│ └── js/
|
||||||
|
│ ├── storage.js # LocalStorage utilities
|
||||||
|
│ ├── state.js # State management
|
||||||
|
│ ├── api.js # API calls
|
||||||
|
│ ├── ui.js # UI rendering
|
||||||
|
│ └── app.js # Main application
|
||||||
|
├── server/ # Backend (future)
|
||||||
|
│ ├── main.go
|
||||||
|
│ ├── handlers/
|
||||||
|
│ ├── models/
|
||||||
|
│ └── middleware/
|
||||||
|
├── migrations/ # PostgreSQL schemas
|
||||||
|
│ └── 001_initial.sql
|
||||||
|
├── docker-compose.yml # Local dev environment
|
||||||
|
└── docs/ # Documentation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend (Planned)
|
||||||
|
|
||||||
|
The project is structured to support a full backend with:
|
||||||
|
|
||||||
|
- **Go server** with Gin/Echo
|
||||||
|
- **PostgreSQL** for persistent storage
|
||||||
|
- **User authentication** (optional)
|
||||||
|
- **API key management** per user
|
||||||
|
- **Chat syncing** across devices
|
||||||
|
|
||||||
|
### Database Schema
|
||||||
|
|
||||||
|
See `migrations/001_initial.sql` for the initial PostgreSQL schema.
|
||||||
|
|
||||||
|
### Running with Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Settings are stored in `localStorage`:
|
||||||
|
|
||||||
|
| Key | Description |
|
||||||
|
|-----|-------------|
|
||||||
|
| `chatSwitchboard_settings` | API endpoint, model, parameters |
|
||||||
|
| `chatSwitchboard_models` | Cached model list |
|
||||||
|
| `chatSwitchboard_chats` | Chat history |
|
||||||
|
|
||||||
|
## API Compatibility
|
||||||
|
|
||||||
|
Works with any OpenAI-compatible API:
|
||||||
|
|
||||||
|
- OpenAI
|
||||||
|
- Anthropic (via proxy)
|
||||||
|
- Ollama (`http://localhost:11434/v1`)
|
||||||
|
- LM Studio
|
||||||
|
- LocalAI
|
||||||
|
- vLLM
|
||||||
|
- Together AI
|
||||||
|
- OpenRouter
|
||||||
|
- And more...
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
|
||||||
|
Originally inspired by various chat interfaces, enhanced with multi-model switching and thinking block support.
|
||||||
85
build.sh
Executable file
85
build.sh
Executable file
@@ -0,0 +1,85 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ==========================================
|
||||||
|
# Chat Switchboard - Build Script
|
||||||
|
# ==========================================
|
||||||
|
# Combines src/ files into a single standalone HTML file
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
SRC_DIR="$SCRIPT_DIR/src"
|
||||||
|
OUTPUT_DIR="$SCRIPT_DIR/standalone"
|
||||||
|
OUTPUT_FILE="$OUTPUT_DIR/index.html"
|
||||||
|
|
||||||
|
echo "🔀 Building Chat Switchboard..."
|
||||||
|
|
||||||
|
# Ensure output directory exists
|
||||||
|
mkdir -p "$OUTPUT_DIR"
|
||||||
|
|
||||||
|
# Read source files
|
||||||
|
CSS_CONTENT=$(cat "$SRC_DIR/css/styles.css")
|
||||||
|
JS_STORAGE=$(cat "$SRC_DIR/js/storage.js")
|
||||||
|
JS_STATE=$(cat "$SRC_DIR/js/state.js")
|
||||||
|
JS_API=$(cat "$SRC_DIR/js/api.js")
|
||||||
|
JS_UI=$(cat "$SRC_DIR/js/ui.js")
|
||||||
|
JS_APP=$(cat "$SRC_DIR/js/app.js")
|
||||||
|
|
||||||
|
# Read HTML and extract head/body content
|
||||||
|
HTML_CONTENT=$(cat "$SRC_DIR/index.html")
|
||||||
|
|
||||||
|
# Create standalone file
|
||||||
|
cat > "$OUTPUT_FILE" << 'HTMLEOF'
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Chat Switchboard</title>
|
||||||
|
<style>
|
||||||
|
HTMLEOF
|
||||||
|
|
||||||
|
# Append CSS
|
||||||
|
echo "$CSS_CONTENT" >> "$OUTPUT_FILE"
|
||||||
|
|
||||||
|
cat >> "$OUTPUT_FILE" << 'HTMLEOF'
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
HTMLEOF
|
||||||
|
|
||||||
|
# Extract body content (between <body> and </body>, excluding script tags)
|
||||||
|
BODY_CONTENT=$(echo "$HTML_CONTENT" | sed -n '/<body>/,/<\/body>/p' | sed '1d;$d' | sed '/<script/,/<\/script>/d')
|
||||||
|
|
||||||
|
echo "$BODY_CONTENT" >> "$OUTPUT_FILE"
|
||||||
|
|
||||||
|
# Add combined JavaScript
|
||||||
|
cat >> "$OUTPUT_FILE" << 'HTMLEOF'
|
||||||
|
<script>
|
||||||
|
HTMLEOF
|
||||||
|
|
||||||
|
echo "$JS_STORAGE" >> "$OUTPUT_FILE"
|
||||||
|
echo "" >> "$OUTPUT_FILE"
|
||||||
|
echo "$JS_STATE" >> "$OUTPUT_FILE"
|
||||||
|
echo "" >> "$OUTPUT_FILE"
|
||||||
|
echo "$JS_API" >> "$OUTPUT_FILE"
|
||||||
|
echo "" >> "$OUTPUT_FILE"
|
||||||
|
echo "$JS_UI" >> "$OUTPUT_FILE"
|
||||||
|
echo "" >> "$OUTPUT_FILE"
|
||||||
|
echo "$JS_APP" >> "$OUTPUT_FILE"
|
||||||
|
|
||||||
|
cat >> "$OUTPUT_FILE" << 'HTMLEOF'
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
HTMLEOF
|
||||||
|
|
||||||
|
# Get file size
|
||||||
|
SIZE=$(wc -c < "$OUTPUT_FILE" | tr -d ' ')
|
||||||
|
SIZE_KB=$((SIZE / 1024))
|
||||||
|
|
||||||
|
echo "✅ Build complete!"
|
||||||
|
echo " Output: $OUTPUT_FILE"
|
||||||
|
echo " Size: ${SIZE_KB}KB ($SIZE bytes)"
|
||||||
|
echo ""
|
||||||
|
echo "📦 To use:"
|
||||||
|
echo " 1. Open standalone/index.html in a browser"
|
||||||
|
echo " 2. Or serve with: python3 -m http.server 8080"
|
||||||
58
docker-compose.yml
Normal file
58
docker-compose.yml
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# PostgreSQL database
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: chat-switchboard-db
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: switchboard
|
||||||
|
POSTGRES_PASSWORD: switchboard_dev
|
||||||
|
POSTGRES_DB: chat_switchboard
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
- ./migrations:/docker-entrypoint-initdb.d:ro
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U switchboard -d chat_switchboard"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# Adminer for database management (optional)
|
||||||
|
adminer:
|
||||||
|
image: adminer:latest
|
||||||
|
container_name: chat-switchboard-adminer
|
||||||
|
ports:
|
||||||
|
- "8081:8080"
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
|
||||||
|
# Backend API (uncomment when implemented)
|
||||||
|
# api:
|
||||||
|
# build:
|
||||||
|
# context: ./server
|
||||||
|
# dockerfile: Dockerfile
|
||||||
|
# container_name: chat-switchboard-api
|
||||||
|
# environment:
|
||||||
|
# DATABASE_URL: postgres://switchboard:switchboard_dev@postgres:5432/chat_switchboard?sslmode=disable
|
||||||
|
# PORT: 8080
|
||||||
|
# JWT_SECRET: your-secret-key-change-in-production
|
||||||
|
# ports:
|
||||||
|
# - "8080:8080"
|
||||||
|
# depends_on:
|
||||||
|
# postgres:
|
||||||
|
# condition: service_healthy
|
||||||
|
|
||||||
|
# Frontend dev server (optional)
|
||||||
|
frontend:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: chat-switchboard-frontend
|
||||||
|
volumes:
|
||||||
|
- ./standalone:/usr/share/nginx/html:ro
|
||||||
|
ports:
|
||||||
|
- "3000:80"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
829
frontend/css/styles.css
Normal file
829
frontend/css/styles.css
Normal file
@@ -0,0 +1,829 @@
|
|||||||
|
/* ==========================================
|
||||||
|
Chat Switchboard - Styles
|
||||||
|
========================================== */
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-primary: #212121;
|
||||||
|
--bg-secondary: #171717;
|
||||||
|
--bg-tertiary: #2f2f2f;
|
||||||
|
--text-primary: #ececec;
|
||||||
|
--text-secondary: #b4b4b4;
|
||||||
|
--accent: #10a37f;
|
||||||
|
--accent-hover: #0d8a6a;
|
||||||
|
--border: #3a3a3a;
|
||||||
|
--code-bg: #1e1e1e;
|
||||||
|
--error: #ff6b6b;
|
||||||
|
--success: #10a37f;
|
||||||
|
--warning: #ffd93d;
|
||||||
|
--thinking-bg: #2a2a3a;
|
||||||
|
--thinking-border: #4a4a6a;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================
|
||||||
|
Header
|
||||||
|
========================================== */
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================
|
||||||
|
Buttons
|
||||||
|
========================================== */
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary { background: var(--accent); color: white; }
|
||||||
|
.btn-primary:hover { background: var(--accent-hover); }
|
||||||
|
.btn-secondary { background: var(--bg-tertiary); color: var(--text-primary); border: 1px solid var(--border); }
|
||||||
|
.btn-secondary:hover { background: var(--border); }
|
||||||
|
.btn-danger { background: transparent; color: var(--error); border: 1px solid var(--error); }
|
||||||
|
.btn-danger:hover { background: var(--error); color: white; }
|
||||||
|
.btn-small { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
|
||||||
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
|
||||||
|
/* ==========================================
|
||||||
|
Layout
|
||||||
|
========================================== */
|
||||||
|
|
||||||
|
.main-container {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 260px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed { width: 0; overflow: hidden; }
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-chat-btn { margin: 1rem; width: calc(100% - 2rem); }
|
||||||
|
|
||||||
|
.chat-history { flex: 1; overflow-y: auto; padding: 0.5rem; }
|
||||||
|
|
||||||
|
.chat-history-item {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-history-item:hover { background: var(--bg-tertiary); }
|
||||||
|
.chat-history-item.active { background: var(--bg-tertiary); border-left: 3px solid var(--accent); }
|
||||||
|
|
||||||
|
.chat-history-item .title {
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-history-item .item-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-history-item:hover .item-actions { opacity: 1; }
|
||||||
|
|
||||||
|
.chat-history-item .item-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-history-item .item-btn:hover { color: var(--text-primary); background: rgba(255,255,255,0.1); }
|
||||||
|
.chat-history-item .item-btn.delete:hover { color: var(--error); background: rgba(255, 107, 107, 0.1); }
|
||||||
|
|
||||||
|
.chat-area {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ==========================================
|
||||||
|
Messages
|
||||||
|
========================================== */
|
||||||
|
|
||||||
|
.message {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto 1.5rem;
|
||||||
|
animation: fadeIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-avatar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.user .message-avatar { background: var(--accent); }
|
||||||
|
.message.assistant .message-avatar { background: linear-gradient(135deg, #6366f1, #8b5cf6); }
|
||||||
|
|
||||||
|
.message-body { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
.message-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-role {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-time {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message:hover .message-actions { opacity: 1; }
|
||||||
|
|
||||||
|
.message-action-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-action-btn:hover { color: var(--text-primary); background: var(--bg-tertiary); }
|
||||||
|
|
||||||
|
.message-text {
|
||||||
|
line-height: 1.6;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-text p { margin-bottom: 0.75rem; }
|
||||||
|
.message-text p:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.message-text code {
|
||||||
|
background: var(--code-bg);
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'Fira Code', 'Monaco', 'Consolas', monospace;
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-text pre {
|
||||||
|
background: var(--code-bg);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-text pre code { background: none; padding: 0; }
|
||||||
|
|
||||||
|
.copy-code-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-text pre:hover .copy-code-btn { opacity: 1; }
|
||||||
|
.copy-code-btn:hover { background: var(--border); color: var(--text-primary); }
|
||||||
|
|
||||||
|
|
||||||
|
/* ==========================================
|
||||||
|
Thinking Blocks (Collapsible)
|
||||||
|
========================================== */
|
||||||
|
|
||||||
|
.thinking-block {
|
||||||
|
background: var(--thinking-bg);
|
||||||
|
border: 1px solid var(--thinking-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: rgba(74, 74, 106, 0.3);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-header:hover {
|
||||||
|
background: rgba(74, 74, 106, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-toggle {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-block.collapsed .thinking-toggle {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #a0a0d0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-content {
|
||||||
|
padding: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-top: 1px solid var(--thinking-border);
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
transition: max-height 0.3s ease, padding 0.3s ease, opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-block.collapsed .thinking-content {
|
||||||
|
max-height: 0;
|
||||||
|
padding: 0 1rem;
|
||||||
|
opacity: 0;
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================
|
||||||
|
Typing Indicator
|
||||||
|
========================================== */
|
||||||
|
|
||||||
|
.typing-indicator {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator span {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: var(--text-secondary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: bounce 1.4s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator span:nth-child(1) { animation-delay: -0.32s; }
|
||||||
|
.typing-indicator span:nth-child(2) { animation-delay: -0.16s; }
|
||||||
|
|
||||||
|
@keyframes bounce {
|
||||||
|
0%, 80%, 100% { transform: scale(0); }
|
||||||
|
40% { transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ==========================================
|
||||||
|
Input Area
|
||||||
|
========================================== */
|
||||||
|
|
||||||
|
.input-area {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-container { max-width: 800px; margin: 0 auto; }
|
||||||
|
|
||||||
|
.input-top-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-selector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-selector label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-selector select {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-selector select:focus { outline: none; border-color: var(--accent); }
|
||||||
|
|
||||||
|
.input-shortcuts {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-shortcuts kbd {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper:focus-within { border-color: var(--accent); }
|
||||||
|
|
||||||
|
.input-wrapper textarea {
|
||||||
|
width: 100%;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: inherit;
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
line-height: 1.5;
|
||||||
|
max-height: 200px;
|
||||||
|
min-height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper textarea::placeholder { color: var(--text-secondary); }
|
||||||
|
|
||||||
|
.input-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-left { display: flex; gap: 0.5rem; align-items: center; }
|
||||||
|
|
||||||
|
.send-btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn:hover:not(:disabled) { background: var(--accent-hover); }
|
||||||
|
.send-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.stop-btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: var(--error);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stop-btn:hover { background: #ff5252; }
|
||||||
|
.stop-btn.visible { display: flex; }
|
||||||
|
|
||||||
|
|
||||||
|
/* ==========================================
|
||||||
|
Modal
|
||||||
|
========================================== */
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay.active { display: flex; }
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 600px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
animation: modalSlideIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modalSlideIn {
|
||||||
|
from { opacity: 0; transform: scale(0.9); }
|
||||||
|
to { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 { font-size: 1.25rem; font-weight: 600; }
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover { color: var(--text-primary); background: var(--bg-tertiary); }
|
||||||
|
|
||||||
|
.modal-body { padding: 1.5rem; }
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================
|
||||||
|
Forms
|
||||||
|
========================================== */
|
||||||
|
|
||||||
|
.form-group { margin-bottom: 1.5rem; }
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select,
|
||||||
|
.form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus,
|
||||||
|
.form-group textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label { font-weight: 500; }
|
||||||
|
|
||||||
|
.toggle-switch {
|
||||||
|
position: relative;
|
||||||
|
width: 50px;
|
||||||
|
height: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch input { opacity: 0; width: 0; height: 0; }
|
||||||
|
|
||||||
|
.toggle-slider {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 26px;
|
||||||
|
transition: 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-slider:before {
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
height: 20px;
|
||||||
|
width: 20px;
|
||||||
|
left: 3px;
|
||||||
|
bottom: 3px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch input:checked + .toggle-slider { background: var(--accent); }
|
||||||
|
.toggle-switch input:checked + .toggle-slider:before { transform: translateX(24px); }
|
||||||
|
|
||||||
|
|
||||||
|
/* ==========================================
|
||||||
|
Toast Notifications
|
||||||
|
========================================== */
|
||||||
|
|
||||||
|
.toast-container {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 2rem;
|
||||||
|
right: 2rem;
|
||||||
|
z-index: 2000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
animation: slideIn 0.3s ease;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from { opacity: 0; transform: translateX(100%); }
|
||||||
|
to { opacity: 1; transform: translateX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.success { border-left: 3px solid var(--success); }
|
||||||
|
.toast.error { border-left: 3px solid var(--error); }
|
||||||
|
.toast.warning { border-left: 3px solid var(--warning); }
|
||||||
|
|
||||||
|
.toast-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================
|
||||||
|
Dropdown
|
||||||
|
========================================== */
|
||||||
|
|
||||||
|
.dropdown {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-content {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 100%;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
min-width: 160px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-content.show { display: block; }
|
||||||
|
|
||||||
|
.dropdown-item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item:hover { background: var(--bg-tertiary); }
|
||||||
|
.dropdown-item:first-child { border-radius: 8px 8px 0 0; }
|
||||||
|
.dropdown-item:last-child { border-radius: 0 0 8px 8px; }
|
||||||
|
|
||||||
|
/* ==========================================
|
||||||
|
Empty State
|
||||||
|
========================================== */
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-icon svg { width: 40px; height: 40px; stroke: var(--accent); }
|
||||||
|
.empty-state h2 { font-size: 1.5rem; margin-bottom: 0.5rem; }
|
||||||
|
.empty-state p { color: var(--text-secondary); max-width: 400px; }
|
||||||
|
|
||||||
|
/* ==========================================
|
||||||
|
Responsive
|
||||||
|
========================================== */
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0; top: 0; bottom: 0;
|
||||||
|
z-index: 100;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
.sidebar.open { transform: translateX(0); }
|
||||||
|
.header { padding: 1rem; }
|
||||||
|
.chat-messages { padding: 1rem; }
|
||||||
|
.input-area { padding: 1rem; }
|
||||||
|
.form-row { grid-template-columns: 1fr; }
|
||||||
|
.input-top-bar { flex-direction: column; align-items: flex-start; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================
|
||||||
|
Scrollbar
|
||||||
|
========================================== */
|
||||||
|
|
||||||
|
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||||
|
::-webkit-scrollbar-track { background: var(--bg-secondary); }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: var(--text-secondary); }
|
||||||
98
frontend/js/api.js
Normal file
98
frontend/js/api.js
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* Chat Switchboard - API Module
|
||||||
|
* Handles communication with LLM backends
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API = {
|
||||||
|
/**
|
||||||
|
* Fetch available models from endpoint
|
||||||
|
*/
|
||||||
|
async fetchModels(endpoint, apiKey) {
|
||||||
|
const response = await fetch(endpoint.replace(/\/$/, '') + '/models', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`API Error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const models = data.data || data.models || data || [];
|
||||||
|
|
||||||
|
return Array.isArray(models)
|
||||||
|
? models.map(m => ({
|
||||||
|
id: m.id || m.name || m,
|
||||||
|
owned_by: m.owned_by || null
|
||||||
|
})).sort((a, b) => a.id.localeCompare(b.id))
|
||||||
|
: [];
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send chat completion request
|
||||||
|
*/
|
||||||
|
async sendMessage(settings, messages, onChunk = null) {
|
||||||
|
const endpoint = settings.apiEndpoint.replace(/\/$/, '') + '/chat/completions';
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${settings.apiKey}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: settings.model,
|
||||||
|
messages: messages.map(m => ({ role: m.role, content: m.content })),
|
||||||
|
max_tokens: settings.maxTokens,
|
||||||
|
temperature: settings.temperature,
|
||||||
|
top_p: settings.topP,
|
||||||
|
presence_penalty: settings.presencePenalty,
|
||||||
|
stream: settings.stream
|
||||||
|
}),
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.text();
|
||||||
|
throw new Error(`API Error: ${response.status} - ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = '';
|
||||||
|
|
||||||
|
if (settings.stream && onChunk) {
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
const chunk = decoder.decode(value);
|
||||||
|
const lines = chunk.split('\n');
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
const data = line.slice(6);
|
||||||
|
if (data === '[DONE]') continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
const delta = parsed.choices?.[0]?.delta?.content || '';
|
||||||
|
content += delta;
|
||||||
|
onChunk(content, delta);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const data = await response.json();
|
||||||
|
content = data.choices?.[0]?.message?.content || 'No response';
|
||||||
|
}
|
||||||
|
|
||||||
|
return { content, controller };
|
||||||
|
}
|
||||||
|
};
|
||||||
108
frontend/js/storage.js
Normal file
108
frontend/js/storage.js
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Chat Switchboard - Storage Module
|
||||||
|
* Abstraction layer for storage (localStorage now, API later)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const Storage = {
|
||||||
|
// Backend mode: 'local' or 'api'
|
||||||
|
mode: 'local',
|
||||||
|
apiBase: '/api',
|
||||||
|
|
||||||
|
async get(key, defaultValue = null) {
|
||||||
|
if (this.mode === 'api') {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.apiBase}/storage/${key}`);
|
||||||
|
if (response.ok) {
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Storage API get error:', e);
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local storage
|
||||||
|
try {
|
||||||
|
const item = localStorage.getItem(key);
|
||||||
|
return item ? JSON.parse(item) : defaultValue;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Storage get error:', e);
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async set(key, value) {
|
||||||
|
if (this.mode === 'api') {
|
||||||
|
try {
|
||||||
|
await fetch(`${this.apiBase}/storage/${key}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(value)
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Storage API set error:', e);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local storage
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, JSON.stringify(value));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Storage set error:', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async remove(key) {
|
||||||
|
if (this.mode === 'api') {
|
||||||
|
try {
|
||||||
|
await fetch(`${this.apiBase}/storage/${key}`, { method: 'DELETE' });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Storage API remove error:', e);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Storage remove error:', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Switch to API mode
|
||||||
|
useApi(baseUrl = '/api') {
|
||||||
|
this.mode = 'api';
|
||||||
|
this.apiBase = baseUrl;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Switch to local mode
|
||||||
|
useLocal() {
|
||||||
|
this.mode = 'local';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// For standalone HTML, use sync versions
|
||||||
|
const StorageSync = {
|
||||||
|
get(key, defaultValue = null) {
|
||||||
|
try {
|
||||||
|
const item = localStorage.getItem(key);
|
||||||
|
return item ? JSON.parse(item) : defaultValue;
|
||||||
|
} catch (e) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
set(key, value) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, JSON.stringify(value));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Storage set error:', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
remove(key) {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
};
|
||||||
125
migrations/001_initial.sql
Normal file
125
migrations/001_initial.sql
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
-- ==========================================
|
||||||
|
-- Chat Switchboard - Initial Schema
|
||||||
|
-- ==========================================
|
||||||
|
-- PostgreSQL migration for backend support
|
||||||
|
|
||||||
|
-- Enable UUID extension
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
-- Users table (optional, for multi-user support)
|
||||||
|
CREATE TABLE users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
email VARCHAR(255) UNIQUE,
|
||||||
|
password_hash VARCHAR(255),
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- API configurations per user
|
||||||
|
CREATE TABLE api_configs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(100) NOT NULL DEFAULT 'default',
|
||||||
|
endpoint VARCHAR(500) NOT NULL,
|
||||||
|
api_key_encrypted TEXT NOT NULL,
|
||||||
|
is_default BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
UNIQUE(user_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- User settings
|
||||||
|
CREATE TABLE user_settings (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID UNIQUE REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
default_model VARCHAR(100) DEFAULT 'gpt-3.5-turbo',
|
||||||
|
stream_responses BOOLEAN DEFAULT TRUE,
|
||||||
|
show_thinking BOOLEAN DEFAULT TRUE,
|
||||||
|
system_prompt TEXT,
|
||||||
|
max_tokens INTEGER DEFAULT 4096,
|
||||||
|
temperature DECIMAL(3,2) DEFAULT 0.7,
|
||||||
|
top_p DECIMAL(3,2) DEFAULT 1.0,
|
||||||
|
presence_penalty DECIMAL(3,2) DEFAULT 0.0,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Chats
|
||||||
|
CREATE TABLE chats (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
model VARCHAR(100),
|
||||||
|
api_config_id UUID REFERENCES api_configs(id) ON DELETE SET NULL,
|
||||||
|
is_archived BOOLEAN DEFAULT FALSE,
|
||||||
|
is_pinned BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Messages
|
||||||
|
CREATE TABLE messages (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
|
||||||
|
role VARCHAR(20) NOT NULL CHECK (role IN ('system', 'user', 'assistant')),
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
model VARCHAR(100),
|
||||||
|
tokens_used INTEGER,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Cached models per API config
|
||||||
|
CREATE TABLE cached_models (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
api_config_id UUID NOT NULL REFERENCES api_configs(id) ON DELETE CASCADE,
|
||||||
|
model_id VARCHAR(100) NOT NULL,
|
||||||
|
owned_by VARCHAR(100),
|
||||||
|
cached_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
UNIQUE(api_config_id, model_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for performance
|
||||||
|
CREATE INDEX idx_chats_user_id ON chats(user_id);
|
||||||
|
CREATE INDEX idx_chats_updated_at ON chats(updated_at DESC);
|
||||||
|
CREATE INDEX idx_messages_chat_id ON messages(chat_id);
|
||||||
|
CREATE INDEX idx_messages_created_at ON messages(created_at);
|
||||||
|
CREATE INDEX idx_api_configs_user_id ON api_configs(user_id);
|
||||||
|
|
||||||
|
-- Updated at trigger function
|
||||||
|
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ language 'plpgsql';
|
||||||
|
|
||||||
|
-- Apply updated_at triggers
|
||||||
|
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER update_api_configs_updated_at BEFORE UPDATE ON api_configs
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER update_user_settings_updated_at BEFORE UPDATE ON user_settings
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER update_chats_updated_at BEFORE UPDATE ON chats
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- View for chat list with message count
|
||||||
|
CREATE VIEW chat_list AS
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
c.user_id,
|
||||||
|
c.title,
|
||||||
|
c.model,
|
||||||
|
c.is_archived,
|
||||||
|
c.is_pinned,
|
||||||
|
c.created_at,
|
||||||
|
c.updated_at,
|
||||||
|
COUNT(m.id) as message_count,
|
||||||
|
MAX(m.created_at) as last_message_at
|
||||||
|
FROM chats c
|
||||||
|
LEFT JOIN messages m ON m.chat_id = c.id
|
||||||
|
GROUP BY c.id;
|
||||||
107
server/main.go
Normal file
107
server/main.go
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Load .env file if present
|
||||||
|
godotenv.Load()
|
||||||
|
|
||||||
|
// Get port from environment
|
||||||
|
port := os.Getenv("PORT")
|
||||||
|
if port == "" {
|
||||||
|
port = "8080"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize router
|
||||||
|
r := gin.Default()
|
||||||
|
|
||||||
|
// CORS middleware
|
||||||
|
r.Use(func(c *gin.Context) {
|
||||||
|
c.Header("Access-Control-Allow-Origin", "*")
|
||||||
|
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
|
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||||
|
if c.Request.Method == "OPTIONS" {
|
||||||
|
c.AbortWithStatus(204)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
r.GET("/health", func(c *gin.Context) {
|
||||||
|
c.JSON(200, gin.H{"status": "ok"})
|
||||||
|
})
|
||||||
|
|
||||||
|
// API routes
|
||||||
|
api := r.Group("/api/v1")
|
||||||
|
{
|
||||||
|
// Auth routes
|
||||||
|
api.POST("/auth/register", handleRegister)
|
||||||
|
api.POST("/auth/login", handleLogin)
|
||||||
|
|
||||||
|
// Protected routes
|
||||||
|
protected := api.Group("")
|
||||||
|
protected.Use(authMiddleware())
|
||||||
|
{
|
||||||
|
// Chats
|
||||||
|
protected.GET("/chats", handleListChats)
|
||||||
|
protected.POST("/chats", handleCreateChat)
|
||||||
|
protected.GET("/chats/:id", handleGetChat)
|
||||||
|
protected.PUT("/chats/:id", handleUpdateChat)
|
||||||
|
protected.DELETE("/chats/:id", handleDeleteChat)
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
protected.GET("/chats/:id/messages", handleGetMessages)
|
||||||
|
protected.POST("/chats/:id/messages", handleCreateMessage)
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
protected.GET("/settings", handleGetSettings)
|
||||||
|
protected.PUT("/settings", handleUpdateSettings)
|
||||||
|
|
||||||
|
// API Configs
|
||||||
|
protected.GET("/api-configs", handleListAPIConfigs)
|
||||||
|
protected.POST("/api-configs", handleCreateAPIConfig)
|
||||||
|
protected.DELETE("/api-configs/:id", handleDeleteAPIConfig)
|
||||||
|
|
||||||
|
// Models (proxy to configured API)
|
||||||
|
protected.GET("/models", handleListModels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("🔀 Chat Switchboard API starting on port %s", port)
|
||||||
|
r.Run(":" + port)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Placeholder handlers - implement in handlers/ package
|
||||||
|
func handleRegister(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
||||||
|
func handleLogin(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
||||||
|
func handleListChats(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
||||||
|
func handleCreateChat(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
||||||
|
func handleGetChat(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
||||||
|
func handleUpdateChat(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
||||||
|
func handleDeleteChat(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
||||||
|
func handleGetMessages(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
||||||
|
func handleCreateMessage(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
||||||
|
func handleGetSettings(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
||||||
|
func handleUpdateSettings(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
||||||
|
func handleListAPIConfigs(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
||||||
|
func handleCreateAPIConfig(c *gin.Context) {
|
||||||
|
c.JSON(501, gin.H{"error": "not implemented"})
|
||||||
|
}
|
||||||
|
func handleDeleteAPIConfig(c *gin.Context) {
|
||||||
|
c.JSON(501, gin.H{"error": "not implemented"})
|
||||||
|
}
|
||||||
|
func handleListModels(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
||||||
|
|
||||||
|
func authMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
// TODO: Implement JWT validation
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
783
src/css/styles.css
Normal file
783
src/css/styles.css
Normal file
@@ -0,0 +1,783 @@
|
|||||||
|
/* ==========================================
|
||||||
|
Chat Switchboard - Styles
|
||||||
|
========================================== */
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-primary: #212121;
|
||||||
|
--bg-secondary: #171717;
|
||||||
|
--bg-tertiary: #2f2f2f;
|
||||||
|
--text-primary: #ececec;
|
||||||
|
--text-secondary: #b4b4b4;
|
||||||
|
--accent: #10a37f;
|
||||||
|
--accent-hover: #0d8a6a;
|
||||||
|
--border: #3a3a3a;
|
||||||
|
--code-bg: #1e1e1e;
|
||||||
|
--error: #ff6b6b;
|
||||||
|
--success: #10a37f;
|
||||||
|
--warning: #ffd93d;
|
||||||
|
--thinking-bg: #1a1a2e;
|
||||||
|
--thinking-border: #4a4a6a;
|
||||||
|
--thinking-text: #a0a0c0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.header {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary { background: var(--accent); color: white; }
|
||||||
|
.btn-primary:hover { background: var(--accent-hover); }
|
||||||
|
.btn-secondary { background: var(--bg-tertiary); color: var(--text-primary); border: 1px solid var(--border); }
|
||||||
|
.btn-secondary:hover { background: var(--border); }
|
||||||
|
.btn-danger { background: transparent; color: var(--error); border: 1px solid var(--error); }
|
||||||
|
.btn-danger:hover { background: var(--error); color: white; }
|
||||||
|
.btn-small { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
|
||||||
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
.main-container {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
.sidebar {
|
||||||
|
width: 260px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed { width: 0; overflow: hidden; }
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-chat-btn { margin: 1rem; width: calc(100% - 2rem); }
|
||||||
|
|
||||||
|
.chat-history { flex: 1; overflow-y: auto; padding: 0.5rem; }
|
||||||
|
|
||||||
|
.chat-history-item {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-history-item:hover { background: var(--bg-tertiary); }
|
||||||
|
.chat-history-item.active { background: var(--bg-tertiary); border-left: 3px solid var(--accent); }
|
||||||
|
|
||||||
|
.chat-history-item .title {
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-history-item .item-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-history-item:hover .item-actions { opacity: 1; }
|
||||||
|
|
||||||
|
.chat-history-item .item-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-history-item .item-btn:hover { color: var(--text-primary); background: rgba(255,255,255,0.1); }
|
||||||
|
.chat-history-item .item-btn.delete:hover { color: var(--error); background: rgba(255, 107, 107, 0.1); }
|
||||||
|
|
||||||
|
/* Chat Area */
|
||||||
|
.chat-area {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Messages */
|
||||||
|
.message {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto 1.5rem;
|
||||||
|
animation: fadeIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-avatar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.user .message-avatar { background: var(--accent); }
|
||||||
|
.message.assistant .message-avatar { background: linear-gradient(135deg, #6366f1, #8b5cf6); }
|
||||||
|
|
||||||
|
.message-body { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
.message-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-role {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-time {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message:hover .message-actions { opacity: 1; }
|
||||||
|
|
||||||
|
.message-action-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-action-btn:hover { color: var(--text-primary); background: var(--bg-tertiary); }
|
||||||
|
|
||||||
|
.message-text {
|
||||||
|
line-height: 1.6;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-text p { margin-bottom: 0.75rem; }
|
||||||
|
.message-text p:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
/* Code blocks */
|
||||||
|
.message-text code {
|
||||||
|
background: var(--code-bg);
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'Fira Code', 'Monaco', 'Consolas', monospace;
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-text pre {
|
||||||
|
background: var(--code-bg);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-text pre code { background: none; padding: 0; }
|
||||||
|
|
||||||
|
.copy-code-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-text pre:hover .copy-code-btn { opacity: 1; }
|
||||||
|
.copy-code-btn:hover { background: var(--border); color: var(--text-primary); }
|
||||||
|
|
||||||
|
/* ==========================================
|
||||||
|
THINKING BLOCKS - Collapsible & Styled
|
||||||
|
========================================== */
|
||||||
|
.thinking-block {
|
||||||
|
background: var(--thinking-bg);
|
||||||
|
border: 1px solid var(--thinking-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: rgba(74, 74, 106, 0.3);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-header:hover {
|
||||||
|
background: rgba(74, 74, 106, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-toggle {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
color: var(--thinking-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-block.collapsed .thinking-toggle {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--thinking-text);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-content {
|
||||||
|
padding: 1rem;
|
||||||
|
color: var(--thinking-text);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
transition: max-height 0.3s ease, padding 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-block.collapsed .thinking-content {
|
||||||
|
max-height: 0;
|
||||||
|
padding: 0 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Typing indicator */
|
||||||
|
.typing-indicator {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator span {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: var(--text-secondary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: bounce 1.4s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator span:nth-child(1) { animation-delay: -0.32s; }
|
||||||
|
.typing-indicator span:nth-child(2) { animation-delay: -0.16s; }
|
||||||
|
|
||||||
|
@keyframes bounce {
|
||||||
|
0%, 80%, 100% { transform: scale(0); }
|
||||||
|
40% { transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input Area */
|
||||||
|
.input-area {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-container { max-width: 800px; margin: 0 auto; }
|
||||||
|
|
||||||
|
.input-top-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-selector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-selector label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-selector select {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-selector select:focus { outline: none; border-color: var(--accent); }
|
||||||
|
|
||||||
|
.input-shortcuts {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-shortcuts kbd {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper:focus-within { border-color: var(--accent); }
|
||||||
|
|
||||||
|
.input-wrapper textarea {
|
||||||
|
width: 100%;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: inherit;
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
line-height: 1.5;
|
||||||
|
max-height: 200px;
|
||||||
|
min-height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper textarea::placeholder { color: var(--text-secondary); }
|
||||||
|
|
||||||
|
.input-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-left { display: flex; gap: 0.5rem; align-items: center; }
|
||||||
|
|
||||||
|
.send-btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn:hover:not(:disabled) { background: var(--accent-hover); }
|
||||||
|
.send-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.stop-btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: var(--error);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stop-btn:hover { background: #ff5252; }
|
||||||
|
.stop-btn.visible { display: flex; }
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay.active { display: flex; }
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 600px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
animation: modalSlideIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modalSlideIn {
|
||||||
|
from { opacity: 0; transform: scale(0.9); }
|
||||||
|
to { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 { font-size: 1.25rem; font-weight: 600; }
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover { color: var(--text-primary); background: var(--bg-tertiary); }
|
||||||
|
|
||||||
|
.modal-body { padding: 1.5rem; }
|
||||||
|
|
||||||
|
.form-group { margin-bottom: 1.5rem; }
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select,
|
||||||
|
.form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus,
|
||||||
|
.form-group textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label { font-weight: 500; }
|
||||||
|
|
||||||
|
.toggle-switch {
|
||||||
|
position: relative;
|
||||||
|
width: 50px;
|
||||||
|
height: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch input { opacity: 0; width: 0; height: 0; }
|
||||||
|
|
||||||
|
.toggle-slider {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 26px;
|
||||||
|
transition: 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-slider:before {
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
height: 20px;
|
||||||
|
width: 20px;
|
||||||
|
left: 3px;
|
||||||
|
bottom: 3px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch input:checked + .toggle-slider { background: var(--accent); }
|
||||||
|
.toggle-switch input:checked + .toggle-slider:before { transform: translateX(24px); }
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toast */
|
||||||
|
.toast-container {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 2rem;
|
||||||
|
right: 2rem;
|
||||||
|
z-index: 2000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
animation: slideIn 0.3s ease;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from { opacity: 0; transform: translateX(100%); }
|
||||||
|
to { opacity: 1; transform: translateX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.success { border-left: 3px solid var(--success); }
|
||||||
|
.toast.error { border-left: 3px solid var(--error); }
|
||||||
|
.toast.warning { border-left: 3px solid var(--warning); }
|
||||||
|
|
||||||
|
.toast-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty State */
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-icon svg { width: 40px; height: 40px; stroke: var(--accent); }
|
||||||
|
.empty-state h2 { font-size: 1.5rem; margin-bottom: 0.5rem; }
|
||||||
|
.empty-state p { color: var(--text-secondary); max-width: 400px; }
|
||||||
|
|
||||||
|
/* Dropdown */
|
||||||
|
.dropdown {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-content {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 100%;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
min-width: 160px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-content.show { display: block; }
|
||||||
|
|
||||||
|
.dropdown-item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item:hover { background: var(--bg-tertiary); }
|
||||||
|
.dropdown-item:first-child { border-radius: 8px 8px 0 0; }
|
||||||
|
.dropdown-item:last-child { border-radius: 0 0 8px 8px; }
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0; top: 0; bottom: 0;
|
||||||
|
z-index: 100;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
.sidebar.open { transform: translateX(0); }
|
||||||
|
.header { padding: 1rem; }
|
||||||
|
.chat-messages { padding: 1rem; }
|
||||||
|
.input-area { padding: 1rem; }
|
||||||
|
.form-row { grid-template-columns: 1fr; }
|
||||||
|
.input-top-bar { flex-direction: column; align-items: flex-start; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||||
|
::-webkit-scrollbar-track { background: var(--bg-secondary); }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: var(--text-secondary); }
|
||||||
206
src/index.html
Normal file
206
src/index.html
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Chat Switchboard</title>
|
||||||
|
<link rel="stylesheet" href="css/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="header">
|
||||||
|
<h1>🔀 Chat Switchboard</h1>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="btn btn-secondary" id="toggleSidebarBtn" title="Toggle Sidebar (Ctrl+B)">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||||
|
<line x1="9" y1="3" x2="9" y2="21"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="btn btn-secondary" id="exportBtn" title="Export Chat">
|
||||||
|
📥 Export
|
||||||
|
</button>
|
||||||
|
<div class="dropdown-content" id="exportDropdown">
|
||||||
|
<button class="dropdown-item" onclick="exportChat('markdown')">📄 Markdown</button>
|
||||||
|
<button class="dropdown-item" onclick="exportChat('json')">📋 JSON</button>
|
||||||
|
<button class="dropdown-item" onclick="exportChat('text')">📝 Plain Text</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-secondary" id="settingsBtn" title="Settings (Ctrl+,)">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="3"></circle>
|
||||||
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||||
|
</svg>
|
||||||
|
Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="main-container">
|
||||||
|
<aside class="sidebar" id="sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<span class="sidebar-title">Chat History</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary new-chat-btn" id="newChatBtn">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||||
|
</svg>
|
||||||
|
New Chat
|
||||||
|
</button>
|
||||||
|
<div class="chat-history" id="chatHistory"></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="chat-area">
|
||||||
|
<div class="chat-messages" id="chatMessages">
|
||||||
|
<div class="empty-state" id="emptyState">
|
||||||
|
<div class="empty-state-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2>Start a Conversation</h2>
|
||||||
|
<p>Configure your API settings and start chatting</p>
|
||||||
|
<button class="btn btn-primary" style="margin-top: 1.5rem;" id="configureBtn">⚙️ Configure API</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-area">
|
||||||
|
<div class="input-container">
|
||||||
|
<div class="input-top-bar">
|
||||||
|
<div class="model-selector">
|
||||||
|
<label for="quickModel">Model:</label>
|
||||||
|
<select id="quickModel">
|
||||||
|
<option value="">-- Configure in Settings --</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-small btn-secondary" id="quickFetchBtn" title="Refresh models">🔄</button>
|
||||||
|
</div>
|
||||||
|
<div class="input-shortcuts">
|
||||||
|
<kbd>Enter</kbd> send · <kbd>Shift+Enter</kbd> newline · <kbd>Esc</kbd> stop
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<textarea id="messageInput" placeholder="Type your message..." rows="1"></textarea>
|
||||||
|
<div class="input-actions">
|
||||||
|
<div class="input-left">
|
||||||
|
<button class="btn btn-small btn-secondary" id="regenerateBtn" title="Regenerate last response" style="display: none;">🔄 Regenerate</button>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
|
<button class="stop-btn" id="stopBtn">⏹ Stop</button>
|
||||||
|
<button class="send-btn" id="sendBtn">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="22" y1="2" x2="11" y2="13"></line>
|
||||||
|
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
|
||||||
|
</svg>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings Modal -->
|
||||||
|
<div class="modal-overlay" id="settingsModal">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>⚙️ Settings</h2>
|
||||||
|
<button class="modal-close" id="closeModalBtn">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="apiEndpoint">API Endpoint</label>
|
||||||
|
<input type="text" id="apiEndpoint" placeholder="https://api.openai.com/v1">
|
||||||
|
<small>Base URL for the OpenAI-compatible API</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="apiKey">API Key</label>
|
||||||
|
<input type="password" id="apiKey" placeholder="sk-...">
|
||||||
|
<small>Your API key for authentication</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="model">Default Model</label>
|
||||||
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
|
<select id="model" style="flex: 1;">
|
||||||
|
<option value="">-- Select or type below --</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" class="btn btn-secondary" id="fetchModelsBtn" title="Fetch available models">
|
||||||
|
🔄 Fetch
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="modelCustom" placeholder="Or type custom model name..." style="margin-top: 0.5rem;">
|
||||||
|
<small>Select from list or enter custom model name</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toggle-group">
|
||||||
|
<span class="toggle-label">Stream Responses</span>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="streamResponse" checked>
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toggle-group">
|
||||||
|
<span class="toggle-label">Save Chat History</span>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="saveHistory" checked>
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toggle-group">
|
||||||
|
<span class="toggle-label">Show Thinking Blocks</span>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="showThinking" checked>
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="systemPrompt">System Prompt (Optional)</label>
|
||||||
|
<textarea id="systemPrompt" rows="3" placeholder="You are a helpful AI assistant."></textarea>
|
||||||
|
<small>Instructions that define the AI's behavior</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="maxTokens">Max Tokens</label>
|
||||||
|
<input type="number" id="maxTokens" value="4096" min="1" max="32768">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="temperature">Temperature</label>
|
||||||
|
<input type="number" id="temperature" value="0.7" min="0" max="2" step="0.1">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="topP">Top P</label>
|
||||||
|
<input type="number" id="topP" value="1" min="0" max="1" step="0.1">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="presencePenalty">Presence Penalty</label>
|
||||||
|
<input type="number" id="presencePenalty" value="0" min="-2" max="2" step="0.1">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" id="cancelBtn">Cancel</button>
|
||||||
|
<button class="btn btn-primary" id="saveBtn">💾 Save Settings</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast-container" id="toastContainer"></div>
|
||||||
|
|
||||||
|
<script src="js/storage.js"></script>
|
||||||
|
<script src="js/state.js"></script>
|
||||||
|
<script src="js/api.js"></script>
|
||||||
|
<script src="js/ui.js"></script>
|
||||||
|
<script src="js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
204
src/js/api.js
Normal file
204
src/js/api.js
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
// ==========================================
|
||||||
|
// API Functions
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
async function fetchModels(showInModal = true) {
|
||||||
|
const endpoint = showInModal
|
||||||
|
? document.getElementById('apiEndpoint').value.trim()
|
||||||
|
: State.settings.apiEndpoint;
|
||||||
|
const apiKey = showInModal
|
||||||
|
? document.getElementById('apiKey').value.trim()
|
||||||
|
: State.settings.apiKey;
|
||||||
|
|
||||||
|
if (!endpoint || !apiKey) {
|
||||||
|
showToast('⚠️ Configure API endpoint and key first', 'warning');
|
||||||
|
if (!showInModal) openSettings();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = showInModal
|
||||||
|
? document.getElementById('fetchModelsBtn')
|
||||||
|
: document.getElementById('quickFetchBtn');
|
||||||
|
const originalText = btn.innerHTML;
|
||||||
|
btn.innerHTML = '⏳';
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(endpoint.replace(/\/$/, '') + '/models', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`API Error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const models = data.data || data.models || data || [];
|
||||||
|
|
||||||
|
State.models = Array.isArray(models)
|
||||||
|
? models.map(m => ({
|
||||||
|
id: m.id || m.name || m,
|
||||||
|
owned_by: m.owned_by || null
|
||||||
|
})).sort((a, b) => a.id.localeCompare(b.id))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
saveModels();
|
||||||
|
|
||||||
|
if (showInModal) {
|
||||||
|
populateModelSelect(document.getElementById('model'));
|
||||||
|
}
|
||||||
|
updateQuickModelSelector();
|
||||||
|
|
||||||
|
showToast(`✅ Loaded ${State.models.length} models`, 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch models error:', error);
|
||||||
|
showToast(`❌ Failed: ${error.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendApiRequest(messages) {
|
||||||
|
const chatContainer = document.getElementById('chatMessages');
|
||||||
|
|
||||||
|
// Add typing indicator
|
||||||
|
const typingDiv = document.createElement('div');
|
||||||
|
typingDiv.className = 'message assistant';
|
||||||
|
typingDiv.id = 'typingIndicator';
|
||||||
|
typingDiv.innerHTML = `
|
||||||
|
<div class="message-content">
|
||||||
|
<div class="message-avatar">🤖</div>
|
||||||
|
<div class="message-body">
|
||||||
|
<div class="message-role">Assistant</div>
|
||||||
|
<div class="typing-indicator"><span></span><span></span><span></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
chatContainer.appendChild(typingDiv);
|
||||||
|
scrollToBottom();
|
||||||
|
|
||||||
|
State.isGenerating = true;
|
||||||
|
State.abortController = new AbortController();
|
||||||
|
document.getElementById('stopBtn').classList.add('visible');
|
||||||
|
document.getElementById('sendBtn').disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const endpoint = State.settings.apiEndpoint.replace(/\/$/, '') + '/chat/completions';
|
||||||
|
const model = document.getElementById('quickModel').value || State.settings.model;
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${State.settings.apiKey}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
messages: messages.map(m => ({ role: m.role, content: m.content })),
|
||||||
|
max_tokens: State.settings.maxTokens,
|
||||||
|
temperature: State.settings.temperature,
|
||||||
|
top_p: State.settings.topP,
|
||||||
|
presence_penalty: State.settings.presencePenalty,
|
||||||
|
stream: State.settings.stream
|
||||||
|
}),
|
||||||
|
signal: State.abortController.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.text();
|
||||||
|
throw new Error(`API Error: ${response.status} - ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('typingIndicator')?.remove();
|
||||||
|
|
||||||
|
let assistantContent = '';
|
||||||
|
|
||||||
|
if (State.settings.stream) {
|
||||||
|
const assistantDiv = document.createElement('div');
|
||||||
|
assistantDiv.className = 'message assistant';
|
||||||
|
assistantDiv.innerHTML = `
|
||||||
|
<div class="message-content">
|
||||||
|
<div class="message-avatar">🤖</div>
|
||||||
|
<div class="message-body">
|
||||||
|
<div class="message-header">
|
||||||
|
<span class="message-role">Assistant</span>
|
||||||
|
</div>
|
||||||
|
<div class="message-text" id="streamingContent"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
chatContainer.appendChild(assistantDiv);
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
const chunk = decoder.decode(value);
|
||||||
|
const lines = chunk.split('\n');
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
const data = line.slice(6);
|
||||||
|
if (data === '[DONE]') continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
const content = parsed.choices?.[0]?.delta?.content || '';
|
||||||
|
assistantContent += content;
|
||||||
|
document.getElementById('streamingContent').innerHTML = formatMessage(assistantContent);
|
||||||
|
scrollToBottom();
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const data = await response.json();
|
||||||
|
assistantContent = data.choices?.[0]?.message?.content || 'No response';
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.push({
|
||||||
|
role: 'assistant',
|
||||||
|
content: assistantContent,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
updateChat(State.currentChatId, { messages, model });
|
||||||
|
|
||||||
|
if (!State.settings.stream) {
|
||||||
|
renderMessages(messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRegenerateButton(messages);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
document.getElementById('typingIndicator')?.remove();
|
||||||
|
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
showToast('⚠️ Generation stopped', 'warning');
|
||||||
|
} else {
|
||||||
|
console.error('API Error:', error);
|
||||||
|
showToast(`❌ ${error.message}`, 'error');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
State.isGenerating = false;
|
||||||
|
State.abortController = null;
|
||||||
|
document.getElementById('stopBtn').classList.remove('visible');
|
||||||
|
document.getElementById('sendBtn').disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopGeneration() {
|
||||||
|
if (State.abortController) {
|
||||||
|
State.abortController.abort();
|
||||||
|
State.abortController = null;
|
||||||
|
}
|
||||||
|
State.isGenerating = false;
|
||||||
|
document.getElementById('stopBtn').classList.remove('visible');
|
||||||
|
document.getElementById('sendBtn').disabled = false;
|
||||||
|
}
|
||||||
234
src/js/app.js
Normal file
234
src/js/app.js
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
// ==========================================
|
||||||
|
// Chat Switchboard - Main Application
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
console.log('🔀 Chat Switchboard initializing...');
|
||||||
|
|
||||||
|
loadSettings();
|
||||||
|
loadChats();
|
||||||
|
loadModels();
|
||||||
|
initializeEventListeners();
|
||||||
|
updateQuickModelSelector();
|
||||||
|
renderChatHistory();
|
||||||
|
|
||||||
|
console.log('✅ Chat Switchboard ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeEventListeners() {
|
||||||
|
// Settings modal
|
||||||
|
document.getElementById('settingsBtn').addEventListener('click', openSettings);
|
||||||
|
document.getElementById('configureBtn').addEventListener('click', openSettings);
|
||||||
|
document.getElementById('closeModalBtn').addEventListener('click', closeSettings);
|
||||||
|
document.getElementById('cancelBtn').addEventListener('click', closeSettings);
|
||||||
|
document.getElementById('saveBtn').addEventListener('click', handleSaveSettings);
|
||||||
|
document.getElementById('fetchModelsBtn').addEventListener('click', () => fetchModels(true));
|
||||||
|
|
||||||
|
// Chat controls
|
||||||
|
document.getElementById('newChatBtn').addEventListener('click', newChat);
|
||||||
|
document.getElementById('sendBtn').addEventListener('click', sendMessage);
|
||||||
|
document.getElementById('stopBtn').addEventListener('click', stopGeneration);
|
||||||
|
document.getElementById('regenerateBtn').addEventListener('click', regenerateResponse);
|
||||||
|
document.getElementById('toggleSidebarBtn').addEventListener('click', toggleSidebar);
|
||||||
|
document.getElementById('quickFetchBtn').addEventListener('click', () => fetchModels(false));
|
||||||
|
|
||||||
|
// Quick model selector
|
||||||
|
document.getElementById('quickModel').addEventListener('change', function() {
|
||||||
|
if (this.value) {
|
||||||
|
State.settings.model = this.value;
|
||||||
|
saveSettings();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export dropdown
|
||||||
|
document.getElementById('exportBtn').addEventListener('click', function(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
document.getElementById('exportDropdown').classList.toggle('show');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', function() {
|
||||||
|
document.getElementById('exportDropdown').classList.remove('show');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Message input
|
||||||
|
const messageInput = document.getElementById('messageInput');
|
||||||
|
messageInput.addEventListener('keydown', handleKeyDown);
|
||||||
|
messageInput.addEventListener('input', function() {
|
||||||
|
this.style.height = 'auto';
|
||||||
|
this.style.height = Math.min(this.scrollHeight, 200) + 'px';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Settings modal model sync
|
||||||
|
document.getElementById('model').addEventListener('change', function() {
|
||||||
|
if (this.value) document.getElementById('modelCustom').value = '';
|
||||||
|
});
|
||||||
|
document.getElementById('modelCustom').addEventListener('input', function() {
|
||||||
|
if (this.value) document.getElementById('model').value = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close modal on overlay click
|
||||||
|
document.getElementById('settingsModal').addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) closeSettings();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global keyboard shortcuts
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.ctrlKey && e.key === ',') {
|
||||||
|
e.preventDefault();
|
||||||
|
openSettings();
|
||||||
|
}
|
||||||
|
if (e.ctrlKey && e.key === 'b') {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleSidebar();
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (State.isGenerating) {
|
||||||
|
stopGeneration();
|
||||||
|
} else if (document.getElementById('settingsModal').classList.contains('active')) {
|
||||||
|
closeSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
sendMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSaveSettings() {
|
||||||
|
State.settings.apiEndpoint = document.getElementById('apiEndpoint').value.trim();
|
||||||
|
State.settings.apiKey = document.getElementById('apiKey').value.trim();
|
||||||
|
|
||||||
|
const modelCustom = document.getElementById('modelCustom').value.trim();
|
||||||
|
const modelSelect = document.getElementById('model').value;
|
||||||
|
State.settings.model = modelCustom || modelSelect || 'gpt-3.5-turbo';
|
||||||
|
|
||||||
|
State.settings.stream = document.getElementById('streamResponse').checked;
|
||||||
|
State.settings.saveHistory = document.getElementById('saveHistory').checked;
|
||||||
|
State.settings.showThinking = document.getElementById('showThinking').checked;
|
||||||
|
State.settings.systemPrompt = document.getElementById('systemPrompt').value.trim();
|
||||||
|
State.settings.maxTokens = parseInt(document.getElementById('maxTokens').value) || 4096;
|
||||||
|
State.settings.temperature = parseFloat(document.getElementById('temperature').value) || 0.7;
|
||||||
|
State.settings.topP = parseFloat(document.getElementById('topP').value) || 1;
|
||||||
|
State.settings.presencePenalty = parseFloat(document.getElementById('presencePenalty').value) || 0;
|
||||||
|
|
||||||
|
saveSettings();
|
||||||
|
updateQuickModelSelector();
|
||||||
|
closeSettings();
|
||||||
|
showToast('✅ Settings saved', 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
function newChat() {
|
||||||
|
State.currentChatId = null;
|
||||||
|
document.getElementById('chatMessages').innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2>Start a Conversation</h2>
|
||||||
|
<p>Type a message below to begin</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.getElementById('regenerateBtn').style.display = 'none';
|
||||||
|
renderChatHistory();
|
||||||
|
document.getElementById('messageInput').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadChat(chatId) {
|
||||||
|
const chat = getChat(chatId);
|
||||||
|
if (chat) {
|
||||||
|
State.currentChatId = chatId;
|
||||||
|
if (chat.model) {
|
||||||
|
State.settings.model = chat.model;
|
||||||
|
updateQuickModelSelector();
|
||||||
|
}
|
||||||
|
renderMessages(chat.messages);
|
||||||
|
renderChatHistory();
|
||||||
|
updateRegenerateButton(chat.messages);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeleteChat(chatId, event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (confirm('Delete this chat?')) {
|
||||||
|
deleteChat(chatId);
|
||||||
|
if (State.currentChatId === chatId) {
|
||||||
|
newChat();
|
||||||
|
}
|
||||||
|
renderChatHistory();
|
||||||
|
showToast('🗑️ Chat deleted', 'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMessage() {
|
||||||
|
const input = document.getElementById('messageInput');
|
||||||
|
const message = input.value.trim();
|
||||||
|
|
||||||
|
if (!message || State.isGenerating) return;
|
||||||
|
|
||||||
|
if (!State.settings.apiEndpoint || !State.settings.apiKey) {
|
||||||
|
showToast('⚠️ Configure API settings first', 'warning');
|
||||||
|
openSettings();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.value = '';
|
||||||
|
input.style.height = 'auto';
|
||||||
|
|
||||||
|
let messages = [];
|
||||||
|
|
||||||
|
if (State.settings.systemPrompt) {
|
||||||
|
messages.push({ role: 'system', content: State.settings.systemPrompt });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (State.currentChatId) {
|
||||||
|
const chat = getChat(State.currentChatId);
|
||||||
|
if (chat) messages = [...chat.messages];
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.push({
|
||||||
|
role: 'user',
|
||||||
|
content: message,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!State.currentChatId) {
|
||||||
|
const title = message.substring(0, 50) + (message.length > 50 ? '...' : '');
|
||||||
|
const chat = createChat(title, messages);
|
||||||
|
State.currentChatId = chat.id;
|
||||||
|
} else {
|
||||||
|
updateChat(State.currentChatId, { messages });
|
||||||
|
}
|
||||||
|
|
||||||
|
renderMessages(messages);
|
||||||
|
renderChatHistory();
|
||||||
|
|
||||||
|
await sendApiRequest(messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function regenerateResponse() {
|
||||||
|
if (State.isGenerating || !State.currentChatId) return;
|
||||||
|
|
||||||
|
const chat = getChat(State.currentChatId);
|
||||||
|
if (!chat || chat.messages.length === 0) return;
|
||||||
|
|
||||||
|
// Remove last assistant message(s)
|
||||||
|
while (chat.messages.length > 0 && chat.messages[chat.messages.length - 1].role === 'assistant') {
|
||||||
|
chat.messages.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chat.messages.length === 0) return;
|
||||||
|
|
||||||
|
updateChat(State.currentChatId, { messages: chat.messages });
|
||||||
|
renderMessages(chat.messages);
|
||||||
|
|
||||||
|
await sendApiRequest(chat.messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize on DOM ready
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
91
src/js/state.js
Normal file
91
src/js/state.js
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
// ==========================================
|
||||||
|
// State Management
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
const State = {
|
||||||
|
settings: {
|
||||||
|
apiEndpoint: '',
|
||||||
|
apiKey: '',
|
||||||
|
model: 'gpt-3.5-turbo',
|
||||||
|
stream: true,
|
||||||
|
saveHistory: true,
|
||||||
|
showThinking: true,
|
||||||
|
systemPrompt: '',
|
||||||
|
maxTokens: 4096,
|
||||||
|
temperature: 0.7,
|
||||||
|
topP: 1,
|
||||||
|
presencePenalty: 0
|
||||||
|
},
|
||||||
|
models: [],
|
||||||
|
chats: [],
|
||||||
|
currentChatId: null,
|
||||||
|
isGenerating: false,
|
||||||
|
abortController: null
|
||||||
|
};
|
||||||
|
|
||||||
|
function loadSettings() {
|
||||||
|
const saved = Storage.get('chatSwitchboard_settings');
|
||||||
|
if (saved) {
|
||||||
|
State.settings = { ...State.settings, ...saved };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSettings() {
|
||||||
|
Storage.set('chatSwitchboard_settings', State.settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadModels() {
|
||||||
|
State.models = Storage.get('chatSwitchboard_models', []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveModels() {
|
||||||
|
Storage.set('chatSwitchboard_models', State.models);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadChats() {
|
||||||
|
State.chats = Storage.get('chatSwitchboard_chats', []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveChats() {
|
||||||
|
if (State.settings.saveHistory) {
|
||||||
|
Storage.set('chatSwitchboard_chats', State.chats);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createChat(title, messages) {
|
||||||
|
const chat = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
title,
|
||||||
|
messages,
|
||||||
|
model: State.settings.model,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
State.chats.unshift(chat);
|
||||||
|
if (State.chats.length > 100) {
|
||||||
|
State.chats = State.chats.slice(0, 100);
|
||||||
|
}
|
||||||
|
saveChats();
|
||||||
|
return chat;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChat(chatId, updates) {
|
||||||
|
const chat = State.chats.find(c => c.id === chatId);
|
||||||
|
if (chat) {
|
||||||
|
Object.assign(chat, updates, { updatedAt: new Date().toISOString() });
|
||||||
|
saveChats();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteChat(chatId) {
|
||||||
|
State.chats = State.chats.filter(c => c.id !== chatId);
|
||||||
|
saveChats();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChat(chatId) {
|
||||||
|
return State.chats.find(c => c.id === chatId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentChat() {
|
||||||
|
return State.currentChatId ? getChat(State.currentChatId) : null;
|
||||||
|
}
|
||||||
45
src/js/storage.js
Normal file
45
src/js/storage.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// ==========================================
|
||||||
|
// Storage Utilities
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
const Storage = {
|
||||||
|
get(key, defaultValue = null) {
|
||||||
|
try {
|
||||||
|
const item = localStorage.getItem(key);
|
||||||
|
return item ? JSON.parse(item) : defaultValue;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Storage get error:', e);
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
set(key, value) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, JSON.stringify(value));
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Storage set error:', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
remove(key) {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Storage remove error:', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
try {
|
||||||
|
localStorage.clear();
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Storage clear error:', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
314
src/js/ui.js
Normal file
314
src/js/ui.js
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
// ==========================================
|
||||||
|
// UI Functions
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
function updateSettingsUI() {
|
||||||
|
document.getElementById('apiEndpoint').value = State.settings.apiEndpoint || '';
|
||||||
|
document.getElementById('apiKey').value = State.settings.apiKey || '';
|
||||||
|
|
||||||
|
const modelSelect = document.getElementById('model');
|
||||||
|
const modelCustom = document.getElementById('modelCustom');
|
||||||
|
const savedModel = State.settings.model || 'gpt-3.5-turbo';
|
||||||
|
|
||||||
|
populateModelSelect(modelSelect);
|
||||||
|
|
||||||
|
const optionExists = Array.from(modelSelect.options).some(opt => opt.value === savedModel);
|
||||||
|
if (optionExists) {
|
||||||
|
modelSelect.value = savedModel;
|
||||||
|
modelCustom.value = '';
|
||||||
|
} else {
|
||||||
|
modelSelect.value = '';
|
||||||
|
modelCustom.value = savedModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('streamResponse').checked = State.settings.stream;
|
||||||
|
document.getElementById('saveHistory').checked = State.settings.saveHistory;
|
||||||
|
document.getElementById('showThinking').checked = State.settings.showThinking;
|
||||||
|
document.getElementById('systemPrompt').value = State.settings.systemPrompt || '';
|
||||||
|
document.getElementById('maxTokens').value = State.settings.maxTokens;
|
||||||
|
document.getElementById('temperature').value = State.settings.temperature;
|
||||||
|
document.getElementById('topP').value = State.settings.topP;
|
||||||
|
document.getElementById('presencePenalty').value = State.settings.presencePenalty;
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateModelSelect(selectElement) {
|
||||||
|
const currentValue = selectElement.value;
|
||||||
|
selectElement.innerHTML = '<option value="">-- Select a model --</option>';
|
||||||
|
|
||||||
|
State.models.forEach(model => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = model.id;
|
||||||
|
option.textContent = model.id + (model.owned_by ? ` (${model.owned_by})` : '');
|
||||||
|
selectElement.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentValue) {
|
||||||
|
selectElement.value = currentValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateQuickModelSelector() {
|
||||||
|
const quickSelect = document.getElementById('quickModel');
|
||||||
|
const currentModel = State.settings.model;
|
||||||
|
|
||||||
|
quickSelect.innerHTML = '';
|
||||||
|
|
||||||
|
if (State.models.length === 0) {
|
||||||
|
quickSelect.innerHTML = '<option value="">-- Fetch models first --</option>';
|
||||||
|
if (currentModel) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = currentModel;
|
||||||
|
opt.textContent = currentModel;
|
||||||
|
quickSelect.appendChild(opt);
|
||||||
|
quickSelect.value = currentModel;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
State.models.forEach(model => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = model.id;
|
||||||
|
option.textContent = model.id;
|
||||||
|
quickSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentModel) {
|
||||||
|
const exists = Array.from(quickSelect.options).some(opt => opt.value === currentModel);
|
||||||
|
if (exists) {
|
||||||
|
quickSelect.value = currentModel;
|
||||||
|
} else {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = currentModel;
|
||||||
|
opt.textContent = currentModel + ' (custom)';
|
||||||
|
quickSelect.insertBefore(opt, quickSelect.firstChild);
|
||||||
|
quickSelect.value = currentModel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSettings() {
|
||||||
|
updateSettingsUI();
|
||||||
|
document.getElementById('settingsModal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSettings() {
|
||||||
|
document.getElementById('settingsModal').classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSidebar() {
|
||||||
|
document.getElementById('sidebar').classList.toggle('collapsed');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChatHistory() {
|
||||||
|
const container = document.getElementById('chatHistory');
|
||||||
|
if (State.chats.length === 0) {
|
||||||
|
container.innerHTML = '<div style="padding: 1rem; text-align: center; color: var(--text-secondary); font-size: 0.875rem;">No chat history</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = State.chats.map(chat => `
|
||||||
|
<div class="chat-history-item ${chat.id === State.currentChatId ? 'active' : ''}"
|
||||||
|
onclick="loadChat('${chat.id}')">
|
||||||
|
<span class="title">${escapeHtml(chat.title)}</span>
|
||||||
|
<div class="item-actions">
|
||||||
|
<button class="item-btn delete" onclick="handleDeleteChat('${chat.id}', event)" title="Delete">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="3 6 5 6 21 6"></polyline>
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMessages(messages) {
|
||||||
|
const container = document.getElementById('chatMessages');
|
||||||
|
if (!messages || messages.length === 0) {
|
||||||
|
newChat();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = messages
|
||||||
|
.filter(m => m.role !== 'system')
|
||||||
|
.map((msg, idx) => createMessageHTML(msg, idx))
|
||||||
|
.join('');
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMessageHTML(message, index) {
|
||||||
|
const isUser = message.role === 'user';
|
||||||
|
const time = message.timestamp ? new Date(message.timestamp).toLocaleTimeString() : '';
|
||||||
|
const formattedContent = formatMessage(message.content);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="message ${message.role}" data-index="${index}">
|
||||||
|
<div class="message-content">
|
||||||
|
<div class="message-avatar">${isUser ? '👤' : '🤖'}</div>
|
||||||
|
<div class="message-body">
|
||||||
|
<div class="message-header">
|
||||||
|
<span class="message-role">${isUser ? 'You' : 'Assistant'}</span>
|
||||||
|
<div class="message-actions">
|
||||||
|
<button class="message-action-btn" onclick="copyMessage(${index})" title="Copy">📋 Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${time ? `<div class="message-time">${time}</div>` : ''}
|
||||||
|
<div class="message-text">${formattedContent}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMessage(content) {
|
||||||
|
let formatted = content;
|
||||||
|
|
||||||
|
// Extract and format thinking blocks FIRST (before escaping)
|
||||||
|
if (State.settings.showThinking) {
|
||||||
|
formatted = formatted.replace(/<thinking>([\s\S]*?)<\/thinking>/gi, (match, thinkingContent) => {
|
||||||
|
const escapedContent = escapeHtml(thinkingContent.trim());
|
||||||
|
const blockId = 'thinking-' + Math.random().toString(36).substr(2, 9);
|
||||||
|
return `__THINKING_BLOCK_${blockId}__${escapedContent}__END_THINKING_BLOCK__`;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Remove thinking blocks if setting is off
|
||||||
|
formatted = formatted.replace(/<thinking>[\s\S]*?<\/thinking>/gi, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now escape HTML for the rest
|
||||||
|
formatted = escapeHtml(formatted);
|
||||||
|
|
||||||
|
// Restore thinking blocks with proper HTML
|
||||||
|
formatted = formatted.replace(/__THINKING_BLOCK_([\w-]+)__([\s\S]*?)__END_THINKING_BLOCK__/g, (match, blockId, content) => {
|
||||||
|
return `
|
||||||
|
<div class="thinking-block" id="${blockId}">
|
||||||
|
<div class="thinking-header" onclick="toggleThinkingBlock('${blockId}')">
|
||||||
|
<span class="thinking-toggle">▼</span>
|
||||||
|
<span class="thinking-label">💭 Thinking</span>
|
||||||
|
</div>
|
||||||
|
<div class="thinking-content">${content.replace(/\n/g, '<br>')}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Code blocks with copy button
|
||||||
|
formatted = formatted.replace(/```(\w*)\n?([\s\S]*?)```/g, (match, lang, code) => {
|
||||||
|
const codeId = 'code-' + Math.random().toString(36).substr(2, 9);
|
||||||
|
return `<pre><code id="${codeId}" class="language-${lang}">${code.trim()}</code><button class="copy-code-btn" onclick="copyCode('${codeId}')">Copy</button></pre>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Inline code
|
||||||
|
formatted = formatted.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||||
|
|
||||||
|
// Bold
|
||||||
|
formatted = formatted.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||||
|
|
||||||
|
// Italic
|
||||||
|
formatted = formatted.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
||||||
|
|
||||||
|
// Line breaks (but not inside pre/code)
|
||||||
|
formatted = formatted.replace(/\n/g, '<br>');
|
||||||
|
|
||||||
|
return formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleThinkingBlock(blockId) {
|
||||||
|
const block = document.getElementById(blockId);
|
||||||
|
if (block) {
|
||||||
|
block.classList.toggle('collapsed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToBottom() {
|
||||||
|
const container = document.getElementById('chatMessages');
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRegenerateButton(messages) {
|
||||||
|
const btn = document.getElementById('regenerateBtn');
|
||||||
|
const hasAssistantMessage = messages && messages.some(m => m.role === 'assistant');
|
||||||
|
btn.style.display = hasAssistantMessage ? 'flex' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message, type = 'success') {
|
||||||
|
const container = document.getElementById('toastContainer');
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = `toast ${type}`;
|
||||||
|
toast.innerHTML = `
|
||||||
|
<span>${message}</span>
|
||||||
|
<button class="toast-close" onclick="this.parentElement.remove()">✕</button>
|
||||||
|
`;
|
||||||
|
container.appendChild(toast);
|
||||||
|
setTimeout(() => toast.remove(), 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message actions
|
||||||
|
function copyMessage(index) {
|
||||||
|
const chat = getCurrentChat();
|
||||||
|
if (chat && chat.messages[index]) {
|
||||||
|
navigator.clipboard.writeText(chat.messages[index].content)
|
||||||
|
.then(() => showToast('📋 Copied to clipboard', 'success'))
|
||||||
|
.catch(() => showToast('❌ Failed to copy', 'error'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyCode(codeId) {
|
||||||
|
const codeElement = document.getElementById(codeId);
|
||||||
|
if (codeElement) {
|
||||||
|
navigator.clipboard.writeText(codeElement.textContent)
|
||||||
|
.then(() => showToast('📋 Code copied', 'success'))
|
||||||
|
.catch(() => showToast('❌ Failed to copy', 'error'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportChat(format) {
|
||||||
|
const chat = getCurrentChat();
|
||||||
|
if (!chat) {
|
||||||
|
showToast('⚠️ No chat to export', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let content, filename, mimeType;
|
||||||
|
const title = chat.title.replace(/[^a-z0-9]/gi, '_');
|
||||||
|
|
||||||
|
switch (format) {
|
||||||
|
case 'markdown':
|
||||||
|
content = `# ${chat.title}\n\n` + chat.messages
|
||||||
|
.filter(m => m.role !== 'system')
|
||||||
|
.map(m => `## ${m.role === 'user' ? 'You' : 'Assistant'}\n\n${m.content}`)
|
||||||
|
.join('\n\n---\n\n');
|
||||||
|
filename = `${title}.md`;
|
||||||
|
mimeType = 'text/markdown';
|
||||||
|
break;
|
||||||
|
case 'json':
|
||||||
|
content = JSON.stringify(chat, null, 2);
|
||||||
|
filename = `${title}.json`;
|
||||||
|
mimeType = 'application/json';
|
||||||
|
break;
|
||||||
|
case 'text':
|
||||||
|
content = chat.messages
|
||||||
|
.filter(m => m.role !== 'system')
|
||||||
|
.map(m => `[${m.role === 'user' ? 'You' : 'Assistant'}]\n${m.content}`)
|
||||||
|
.join('\n\n');
|
||||||
|
filename = `${title}.txt`;
|
||||||
|
mimeType = 'text/plain';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new Blob([content], { type: mimeType });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
showToast(`📥 Exported as ${format}`, 'success');
|
||||||
|
document.getElementById('exportDropdown').classList.remove('show');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user