commit 701e387a5b92f9362688393a5e73b7c03e071d0c Author: Jeffrey Smith Date: Tue Feb 3 17:20:06 2026 -0500 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..840adb0 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..1667d98 --- /dev/null +++ b/README.md @@ -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 `` 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. diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..ffec7a2 --- /dev/null +++ b/build.sh @@ -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' + + + + + + Chat Switchboard + + +HTMLEOF + +# Extract body content (between and , excluding script tags) +BODY_CONTENT=$(echo "$HTML_CONTENT" | sed -n '//,/<\/body>/p' | sed '1d;$d' | sed '/ + + +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" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c4928c1 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/frontend/css/styles.css b/frontend/css/styles.css new file mode 100644 index 0000000..eaefcd5 --- /dev/null +++ b/frontend/css/styles.css @@ -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); } diff --git a/frontend/js/api.js b/frontend/js/api.js new file mode 100644 index 0000000..9f857a6 --- /dev/null +++ b/frontend/js/api.js @@ -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 }; + } +}; diff --git a/frontend/js/storage.js b/frontend/js/storage.js new file mode 100644 index 0000000..9d197d3 --- /dev/null +++ b/frontend/js/storage.js @@ -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) {} + } +}; diff --git a/migrations/001_initial.sql b/migrations/001_initial.sql new file mode 100644 index 0000000..6d8415d --- /dev/null +++ b/migrations/001_initial.sql @@ -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; diff --git a/server/main.go b/server/main.go new file mode 100644 index 0000000..5e48262 --- /dev/null +++ b/server/main.go @@ -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() + } +} diff --git a/src/css/styles.css b/src/css/styles.css new file mode 100644 index 0000000..0f598d7 --- /dev/null +++ b/src/css/styles.css @@ -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); } diff --git a/src/index.html b/src/index.html new file mode 100644 index 0000000..00a6d68 --- /dev/null +++ b/src/index.html @@ -0,0 +1,206 @@ + + + + + + Chat Switchboard + + + +
+

๐Ÿ”€ Chat Switchboard

+
+ + + +
+
+ +
+ + +
+
+
+
+ + + +
+

Start a Conversation

+

Configure your API settings and start chatting

+ +
+
+ +
+
+
+
+ + + +
+
+ Enter send ยท Shift+Enter newline ยท Esc stop +
+
+
+ +
+
+ +
+
+ + +
+
+
+
+
+
+
+ + + + +
+ + + + + + + + diff --git a/src/js/api.js b/src/js/api.js new file mode 100644 index 0000000..b280073 --- /dev/null +++ b/src/js/api.js @@ -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 = ` +
+
๐Ÿค–
+
+
Assistant
+
+
+
+ `; + 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 = ` +
+
๐Ÿค–
+
+
+ Assistant +
+
+
+
+ `; + 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; +} diff --git a/src/js/app.js b/src/js/app.js new file mode 100644 index 0000000..8ede0ee --- /dev/null +++ b/src/js/app.js @@ -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 = ` +
+
+ + + +
+

Start a Conversation

+

Type a message below to begin

+
+ `; + 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); diff --git a/src/js/state.js b/src/js/state.js new file mode 100644 index 0000000..467e184 --- /dev/null +++ b/src/js/state.js @@ -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; +} diff --git a/src/js/storage.js b/src/js/storage.js new file mode 100644 index 0000000..2eb9fbc --- /dev/null +++ b/src/js/storage.js @@ -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; + } + } +}; diff --git a/src/js/ui.js b/src/js/ui.js new file mode 100644 index 0000000..3025c76 --- /dev/null +++ b/src/js/ui.js @@ -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 = ''; + + 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 = ''; + 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 = '
No chat history
'; + return; + } + + container.innerHTML = State.chats.map(chat => ` +
+ ${escapeHtml(chat.title)} +
+ +
+
+ `).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 ` +
+
+
${isUser ? '๐Ÿ‘ค' : '๐Ÿค–'}
+
+
+ ${isUser ? 'You' : 'Assistant'} +
+ +
+
+ ${time ? `
${time}
` : ''} +
${formattedContent}
+
+
+
+ `; +} + +function formatMessage(content) { + let formatted = content; + + // Extract and format thinking blocks FIRST (before escaping) + if (State.settings.showThinking) { + formatted = formatted.replace(/([\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(/[\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 ` +
+
+ โ–ผ + ๐Ÿ’ญ Thinking +
+
${content.replace(/\n/g, '
')}
+
+ `; + }); + + // 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 `
${code.trim()}
`; + }); + + // Inline code + formatted = formatted.replace(/`([^`]+)`/g, '$1'); + + // Bold + formatted = formatted.replace(/\*\*([^*]+)\*\*/g, '$1'); + + // Italic + formatted = formatted.replace(/\*([^*]+)\*/g, '$1'); + + // Line breaks (but not inside pre/code) + formatted = formatted.replace(/\n/g, '
'); + + 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 = ` + ${message} + + `; + 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'); +}