Initial commit: OpenClaw WebUI with LDAP SSO
Features: - Modern chat interface with streaming responses - Multi-file upload support - Code canvas panel for code viewing/editing - Chat history persistence - LDAP SSO authentication - OpenAI-compatible API proxy to OpenClaw gateway - Model/agent selection - Dark theme
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
data/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
180
README.md
Normal file
180
README.md
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
# OpenClaw WebUI
|
||||||
|
|
||||||
|
A modern, OpenWebUI-compatible chat interface for OpenClaw with LDAP SSO support.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Modern Chat Interface** - Clean, responsive UI inspired by OpenWebUI
|
||||||
|
- **Multi-file Upload** - Attach files to messages
|
||||||
|
- **Code Canvas** - Side panel for code editing and viewing
|
||||||
|
- **Chat History** - Persistent conversation storage
|
||||||
|
- **Streaming Responses** - Real-time token streaming
|
||||||
|
- **LDAP SSO** - Enterprise authentication via LDAP
|
||||||
|
- **Model Selection** - Switch between OpenClaw agents
|
||||||
|
- **Dark Theme** - Easy on the eyes
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Development mode (with hot reload)
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Production build
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Start production server
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
The server runs on port 3000 by default.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Configure via environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Server
|
||||||
|
PORT=3000
|
||||||
|
NODE_ENV=production
|
||||||
|
|
||||||
|
# OpenClaw Gateway
|
||||||
|
OPENCLAW_GATEWAY=http://127.0.0.1:18789
|
||||||
|
|
||||||
|
# LDAP Authentication
|
||||||
|
LDAP_ENABLED=true
|
||||||
|
LDAP_URL=ldap://localhost:389
|
||||||
|
LDAP_BASE_DN=ou=users,dc=example,dc=com
|
||||||
|
|
||||||
|
# Session
|
||||||
|
SESSION_SECRET=your-secret-key
|
||||||
|
|
||||||
|
# Development (disable auth)
|
||||||
|
DISABLE_AUTH=true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ OpenClaw WebUI │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ Frontend (Vanilla JS + Vite) │
|
||||||
|
│ ├── Chat Interface │
|
||||||
|
│ ├── File Upload │
|
||||||
|
│ ├── Code Canvas │
|
||||||
|
│ └── History Sidebar │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ Backend (Express.js) │
|
||||||
|
│ ├── LDAP SSO Authentication │
|
||||||
|
│ ├── Session Management │
|
||||||
|
│ ├── Chat History Persistence │
|
||||||
|
│ ├── File Upload Handling │
|
||||||
|
│ └── /v1/chat/completions Proxy │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ OpenClaw Gateway (port 18789) │
|
||||||
|
│ ├── WebSocket Protocol │
|
||||||
|
│ ├── OpenAI-Compatible API │
|
||||||
|
│ └── Agent Execution │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
- `GET /api/auth/status` - Check authentication status
|
||||||
|
- `POST /api/auth/login` - Login with username/password
|
||||||
|
- `POST /api/auth/logout` - Logout
|
||||||
|
|
||||||
|
### Conversations
|
||||||
|
- `GET /api/conversations` - List all conversations
|
||||||
|
- `POST /api/conversations` - Create new conversation
|
||||||
|
- `PUT /api/conversations/:id` - Update conversation
|
||||||
|
- `DELETE /api/conversations/:id` - Delete conversation
|
||||||
|
- `GET /api/conversations/:id/messages` - Get messages
|
||||||
|
- `POST /api/conversations/:id/messages` - Save message
|
||||||
|
|
||||||
|
### Files
|
||||||
|
- `POST /api/upload` - Upload file
|
||||||
|
- `GET /api/upload/:id` - Download file
|
||||||
|
- `GET /api/upload/:id/meta` - Get file metadata
|
||||||
|
|
||||||
|
### OpenAI-Compatible
|
||||||
|
- `POST /v1/chat/completions` - Chat completions (proxied to OpenClaw)
|
||||||
|
- `GET /v1/models` - List available models
|
||||||
|
|
||||||
|
## LDAP Configuration
|
||||||
|
|
||||||
|
The LDAP integration supports standard Active Directory and OpenLDAP setups:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# LDAP Server URL
|
||||||
|
LDAP_URL=ldap://ldap.example.com:389
|
||||||
|
|
||||||
|
# Base DN for user search
|
||||||
|
LDAP_BASE_DN=ou=users,dc=example,dc=com
|
||||||
|
|
||||||
|
# Bind DN for searching (if needed)
|
||||||
|
LDAP_BIND_DN=cn=admin,dc=example,dc=com
|
||||||
|
LDAP_BIND_PASSWORD=admin_password
|
||||||
|
|
||||||
|
# Custom search filter (optional)
|
||||||
|
LDAP_SEARCH_FILTER=(uid={{username}})
|
||||||
|
```
|
||||||
|
|
||||||
|
Users authenticate with their LDAP username and password.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run in development mode
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# This starts:
|
||||||
|
# - Backend server on port 3000 (with hot reload)
|
||||||
|
# - Vite dev server on port 5173 (with HMR)
|
||||||
|
# - Proxy from Vite to backend for /api and /v1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build frontend
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Start production server
|
||||||
|
NODE_ENV=production npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
The production server serves static files from `dist/` and handles all API routes.
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
openclaw-webui/
|
||||||
|
├── client/
|
||||||
|
│ ├── index.html
|
||||||
|
│ ├── main.js # Main application
|
||||||
|
│ └── public/
|
||||||
|
│ ├── styles.css
|
||||||
|
│ └── favicon.svg
|
||||||
|
├── server/
|
||||||
|
│ └── index.js # Express server
|
||||||
|
├── data/ # Chat history (gitignored)
|
||||||
|
├── dist/ # Production build (gitignored)
|
||||||
|
├── package.json
|
||||||
|
├── vite.config.js
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License - See LICENSE file for details.
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
|
||||||
|
Built for [OpenClaw](https://github.com/openclaw/openclaw) - AI assistant framework.
|
||||||
14
client/index.html
Normal file
14
client/index.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>OpenClaw WebUI</title>
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
779
client/main.js
Normal file
779
client/main.js
Normal file
@@ -0,0 +1,779 @@
|
|||||||
|
/**
|
||||||
|
* OpenClaw WebUI - Main Application
|
||||||
|
*
|
||||||
|
* A modern chat interface for OpenClaw with:
|
||||||
|
* - Multi-file upload
|
||||||
|
* - Code canvas
|
||||||
|
* - Chat history
|
||||||
|
* - Streaming responses
|
||||||
|
* - LDAP SSO
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ==================== State Management ====================
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
user: null,
|
||||||
|
conversations: [],
|
||||||
|
currentConversation: null,
|
||||||
|
messages: [],
|
||||||
|
isLoading: false,
|
||||||
|
models: [],
|
||||||
|
selectedModel: 'main',
|
||||||
|
files: [],
|
||||||
|
canvasOpen: false,
|
||||||
|
canvasContent: '',
|
||||||
|
canvasLanguage: 'javascript'
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== API Client ====================
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
async request(path, options = {}) {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
|
},
|
||||||
|
body: options.body ? JSON.stringify(options.body) : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||||
|
throw new Error(err.error || 'Request failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 204) return null;
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
async getStatus() {
|
||||||
|
return this.request('/api/auth/status');
|
||||||
|
},
|
||||||
|
|
||||||
|
async login(username, password) {
|
||||||
|
return this.request('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { username, password }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async logout() {
|
||||||
|
return this.request('/api/auth/logout', { method: 'POST' });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Conversations
|
||||||
|
async getConversations() {
|
||||||
|
return this.request('/api/conversations');
|
||||||
|
},
|
||||||
|
|
||||||
|
async createConversation(title) {
|
||||||
|
return this.request('/api/conversations', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { title }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateConversation(id, data) {
|
||||||
|
return this.request(`/api/conversations/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: data
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteConversation(id) {
|
||||||
|
return this.request(`/api/conversations/${id}`, { method: 'DELETE' });
|
||||||
|
},
|
||||||
|
|
||||||
|
async getMessages(convId) {
|
||||||
|
return this.request(`/api/conversations/${convId}/messages`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveMessage(convId, message) {
|
||||||
|
return this.request(`/api/conversations/${convId}/messages`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: message
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Models
|
||||||
|
async getModels() {
|
||||||
|
return this.request('/api/models');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Upload
|
||||||
|
async uploadFile(file) {
|
||||||
|
const res = await fetch(`/api/upload?filename=${encodeURIComponent(file.name)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': file.type },
|
||||||
|
body: file
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== Markdown Parser (Simple) ====================
|
||||||
|
|
||||||
|
function parseMarkdown(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
|
||||||
|
// Escape HTML
|
||||||
|
text = text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
|
||||||
|
// Code blocks (with language)
|
||||||
|
text = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => {
|
||||||
|
const langClass = lang ? `language-${lang}` : '';
|
||||||
|
return `<pre class="code-block ${langClass}" data-language="${lang}"><code>${code}</code></pre>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Inline code
|
||||||
|
text = text.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>');
|
||||||
|
|
||||||
|
// Bold
|
||||||
|
text = text.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||||
|
|
||||||
|
// Italic
|
||||||
|
text = text.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
||||||
|
|
||||||
|
// Links
|
||||||
|
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
||||||
|
|
||||||
|
// Line breaks
|
||||||
|
text = text.replace(/\n/g, '<br>');
|
||||||
|
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Streaming Chat ====================
|
||||||
|
|
||||||
|
async function streamChat(messages, onToken, onComplete, onError) {
|
||||||
|
const body = {
|
||||||
|
model: `openclaw:${state.selectedModel}`,
|
||||||
|
stream: true,
|
||||||
|
messages: messages.map(m => ({
|
||||||
|
role: m.role,
|
||||||
|
content: m.content
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer a41984619a5f4b9bf9148ab6eb4abca53eb796d046cbbec5',
|
||||||
|
'x-openclaw-agent-id': state.selectedModel
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||||
|
throw new Error(err.error?.message || err.error || 'Request failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let fullContent = '';
|
||||||
|
|
||||||
|
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 json = JSON.parse(data);
|
||||||
|
const content = json.choices?.[0]?.delta?.content;
|
||||||
|
if (content) {
|
||||||
|
fullContent += content;
|
||||||
|
onToken(content, fullContent);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore parse errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onComplete(fullContent);
|
||||||
|
} catch (err) {
|
||||||
|
onError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== UI Components ====================
|
||||||
|
|
||||||
|
function render(selector, html) {
|
||||||
|
document.querySelector(selector).innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderApp() {
|
||||||
|
const app = document.getElementById('app');
|
||||||
|
|
||||||
|
if (!state.user) {
|
||||||
|
app.innerHTML = renderLoginPage();
|
||||||
|
} else {
|
||||||
|
app.innerHTML = renderMainPage();
|
||||||
|
attachEventListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLoginPage() {
|
||||||
|
return `
|
||||||
|
<div class="login-container">
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-header">
|
||||||
|
<svg class="logo" viewBox="0 0 100 100" width="64" height="64">
|
||||||
|
<circle cx="50" cy="50" r="45" fill="none" stroke="var(--primary)" stroke-width="4"/>
|
||||||
|
<path d="M30 50 L45 65 L70 35" stroke="var(--primary)" stroke-width="6" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<h1>OpenClaw WebUI</h1>
|
||||||
|
<p>Sign in to continue</p>
|
||||||
|
</div>
|
||||||
|
<form id="login-form" class="login-form">
|
||||||
|
<input type="text" id="login-username" placeholder="Username" autocomplete="username" required>
|
||||||
|
<input type="password" id="login-password" placeholder="Password" autocomplete="current-password" required>
|
||||||
|
<button type="submit" class="btn-primary">Sign In</button>
|
||||||
|
</form>
|
||||||
|
<div id="login-error" class="login-error hidden"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMainPage() {
|
||||||
|
return `
|
||||||
|
<div class="app-container">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<button id="new-chat-btn" class="btn-new-chat">
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20">
|
||||||
|
<path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
|
||||||
|
</svg>
|
||||||
|
New Chat
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-content">
|
||||||
|
<div id="conversations-list" class="conversations-list">
|
||||||
|
${renderConversationsList()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div class="user-info">
|
||||||
|
<div class="user-avatar">${state.user.username?.[0]?.toUpperCase() || '?'}</div>
|
||||||
|
<div class="user-name">${state.user.displayName || state.user.username}</div>
|
||||||
|
</div>
|
||||||
|
<button id="logout-btn" class="btn-logout" title="Sign out">
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20">
|
||||||
|
<path fill="currentColor" d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main Chat Area -->
|
||||||
|
<main class="main-content">
|
||||||
|
<div class="chat-header">
|
||||||
|
<div class="model-selector">
|
||||||
|
<label for="model-select">Model:</label>
|
||||||
|
<select id="model-select">
|
||||||
|
${state.models.map(m => `
|
||||||
|
<option value="${m.id}" ${m.id === state.selectedModel ? 'selected' : ''}>
|
||||||
|
${m.name || m.id}
|
||||||
|
</option>
|
||||||
|
`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button id="canvas-toggle" class="btn-canvas ${state.canvasOpen ? 'active' : ''}">
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20">
|
||||||
|
<path fill="currentColor" d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"/>
|
||||||
|
</svg>
|
||||||
|
Code Canvas
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chat-container">
|
||||||
|
<div id="messages-container" class="messages-container">
|
||||||
|
${state.messages.length === 0 ? renderEmptyState() : renderMessages()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-container">
|
||||||
|
<div id="files-preview" class="files-preview"></div>
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<label for="file-input" class="btn-attach" title="Attach files">
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20">
|
||||||
|
<path fill="currentColor" d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z"/>
|
||||||
|
</svg>
|
||||||
|
</label>
|
||||||
|
<input type="file" id="file-input" multiple accept="*/*" style="display:none">
|
||||||
|
<textarea id="message-input" placeholder="Message OpenClaw..." rows="1"></textarea>
|
||||||
|
<button id="send-btn" class="btn-send" title="Send">
|
||||||
|
<svg viewBox="0 0 24 24" width="24" height="24">
|
||||||
|
<path fill="currentColor" d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Code Canvas Panel -->
|
||||||
|
<div id="canvas-panel" class="canvas-panel ${state.canvasOpen ? 'open' : ''}">
|
||||||
|
<div class="canvas-header">
|
||||||
|
<select id="canvas-language">
|
||||||
|
<option value="javascript">JavaScript</option>
|
||||||
|
<option value="python">Python</option>
|
||||||
|
<option value="html">HTML</option>
|
||||||
|
<option value="css">CSS</option>
|
||||||
|
<option value="json">JSON</option>
|
||||||
|
<option value="markdown">Markdown</option>
|
||||||
|
<option value="bash">Bash</option>
|
||||||
|
<option value="sql">SQL</option>
|
||||||
|
</select>
|
||||||
|
<button id="canvas-copy" class="btn-canvas-action" title="Copy">
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16">
|
||||||
|
<path fill="currentColor" d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button id="canvas-close" class="btn-canvas-action" title="Close">
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16">
|
||||||
|
<path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea id="canvas-content" class="canvas-editor" spellcheck="false"></textarea>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderConversationsList() {
|
||||||
|
if (state.conversations.length === 0) {
|
||||||
|
return '<div class="no-conversations">No conversations yet</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return state.conversations.map(conv => `
|
||||||
|
<div class="conversation-item ${conv.id === state.currentConversation?.id ? 'active' : ''}" data-id="${conv.id}">
|
||||||
|
<div class="conv-title">${escapeHtml(conv.title)}</div>
|
||||||
|
<div class="conv-date">${formatDate(conv.updatedAt)}</div>
|
||||||
|
<button class="conv-delete" data-id="${conv.id}" title="Delete">
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16">
|
||||||
|
<path fill="currentColor" d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEmptyState() {
|
||||||
|
return `
|
||||||
|
<div class="empty-state">
|
||||||
|
<svg viewBox="0 0 100 100" width="80" height="80">
|
||||||
|
<circle cx="50" cy="50" r="45" fill="none" stroke="var(--text-muted)" stroke-width="2"/>
|
||||||
|
<circle cx="50" cy="40" r="12" fill="none" stroke="var(--text-muted)" stroke-width="2"/>
|
||||||
|
<path d="M35 55 Q50 70 65 55" fill="none" stroke="var(--text-muted)" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
<h2>Welcome to OpenClaw</h2>
|
||||||
|
<p>Start a conversation or upload files to begin</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMessages() {
|
||||||
|
return state.messages.map(msg => renderMessage(msg)).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMessage(msg) {
|
||||||
|
const isUser = msg.role === 'user';
|
||||||
|
const content = msg.content || '';
|
||||||
|
|
||||||
|
let filesHtml = '';
|
||||||
|
if (msg.files?.length) {
|
||||||
|
filesHtml = `<div class="message-files">
|
||||||
|
${msg.files.map(f => `
|
||||||
|
<div class="attached-file">
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16">
|
||||||
|
<path fill="currentColor" d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"/>
|
||||||
|
</svg>
|
||||||
|
${escapeHtml(f.name)}
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="message ${isUser ? 'user' : 'assistant'}">
|
||||||
|
<div class="message-avatar">${isUser ? (state.user.username?.[0]?.toUpperCase() || 'U') : '⚡'}</div>
|
||||||
|
<div class="message-content">
|
||||||
|
${filesHtml}
|
||||||
|
<div class="message-text">${parseMarkdown(content)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Event Handlers ====================
|
||||||
|
|
||||||
|
function attachEventListeners() {
|
||||||
|
// Login form
|
||||||
|
document.getElementById('login-form')?.addEventListener('submit', handleLogin);
|
||||||
|
|
||||||
|
// Logout
|
||||||
|
document.getElementById('logout-btn')?.addEventListener('click', handleLogout);
|
||||||
|
|
||||||
|
// New chat
|
||||||
|
document.getElementById('new-chat-btn')?.addEventListener('click', handleNewChat);
|
||||||
|
|
||||||
|
// Model selection
|
||||||
|
document.getElementById('model-select')?.addEventListener('change', (e) => {
|
||||||
|
state.selectedModel = e.target.value;
|
||||||
|
localStorage.setItem('selected-model', state.selectedModel);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Message input
|
||||||
|
const input = document.getElementById('message-input');
|
||||||
|
input?.addEventListener('keydown', handleInputKeydown);
|
||||||
|
input?.addEventListener('input', autoResizeTextarea);
|
||||||
|
|
||||||
|
// Send button
|
||||||
|
document.getElementById('send-btn')?.addEventListener('click', handleSendMessage);
|
||||||
|
|
||||||
|
// File input
|
||||||
|
document.getElementById('file-input')?.addEventListener('change', handleFileSelect);
|
||||||
|
|
||||||
|
// Canvas toggle
|
||||||
|
document.getElementById('canvas-toggle')?.addEventListener('click', toggleCanvas);
|
||||||
|
document.getElementById('canvas-close')?.addEventListener('click', () => toggleCanvas(false));
|
||||||
|
document.getElementById('canvas-copy')?.addEventListener('click', copyCanvasContent);
|
||||||
|
|
||||||
|
// Conversation clicks
|
||||||
|
document.getElementById('conversations-list')?.addEventListener('click', handleConversationClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogin(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const username = document.getElementById('login-username').value;
|
||||||
|
const password = document.getElementById('login-password').value;
|
||||||
|
const errorDiv = document.getElementById('login-error');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.login(username, password);
|
||||||
|
if (result.success) {
|
||||||
|
state.user = result.user;
|
||||||
|
await loadInitialData();
|
||||||
|
renderApp();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errorDiv.textContent = err.message;
|
||||||
|
errorDiv.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await api.logout();
|
||||||
|
state.user = null;
|
||||||
|
renderApp();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNewChat() {
|
||||||
|
const conv = await api.createConversation('New Chat');
|
||||||
|
state.conversations.unshift(conv);
|
||||||
|
state.currentConversation = conv;
|
||||||
|
state.messages = [];
|
||||||
|
renderApp();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConversationClick(e) {
|
||||||
|
const deleteBtn = e.target.closest('.conv-delete');
|
||||||
|
if (deleteBtn) {
|
||||||
|
e.stopPropagation();
|
||||||
|
await handleDeleteConversation(deleteBtn.dataset.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = e.target.closest('.conversation-item');
|
||||||
|
if (item) {
|
||||||
|
await selectConversation(item.dataset.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteConversation(id) {
|
||||||
|
if (!confirm('Delete this conversation?')) return;
|
||||||
|
|
||||||
|
await api.deleteConversation(id);
|
||||||
|
state.conversations = state.conversations.filter(c => c.id !== id);
|
||||||
|
|
||||||
|
if (state.currentConversation?.id === id) {
|
||||||
|
state.currentConversation = state.conversations[0] || null;
|
||||||
|
state.messages = state.currentConversation ? await api.getMessages(state.currentConversation.id) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
renderApp();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectConversation(id) {
|
||||||
|
state.currentConversation = state.conversations.find(c => c.id === id);
|
||||||
|
state.messages = await api.getMessages(id);
|
||||||
|
renderApp();
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInputKeydown(e) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSendMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoResizeTextarea(e) {
|
||||||
|
const textarea = e.target;
|
||||||
|
textarea.style.height = 'auto';
|
||||||
|
textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFileSelect(e) {
|
||||||
|
const files = Array.from(e.target.files);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
const uploaded = await api.uploadFile(file);
|
||||||
|
state.files.push({
|
||||||
|
id: uploaded.id,
|
||||||
|
name: file.name,
|
||||||
|
type: file.type,
|
||||||
|
size: file.size
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to upload file:', file.name, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderFilesPreview();
|
||||||
|
e.target.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFilesPreview() {
|
||||||
|
const container = document.getElementById('files-preview');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.innerHTML = state.files.map(f => `
|
||||||
|
<div class="file-preview" data-id="${f.id}">
|
||||||
|
<span>${escapeHtml(f.name)}</span>
|
||||||
|
<button class="file-remove" data-id="${f.id}">×</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
container.querySelectorAll('.file-remove').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
state.files = state.files.filter(f => f.id !== btn.dataset.id);
|
||||||
|
renderFilesPreview();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSendMessage() {
|
||||||
|
const input = document.getElementById('message-input');
|
||||||
|
const content = input.value.trim();
|
||||||
|
|
||||||
|
if (!content && state.files.length === 0) return;
|
||||||
|
if (state.isLoading) return;
|
||||||
|
|
||||||
|
// Create conversation if needed
|
||||||
|
if (!state.currentConversation) {
|
||||||
|
const conv = await api.createConversation(content.substring(0, 50));
|
||||||
|
state.conversations.unshift(conv);
|
||||||
|
state.currentConversation = conv;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build message
|
||||||
|
const userMessage = {
|
||||||
|
role: 'user',
|
||||||
|
content: buildMessageContent(content, state.files),
|
||||||
|
files: state.files.length ? [...state.files] : null
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add user message
|
||||||
|
state.messages.push(userMessage);
|
||||||
|
await api.saveMessage(state.currentConversation.id, userMessage);
|
||||||
|
|
||||||
|
// Clear input
|
||||||
|
input.value = '';
|
||||||
|
input.style.height = 'auto';
|
||||||
|
state.files = [];
|
||||||
|
renderFilesPreview();
|
||||||
|
|
||||||
|
// Add placeholder for assistant response
|
||||||
|
const assistantMessage = { role: 'assistant', content: '' };
|
||||||
|
state.messages.push(assistantMessage);
|
||||||
|
|
||||||
|
state.isLoading = true;
|
||||||
|
updateSendButton(true);
|
||||||
|
renderApp();
|
||||||
|
scrollToBottom();
|
||||||
|
|
||||||
|
// Stream response
|
||||||
|
const messagesForApi = state.messages.slice(0, -1).map(m => ({
|
||||||
|
role: m.role,
|
||||||
|
content: m.content
|
||||||
|
}));
|
||||||
|
|
||||||
|
await streamChat(
|
||||||
|
messagesForApi,
|
||||||
|
(token, full) => {
|
||||||
|
assistantMessage.content = full;
|
||||||
|
updateLastMessage(full);
|
||||||
|
},
|
||||||
|
async (fullContent) => {
|
||||||
|
state.isLoading = false;
|
||||||
|
updateSendButton(false);
|
||||||
|
await api.saveMessage(state.currentConversation.id, assistantMessage);
|
||||||
|
|
||||||
|
// Update conversation title if first message
|
||||||
|
if (state.messages.length === 2) {
|
||||||
|
const title = content.substring(0, 50) + (content.length > 50 ? '...' : '');
|
||||||
|
await api.updateConversation(state.currentConversation.id, { title });
|
||||||
|
state.currentConversation.title = title;
|
||||||
|
document.querySelector('.conv-title[data-id="' + state.currentConversation.id + '"]')?.textContent = title;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
state.isLoading = false;
|
||||||
|
updateSendButton(false);
|
||||||
|
assistantMessage.content = `**Error:** ${err.message}`;
|
||||||
|
updateLastMessage(assistantMessage.content);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMessageContent(text, files) {
|
||||||
|
if (!files.length) return text;
|
||||||
|
|
||||||
|
const fileDescriptions = files.map(f => `[Attached: ${f.name}]`).join('\n');
|
||||||
|
return `${text}\n\n${fileDescriptions}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLastMessage(content) {
|
||||||
|
const container = document.getElementById('messages-container');
|
||||||
|
const messages = container.querySelectorAll('.message');
|
||||||
|
const last = messages[messages.length - 1];
|
||||||
|
if (last) {
|
||||||
|
const textDiv = last.querySelector('.message-text');
|
||||||
|
if (textDiv) {
|
||||||
|
textDiv.innerHTML = parseMarkdown(content);
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSendButton(loading) {
|
||||||
|
const btn = document.getElementById('send-btn');
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = loading;
|
||||||
|
btn.classList.toggle('loading', loading);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCanvas(open) {
|
||||||
|
state.canvasOpen = open ?? !state.canvasOpen;
|
||||||
|
const panel = document.getElementById('canvas-panel');
|
||||||
|
const btn = document.getElementById('canvas-toggle');
|
||||||
|
|
||||||
|
if (state.canvasOpen) {
|
||||||
|
panel?.classList.add('open');
|
||||||
|
btn?.classList.add('active');
|
||||||
|
} else {
|
||||||
|
panel?.classList.remove('open');
|
||||||
|
btn?.classList.remove('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyCanvasContent() {
|
||||||
|
const content = document.getElementById('canvas-content')?.value;
|
||||||
|
if (content) {
|
||||||
|
navigator.clipboard.writeText(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToBottom() {
|
||||||
|
const container = document.getElementById('messages-container');
|
||||||
|
if (container) {
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Utilities ====================
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(ts) {
|
||||||
|
const date = new Date(ts);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
if (date.toDateString() === now.toDateString()) {
|
||||||
|
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = now - date;
|
||||||
|
if (diff < 7 * 24 * 60 * 60 * 1000) {
|
||||||
|
return date.toLocaleDateString([], { weekday: 'short' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Initialization ====================
|
||||||
|
|
||||||
|
async function loadInitialData() {
|
||||||
|
try {
|
||||||
|
const [convRes, modelsRes] = await Promise.all([
|
||||||
|
api.getConversations(),
|
||||||
|
api.getModels()
|
||||||
|
]);
|
||||||
|
|
||||||
|
state.conversations = convRes || [];
|
||||||
|
state.models = (modelsRes?.data || []).map(m => ({
|
||||||
|
id: m.id,
|
||||||
|
name: m.name || m.id
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Restore selected model
|
||||||
|
const savedModel = localStorage.getItem('selected-model');
|
||||||
|
if (savedModel && state.models.find(m => m.id === savedModel)) {
|
||||||
|
state.selectedModel = savedModel;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load initial data:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
try {
|
||||||
|
const result = await api.getStatus();
|
||||||
|
if (result.authenticated) {
|
||||||
|
state.user = result.user;
|
||||||
|
await loadInitialData();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to check auth status:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderApp();
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
4
client/public/favicon.svg
Normal file
4
client/public/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<circle cx="50" cy="50" r="45" fill="none" stroke="#6366f1" stroke-width="6"/>
|
||||||
|
<path d="M28 50 L43 65 L72 30" stroke="#6366f1" stroke-width="8" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 278 B |
752
client/public/styles.css
Normal file
752
client/public/styles.css
Normal file
@@ -0,0 +1,752 @@
|
|||||||
|
/**
|
||||||
|
* OpenClaw WebUI - Styles
|
||||||
|
* Dark theme inspired by OpenWebUI
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-primary: #0f0f0f;
|
||||||
|
--bg-secondary: #1a1a1a;
|
||||||
|
--bg-tertiary: #242424;
|
||||||
|
--bg-hover: #2a2a2a;
|
||||||
|
--bg-active: #333;
|
||||||
|
--text-primary: #e0e0e0;
|
||||||
|
--text-secondary: #a0a0a0;
|
||||||
|
--text-muted: #666;
|
||||||
|
--border-color: #333;
|
||||||
|
--primary: #6366f1;
|
||||||
|
--primary-hover: #818cf8;
|
||||||
|
--accent: #22c55e;
|
||||||
|
--danger: #ef4444;
|
||||||
|
--user-msg-bg: #2f2f2f;
|
||||||
|
--assistant-msg-bg: #1a1a1a;
|
||||||
|
--code-bg: #0d0d0d;
|
||||||
|
--font-mono: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', monospace;
|
||||||
|
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
--radius-sm: 6px;
|
||||||
|
--radius-md: 10px;
|
||||||
|
--radius-lg: 16px;
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0,0,0,0.3);
|
||||||
|
--shadow-md: 0 4px 12px rgba(0,0,0,0.4);
|
||||||
|
--transition: 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== Login Page ==================== */
|
||||||
|
|
||||||
|
.login-container {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 40px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header .logo {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header h1 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form input {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 14px 16px;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: border-color var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error {
|
||||||
|
color: var(--danger);
|
||||||
|
text-align: center;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== Buttons ==================== */
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
padding: 14px 20px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-new-chat {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px dashed var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-new-chat:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-send {
|
||||||
|
background: var(--primary);
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-send:hover:not(:disabled) {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-send:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-send.loading {
|
||||||
|
animation: pulse 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-attach {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-attach:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-canvas {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-canvas:hover,
|
||||||
|
.btn-canvas.active {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-logout {
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-logout:hover {
|
||||||
|
background: var(--danger);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-canvas-action {
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-canvas-action:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== Main Layout ==================== */
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== Sidebar ==================== */
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 280px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== Conversations List ==================== */
|
||||||
|
|
||||||
|
.conversations-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
transition: background var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-item:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-item.active {
|
||||||
|
background: var(--bg-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conv-title {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding-right: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conv-date {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conv-delete {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 8px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-item:hover .conv-delete {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conv-delete:hover {
|
||||||
|
color: var(--danger);
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-conversations {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 20px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== Main Content ==================== */
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-selector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-selector label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-selector select {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 6px 10px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-selector select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== Chat Container ==================== */
|
||||||
|
|
||||||
|
.chat-container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state svg {
|
||||||
|
opacity: 0.5;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== Messages ==================== */
|
||||||
|
|
||||||
|
.message {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
max-width: 900px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-avatar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.user .message-avatar {
|
||||||
|
background: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-text {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border-top-left-radius: var(--radius-sm);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.user .message-text {
|
||||||
|
background: var(--user-msg-bg);
|
||||||
|
border-top-left-radius: var(--radius-lg);
|
||||||
|
border-top-right-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-files {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attached-file {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== Code Blocks ==================== */
|
||||||
|
|
||||||
|
.code-block {
|
||||||
|
background: var(--code-bg);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 16px;
|
||||||
|
margin: 12px 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-code {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== Input Area ==================== */
|
||||||
|
|
||||||
|
.input-container {
|
||||||
|
padding: 16px 20px 24px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-preview {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-remove {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-remove:hover {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper:focus-within {
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
#message-input {
|
||||||
|
flex: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
resize: none;
|
||||||
|
min-height: 24px;
|
||||||
|
max-height: 200px;
|
||||||
|
padding: 8px 4px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
#message-input:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#message-input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== Code Canvas ==================== */
|
||||||
|
|
||||||
|
.canvas-panel {
|
||||||
|
position: absolute;
|
||||||
|
right: -400px;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 400px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-left: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: right var(--transition);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-panel.open {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
#canvas-language {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 6px 10px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-editor {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--code-bg);
|
||||||
|
border: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 16px;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-editor:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== Scrollbar ==================== */
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== Responsive ==================== */
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
left: -280px;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 100;
|
||||||
|
transition: left var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.open {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-panel {
|
||||||
|
width: 100%;
|
||||||
|
right: -100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== Syntax Highlighting (Basic) ==================== */
|
||||||
|
|
||||||
|
.language-javascript .keyword { color: #c678dd; }
|
||||||
|
.language-javascript .string { color: #98c379; }
|
||||||
|
.language-javascript .number { color: #d19a66; }
|
||||||
|
.language-javascript .comment { color: #5c6370; }
|
||||||
|
.language-javascript .function { color: #61afef; }
|
||||||
|
|
||||||
|
.language-python .keyword { color: #c678dd; }
|
||||||
|
.language-python .string { color: #98c379; }
|
||||||
|
.language-python .number { color: #d19a66; }
|
||||||
|
.language-python .comment { color: #5c6370; }
|
||||||
|
.language-python .function { color: #61afef; }
|
||||||
32
package.json
Normal file
32
package.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "openclaw-webui",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "OpenWebUI-compatible chat interface for OpenClaw with LDAP SSO",
|
||||||
|
"main": "server/index.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
|
||||||
|
"dev:server": "node --watch server/index.js",
|
||||||
|
"dev:client": "vite",
|
||||||
|
"build": "vite build && npm run build:server",
|
||||||
|
"build:server": "echo 'Server is ESM, no build needed'",
|
||||||
|
"start": "NODE_ENV=production node server/index.js",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"express-session": "^1.17.3",
|
||||||
|
"http-proxy-middleware": "^2.0.6",
|
||||||
|
"ldapjs": "^3.0.7",
|
||||||
|
"uuid": "^9.0.0",
|
||||||
|
"ws": "^8.16.0",
|
||||||
|
"eventsource": "^2.0.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vite": "^5.0.12",
|
||||||
|
"concurrently": "^8.2.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
36
scripts/create-repo.py
Normal file
36
scripts/create-repo.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Create the Gitea repository for OpenClaw WebUI."""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import sys
|
||||||
|
|
||||||
|
GITEA_URL = "https://git.theta42.com/api/v1"
|
||||||
|
USERNAME = "nova"
|
||||||
|
PASSWORD = "ea21f1d%e2c0fHD93*21ab0"
|
||||||
|
REPO_NAME = "openclaw-webui"
|
||||||
|
|
||||||
|
def create_repo():
|
||||||
|
endpoint = f"{GITEA_URL}/user/repos"
|
||||||
|
data = {
|
||||||
|
"name": REPO_NAME,
|
||||||
|
"description": "OpenWebUI-compatible chat interface for OpenClaw with LDAP SSO",
|
||||||
|
"private": False,
|
||||||
|
"auto_init": False
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(endpoint, auth=(USERNAME, PASSWORD), json=data)
|
||||||
|
|
||||||
|
if response.status_code == 201:
|
||||||
|
repo = response.json()
|
||||||
|
print(f"✅ Repository created: {repo['html_url']}")
|
||||||
|
return repo['html_url']
|
||||||
|
elif response.status_code == 409:
|
||||||
|
print(f"⚠️ Repository already exists")
|
||||||
|
return f"https://git.theta42.com/{USERNAME}/{REPO_NAME}"
|
||||||
|
else:
|
||||||
|
print(f"❌ Failed to create repo: {response.status_code}")
|
||||||
|
print(response.text)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
create_repo()
|
||||||
480
server/index.js
Normal file
480
server/index.js
Normal file
@@ -0,0 +1,480 @@
|
|||||||
|
/**
|
||||||
|
* OpenClaw WebUI - Backend Proxy Server
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - LDAP SSO Authentication
|
||||||
|
* - Proxy to OpenClaw Gateway (WebSocket + HTTP)
|
||||||
|
* - Session management
|
||||||
|
* - Chat history persistence
|
||||||
|
*/
|
||||||
|
|
||||||
|
import express from 'express';
|
||||||
|
import session from 'express-session';
|
||||||
|
import { createProxyMiddleware } from 'http-proxy-middleware';
|
||||||
|
import { WebSocketServer, WebSocket } from 'ws';
|
||||||
|
import { createServer } from 'http';
|
||||||
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
||||||
|
import { join, dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import ldap from 'ldapjs';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
const CONFIG = {
|
||||||
|
port: process.env.PORT || 3000,
|
||||||
|
gatewayUrl: process.env.OPENCLAW_GATEWAY || 'http://127.0.0.1:18789',
|
||||||
|
gatewayToken: process.env.OPENCLAW_TOKEN || 'a41984619a5f4b9bf9148ab6eb4abca53eb796d046cbbec5',
|
||||||
|
sessionSecret: process.env.SESSION_SECRET || 'openclaw-webui-secret-change-in-production',
|
||||||
|
|
||||||
|
// LDAP Configuration
|
||||||
|
ldap: {
|
||||||
|
url: process.env.LDAP_URL || 'ldap://localhost:389',
|
||||||
|
baseDN: process.env.LDAP_BASE_DN || 'ou=users,dc=example,dc=com',
|
||||||
|
bindDN: process.env.LDAP_BIND_DN || '',
|
||||||
|
bindPassword: process.env.LDAP_BIND_PASSWORD || '',
|
||||||
|
searchFilter: process.env.LDAP_SEARCH_FILTER || '(uid={{username}})',
|
||||||
|
enabled: process.env.LDAP_ENABLED === 'true'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Data paths
|
||||||
|
dataDir: process.env.DATA_DIR || join(__dirname, '../data'),
|
||||||
|
|
||||||
|
// Disable auth for development
|
||||||
|
disableAuth: process.env.DISABLE_AUTH === 'true'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ensure data directory exists
|
||||||
|
if (!existsSync(CONFIG.dataDir)) {
|
||||||
|
mkdirSync(CONFIG.dataDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Express app
|
||||||
|
const app = express();
|
||||||
|
const server = createServer(app);
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
|
// Session middleware
|
||||||
|
app.use(session({
|
||||||
|
secret: CONFIG.sessionSecret,
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
cookie: {
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
maxAge: 24 * 60 * 60 * 1000 // 24 hours
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// CORS for development
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||||
|
if (req.method === 'OPTIONS') return res.sendStatus(200);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== LDAP Authentication ====================
|
||||||
|
|
||||||
|
async function authenticateLDAP(username, password) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const client = ldap.createClient({ url: CONFIG.ldap.url });
|
||||||
|
|
||||||
|
client.on('error', (err) => {
|
||||||
|
reject(new Error('LDAP connection failed'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Construct user DN
|
||||||
|
const userDN = `uid=${username},${CONFIG.ldap.baseDN}`;
|
||||||
|
|
||||||
|
client.bind(userDN, password, (err) => {
|
||||||
|
if (err) {
|
||||||
|
client.destroy();
|
||||||
|
reject(new Error('Invalid credentials'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Successfully authenticated - get user info
|
||||||
|
const searchOptions = {
|
||||||
|
scope: 'base',
|
||||||
|
filter: `(uid=${username})`,
|
||||||
|
attributes: ['dn', 'uid', 'cn', 'mail', 'displayName', 'memberOf']
|
||||||
|
};
|
||||||
|
|
||||||
|
client.search(userDN, searchOptions, (err, res) => {
|
||||||
|
if (err) {
|
||||||
|
client.destroy();
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = { username };
|
||||||
|
|
||||||
|
res.on('searchEntry', (entry) => {
|
||||||
|
user.dn = entry.object.dn;
|
||||||
|
user.uid = entry.object.uid;
|
||||||
|
user.cn = entry.object.cn;
|
||||||
|
user.email = entry.object.mail;
|
||||||
|
user.displayName = entry.object.displayName || entry.object.cn;
|
||||||
|
user.groups = entry.object.memberOf || [];
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on('error', (err) => {
|
||||||
|
client.destroy();
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on('end', () => {
|
||||||
|
client.destroy();
|
||||||
|
resolve(user);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Auth Routes ====================
|
||||||
|
|
||||||
|
// Check auth status
|
||||||
|
app.get('/api/auth/status', (req, res) => {
|
||||||
|
if (CONFIG.disableAuth) {
|
||||||
|
return res.json({ authenticated: true, user: { username: 'dev-user', displayName: 'Dev User' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.session.user) {
|
||||||
|
return res.json({ authenticated: true, user: req.session.user });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ authenticated: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Login endpoint
|
||||||
|
app.post('/api/auth/login', async (req, res) => {
|
||||||
|
const { username, password } = req.body;
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
return res.status(400).json({ error: 'Username and password required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Development bypass
|
||||||
|
if (CONFIG.disableAuth) {
|
||||||
|
req.session.user = { username, displayName: username };
|
||||||
|
return res.json({ success: true, user: req.session.user });
|
||||||
|
}
|
||||||
|
|
||||||
|
// LDAP authentication
|
||||||
|
if (CONFIG.ldap.enabled) {
|
||||||
|
try {
|
||||||
|
const user = await authenticateLDAP(username, password);
|
||||||
|
req.session.user = user;
|
||||||
|
res.json({ success: true, user });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(401).json({ error: 'Invalid credentials' });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: simple password check (for development without LDAP)
|
||||||
|
res.status(401).json({ error: 'LDAP not configured' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Logout endpoint
|
||||||
|
app.post('/api/auth/logout', (req, res) => {
|
||||||
|
req.session.destroy();
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auth middleware for protected routes
|
||||||
|
function requireAuth(req, res, next) {
|
||||||
|
if (CONFIG.disableAuth) return next();
|
||||||
|
if (!req.session.user) {
|
||||||
|
return res.status(401).json({ error: 'Authentication required' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Chat History ====================
|
||||||
|
|
||||||
|
function getHistoryPath(userId) {
|
||||||
|
return join(CONFIG.dataDir, `history-${userId}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadHistory(userId) {
|
||||||
|
const path = getHistoryPath(userId);
|
||||||
|
if (!existsSync(path)) return { conversations: [], messages: {} };
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(path, 'utf-8'));
|
||||||
|
} catch {
|
||||||
|
return { conversations: [], messages: {} };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveHistory(userId, data) {
|
||||||
|
const path = getHistoryPath(userId);
|
||||||
|
writeFileSync(path, JSON.stringify(data, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get conversations list
|
||||||
|
app.get('/api/conversations', requireAuth, (req, res) => {
|
||||||
|
const userId = req.session.user?.username || 'dev-user';
|
||||||
|
const history = loadHistory(userId);
|
||||||
|
res.json(history.conversations);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get messages for a conversation
|
||||||
|
app.get('/api/conversations/:id/messages', requireAuth, (req, res) => {
|
||||||
|
const userId = req.session.user?.username || 'dev-user';
|
||||||
|
const history = loadHistory(userId);
|
||||||
|
res.json(history.messages[req.params.id] || []);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create new conversation
|
||||||
|
app.post('/api/conversations', requireAuth, (req, res) => {
|
||||||
|
const userId = req.session.user?.username || 'dev-user';
|
||||||
|
const history = loadHistory(userId);
|
||||||
|
|
||||||
|
const conv = {
|
||||||
|
id: uuidv4(),
|
||||||
|
title: req.body.title || 'New Chat',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
history.conversations.unshift(conv);
|
||||||
|
history.messages[conv.id] = [];
|
||||||
|
saveHistory(userId, history);
|
||||||
|
|
||||||
|
res.json(conv);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update conversation
|
||||||
|
app.put('/api/conversations/:id', requireAuth, (req, res) => {
|
||||||
|
const userId = req.session.user?.username || 'dev-user';
|
||||||
|
const history = loadHistory(userId);
|
||||||
|
|
||||||
|
const conv = history.conversations.find(c => c.id === req.params.id);
|
||||||
|
if (!conv) return res.status(404).json({ error: 'Not found' });
|
||||||
|
|
||||||
|
Object.assign(conv, req.body, { updatedAt: Date.now() });
|
||||||
|
saveHistory(userId, history);
|
||||||
|
|
||||||
|
res.json(conv);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete conversation
|
||||||
|
app.delete('/api/conversations/:id', requireAuth, (req, res) => {
|
||||||
|
const userId = req.session.user?.username || 'dev-user';
|
||||||
|
const history = loadHistory(userId);
|
||||||
|
|
||||||
|
history.conversations = history.conversations.filter(c => c.id !== req.params.id);
|
||||||
|
delete history.messages[req.params.id];
|
||||||
|
saveHistory(userId, history);
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save message to conversation
|
||||||
|
app.post('/api/conversations/:id/messages', requireAuth, (req, res) => {
|
||||||
|
const userId = req.session.user?.username || 'dev-user';
|
||||||
|
const history = loadHistory(userId);
|
||||||
|
|
||||||
|
if (!history.messages[req.params.id]) {
|
||||||
|
history.messages[req.params.id] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg = {
|
||||||
|
id: uuidv4(),
|
||||||
|
role: req.body.role,
|
||||||
|
content: req.body.content,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
...req.body
|
||||||
|
};
|
||||||
|
|
||||||
|
history.messages[req.params.id].push(msg);
|
||||||
|
|
||||||
|
// Update conversation
|
||||||
|
const conv = history.conversations.find(c => c.id === req.params.id);
|
||||||
|
if (conv) {
|
||||||
|
conv.updatedAt = Date.now();
|
||||||
|
// Auto-title from first user message
|
||||||
|
if (msg.role === 'user' && history.messages[req.params.id].length === 1) {
|
||||||
|
conv.title = msg.content.substring(0, 50) + (msg.content.length > 50 ? '...' : '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveHistory(userId, history);
|
||||||
|
res.json(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== OpenClaw Gateway Proxy ====================
|
||||||
|
|
||||||
|
// HTTP proxy for OpenAI-compatible endpoints
|
||||||
|
app.use('/v1', requireAuth, createProxyMiddleware({
|
||||||
|
target: CONFIG.gatewayUrl,
|
||||||
|
changeOrigin: true,
|
||||||
|
onProxyReq: (proxyReq, req, res) => {
|
||||||
|
// Add auth token for OpenClaw gateway
|
||||||
|
proxyReq.setHeader('Authorization', `Bearer ${CONFIG.gatewayToken}`);
|
||||||
|
proxyReq.setHeader('x-openclaw-agent-id', req.headers['x-openclaw-agent-id'] || 'main');
|
||||||
|
},
|
||||||
|
onProxyRes: (proxyRes, req, res) => {
|
||||||
|
// Handle SSE streaming
|
||||||
|
if (proxyRes.headers['content-type']?.includes('text/event-stream')) {
|
||||||
|
proxyRes.headers['cache-control'] = 'no-cache';
|
||||||
|
proxyRes.headers['connection'] = 'keep-alive';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ==================== File Upload ====================
|
||||||
|
|
||||||
|
import { createWriteStream } from 'fs';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
|
||||||
|
const uploads = new Map();
|
||||||
|
|
||||||
|
app.post('/api/upload', requireAuth, express.raw({ type: '*/*', limit: '50mb' }), (req, res) => {
|
||||||
|
const id = uuidv4();
|
||||||
|
const filename = req.query.filename || 'file';
|
||||||
|
const mimeType = req.headers['content-type'] || 'application/octet-stream';
|
||||||
|
|
||||||
|
uploads.set(id, {
|
||||||
|
filename,
|
||||||
|
mimeType,
|
||||||
|
data: req.body,
|
||||||
|
uploadedAt: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up old uploads (older than 1 hour)
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [k, v] of uploads) {
|
||||||
|
if (now - v.uploadedAt > 3600000) uploads.delete(k);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ id, filename, mimeType, size: req.body.length });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/upload/:id', requireAuth, (req, res) => {
|
||||||
|
const upload = uploads.get(req.params.id);
|
||||||
|
if (!upload) return res.status(404).json({ error: 'Not found' });
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', upload.mimeType);
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${upload.filename}"`);
|
||||||
|
res.send(upload.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get upload metadata
|
||||||
|
app.get('/api/upload/:id/meta', requireAuth, (req, res) => {
|
||||||
|
const upload = uploads.get(req.params.id);
|
||||||
|
if (!upload) return res.status(404).json({ error: 'Not found' });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
id: req.params.id,
|
||||||
|
filename: upload.filename,
|
||||||
|
mimeType: upload.mimeType,
|
||||||
|
size: upload.data.length
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== Models & Agents ====================
|
||||||
|
|
||||||
|
app.get('/api/models', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${CONFIG.gatewayUrl}/v1/models`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${CONFIG.gatewayToken}` }
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
res.json(data);
|
||||||
|
} catch (err) {
|
||||||
|
res.json({
|
||||||
|
data: [
|
||||||
|
{ id: 'main', name: 'Main', owned_by: 'openclaw' },
|
||||||
|
{ id: 'huihui', name: 'HuiHui MoE', owned_by: 'openclaw' },
|
||||||
|
{ id: 'gpt-oss', name: 'GPT-OSS 120B', owned_by: 'openclaw' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== Static Files ====================
|
||||||
|
|
||||||
|
// Serve frontend in production
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
app.use(express.static(join(__dirname, '../dist')));
|
||||||
|
|
||||||
|
app.get('*', (req, res) => {
|
||||||
|
res.sendFile(join(__dirname, '../dist/index.html'));
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Development mode - proxy to Vite
|
||||||
|
app.get('/', (req, res) => {
|
||||||
|
res.redirect('http://localhost:5173');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== WebSocket Server ====================
|
||||||
|
|
||||||
|
const wss = new WebSocketServer({ server, path: '/ws' });
|
||||||
|
|
||||||
|
wss.on('connection', (ws, req) => {
|
||||||
|
console.log('WebSocket client connected');
|
||||||
|
|
||||||
|
// Connect to OpenClaw gateway
|
||||||
|
const gatewayUrl = CONFIG.gatewayUrl.replace('http', 'ws');
|
||||||
|
const gatewayWs = new WebSocket(`${gatewayUrl}/ws`);
|
||||||
|
|
||||||
|
let helloReceived = false;
|
||||||
|
|
||||||
|
gatewayWs.on('open', () => {
|
||||||
|
// Wait for challenge and send connect
|
||||||
|
ws.on('message', (data) => {
|
||||||
|
// Forward client messages to gateway
|
||||||
|
gatewayWs.send(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
gatewayWs.on('message', (data) => {
|
||||||
|
// Forward gateway messages to client
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
gatewayWs.on('error', (err) => {
|
||||||
|
console.error('Gateway WS error:', err.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
gatewayWs.on('close', () => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
gatewayWs.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('error', (err) => {
|
||||||
|
console.error('Client WS error:', err.message);
|
||||||
|
gatewayWs.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== Start Server ====================
|
||||||
|
|
||||||
|
server.listen(CONFIG.port, () => {
|
||||||
|
console.log(`
|
||||||
|
╔═══════════════════════════════════════════════════════════╗
|
||||||
|
║ OpenClaw WebUI Server ║
|
||||||
|
╠═══════════════════════════════════════════════════════════╣
|
||||||
|
║ Port: ${CONFIG.port.toString().padEnd(44)}║
|
||||||
|
║ Gateway: ${CONFIG.gatewayUrl.padEnd(44)}║
|
||||||
|
║ LDAP: ${(CONFIG.ldap.enabled ? 'Enabled' : 'Disabled').padEnd(44)}║
|
||||||
|
║ Auth: ${(CONFIG.disableAuth ? 'Disabled (dev mode)' : 'Enabled').padEnd(44)}║
|
||||||
|
╚═══════════════════════════════════════════════════════════╝
|
||||||
|
`);
|
||||||
|
});
|
||||||
22
vite.config.js
Normal file
22
vite.config.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
root: 'client',
|
||||||
|
publicDir: 'public',
|
||||||
|
build: {
|
||||||
|
outDir: '../dist',
|
||||||
|
emptyOutDir: true
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:3000',
|
||||||
|
'/v1': 'http://localhost:3000',
|
||||||
|
'/ws': {
|
||||||
|
target: 'ws://localhost:3000',
|
||||||
|
ws: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user