picoclaw/pkg/channels/websocket/chat.html

1473 lines
51 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="format-detection" content="telephone=no">
<title>PicoClaw · Intelligent Conversation</title>
<link rel="stylesheet" href="/static/css/fontawesome.min.css">
<style>
:root {
--bg-primary: #0f0f0f;
--bg-secondary: #1a1a1a;
--bg-tertiary: #252525;
--accent-primary: #3b82f6;
--accent-secondary: #8b5cf6;
--text-primary: #ffffff;
--text-secondary: #a1a1a1;
--text-tertiary: #737373;
--border-color: rgba(255, 255, 255, 0.08);
--glass-bg: rgba(26, 26, 26, 0.8);
--hover-bg: rgba(255, 255, 255, 0.05);
--success: #10b981;
--error: #ef4444;
}
[data-theme="light"] {
--bg-primary: #ffffff;
--bg-secondary: #f8f9fa;
--bg-tertiary: #e9ecef;
--accent-primary: #2563eb;
--accent-secondary: #7c3aed;
--text-primary: #0f0f0f;
--text-secondary: #525252;
--text-tertiary: #737373;
--border-color: rgba(0, 0, 0, 0.08);
--glass-bg: rgba(255, 255, 255, 0.8);
--hover-bg: rgba(0, 0, 0, 0.03);
--success: #059669;
--error: #dc2626;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
background: var(--bg-primary);
color: var(--text-primary);
height: 100vh;
/* 移动端防止键盘弹出时页面缩放 */
height: 100dvh;
overflow: hidden;
position: relative;
transition: background-color 0.2s ease;
/* 移动端触摸优化 */
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
touch-action: manipulation;
}
/* 移除动画背景和装饰性元素 */
.container {
position: relative;
z-index: 1;
width: 100%;
max-width: 1100px;
height: 100vh;
height: 100dvh;
margin: 0 auto;
padding: 40px 24px;
display: flex;
flex-direction: column;
gap: 20px;
/* 移动端安全区域 */
padding-top: max(40px, env(safe-area-inset-top));
padding-bottom: max(20px, env(safe-area-inset-bottom));
padding-left: max(24px, env(safe-area-inset-left));
padding-right: max(24px, env(safe-area-inset-right));
}
.header {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 14px 20px;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.2s ease;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.header-right {
display: flex;
align-items: center;
gap: 6px;
}
.lang-toggle {
width: 36px;
height: 36px;
border-radius: 8px;
background: transparent;
border: 1px solid transparent;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
user-select: none;
margin-left: 12px;
}
.lang-toggle:hover {
background: var(--hover-bg);
color: var(--text-primary);
}
.theme-toggle {
width: 36px;
height: 36px;
border-radius: 8px;
background: transparent;
border: 1px solid transparent;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
font-size: 14px;
color: var(--text-secondary);
}
.theme-toggle:hover {
background: var(--hover-bg);
color: var(--text-primary);
}
.logo {
height: 32px;
width: auto;
object-fit: contain;
}
.header h1 {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
letter-spacing: -0.3px;
}
.status {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 500;
padding: 6px 12px;
border-radius: 6px;
background: var(--bg-tertiary);
color: var(--text-secondary);
transition: all 0.2s ease;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
transition: all 0.2s ease;
}
.status.connected {
color: var(--text-primary);
}
.status.connected .status-dot {
background: var(--success);
}
.status.disconnected .status-dot {
background: var(--error);
}
.messages {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 24px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
display: flex;
flex-direction: column;
gap: 4px;
position: relative;
/* 移动端滚动优化 */
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
/* Custom scrollbar styles */
.messages::-webkit-scrollbar {
width: 8px;
}
.messages::-webkit-scrollbar-track {
background: transparent;
margin: 12px 0;
}
.messages::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
}
.messages::-webkit-scrollbar-thumb:hover {
background: var(--text-tertiary);
}
.message-group {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 16px;
}
.message-timestamp {
text-align: center;
font-size: 11px;
color: var(--text-tertiary);
margin: 12px 0 8px 0;
user-select: none;
}
.message {
display: flex;
gap: 12px;
opacity: 0;
animation: fadeIn 0.3s ease forwards;
}
@keyframes fadeIn {
to {
opacity: 1;
}
}
.message.user {
flex-direction: row-reverse;
}
.message-avatar {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
flex-shrink: 0;
background: var(--bg-tertiary);
color: var(--text-secondary);
}
.message-content {
max-width: 65%;
padding: 8px 14px;
border-radius: 12px;
word-wrap: break-word;
line-height: 1.5;
font-size: 14px;
}
.message.user .message-content {
background: var(--accent-primary);
color: #ffffff;
}
.message.assistant .message-content {
background: var(--bg-tertiary);
color: var(--text-primary);
}
/* Markdown 样式 */
.message-content h1,
.message-content h2,
.message-content h3 {
margin-top: 16px;
margin-bottom: 8px;
font-weight: 600;
line-height: 1.4;
}
.message.user .message-content h1,
.message.user .message-content h2,
.message.user .message-content h3 {
color: #ffffff;
}
.message.assistant .message-content h1,
.message.assistant .message-content h2,
.message.assistant .message-content h3 {
color: var(--text-primary);
}
.message-content h1 { font-size: 1.4em; }
.message-content h2 { font-size: 1.2em; }
.message-content h3 { font-size: 1.1em; }
.message-content p {
margin-bottom: 10px;
}
.message-content p:last-child {
margin-bottom: 0;
}
.message-content code {
background: rgba(0, 0, 0, 0.2);
padding: 2px 6px;
border-radius: 4px;
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
font-size: 0.9em;
}
.message.user .message-content code {
background: rgba(0, 0, 0, 0.2);
color: #ffffff;
}
.message.assistant .message-content code {
background: var(--bg-secondary);
color: var(--accent-primary);
}
.message-content pre {
background: var(--bg-primary);
border: 1px solid var(--border-color);
padding: 16px;
border-radius: 8px;
overflow-x: auto;
margin: 12px 0;
}
.message-content pre code {
background: transparent;
padding: 0;
font-size: 0.85em;
line-height: 1.5;
}
.message-content ul,
.message-content ol {
margin: 12px 0;
padding-left: 28px;
}
.message-content li {
margin: 8px 0;
line-height: 1.7;
}
.message-content li::marker {
color: var(--accent-cyan);
}
.message-content blockquote {
border-left: 4px solid var(--accent-cyan);
padding-left: 16px;
margin: 16px 0;
color: var(--text-secondary);
font-style: italic;
background: rgba(6, 182, 212, 0.05);
padding: 12px 16px;
border-radius: 0 6px 6px 0;
}
.message-content table {
border-collapse: collapse;
width: 100%;
margin: 16px 0;
border-radius: 6px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.05);
}
.message-content th,
.message-content td {
border: 1px solid rgba(255, 255, 255, 0.05);
padding: 12px 16px;
text-align: left;
}
.message-content th {
background: rgba(6, 182, 212, 0.1);
font-weight: 600;
color: var(--accent-cyan);
}
.message-content td {
background: var(--table-bg);
}
.message-content a {
color: var(--accent-primary);
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 2px;
}
.message-content hr {
border: none;
border-top: 1px solid var(--border-color);
margin: 16px 0;
}
.message-content img {
max-width: 100%;
border-radius: 8px;
margin: 10px 0;
}
.message.system {
justify-content: center;
}
.message.system .message-content {
background: var(--bg-tertiary);
color: var(--text-secondary);
text-align: center;
font-size: 12px;
max-width: 90%;
padding: 8px 14px;
}
.message.system .message-content p {
margin-bottom: 0;
}
.input-area {
padding: 0;
background: transparent;
}
.input-form {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 20px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
transition: all 0.2s ease;
}
.input-bottom {
display: flex;
justify-content: space-between;
align-items: center;
}
.input-form:focus-within {
border-color: var(--accent-primary);
}
.input-actions {
display: flex;
align-items: center;
gap: 8px;
}
.action-button {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border-radius: 6px;
color: var(--text-tertiary);
cursor: pointer;
transition: all 0.2s ease;
font-size: 16px;
/* 移动端触摸优化 */
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
user-select: none;
}
.action-button:hover {
background: var(--hover-bg);
color: var(--text-primary);
}
#messageInput {
flex: 1;
padding: 2px 8px;
background: transparent;
border: none;
font-size: 14px;
color: var(--text-primary);
font-family: inherit;
outline: none;
resize: none;
line-height: 1.5;
min-height: 84px;
max-height: 200px;
/* 移动端输入优化 */
-webkit-appearance: none;
appearance: none;
touch-action: manipulation;
}
#messageInput::placeholder {
color: var(--text-tertiary);
}
#sendButton {
padding: 8px 16px;
background: var(--accent-primary);
color: #ffffff;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
/* 移动端触摸优化 */
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
user-select: none;
}
#sendButton:hover:not(:disabled) {
opacity: 0.9;
}
#sendButton:active:not(:disabled) {
transform: scale(0.98);
}
#sendButton:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Scrollbar styling */
.messages::-webkit-scrollbar {
width: 4px;
}
.messages::-webkit-scrollbar-track {
background: transparent;
}
.messages::-webkit-scrollbar-thumb {
background: var(--bg-tertiary);
border-radius: 2px;
}
.messages::-webkit-scrollbar-thumb:hover {
background: var(--border-color);
}
/* Typing Indicator */
.typing-indicator {
display: none;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: var(--bg-tertiary);
border-radius: 12px;
max-width: fit-content;
}
.typing-indicator.active {
display: flex;
}
.typing-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-tertiary);
animation: typingBounce 1.2s infinite;
}
.typing-dot:nth-child(2) {
animation-delay: 0.2s;
}
.typing-dot:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typingBounce {
0%, 60%, 100% {
transform: translateY(0);
opacity: 0.3;
}
30% {
transform: translateY(-6px);
opacity: 1;
}
}
/* Responsive Design */
@media (max-width: 768px) {
.container {
padding: 12px;
gap: 12px;
}
.header {
padding: 12px 16px;
border-radius: 8px;
}
.header-left {
gap: 12px;
min-width: 0;
flex: 1;
}
.logo {
height: 28px;
flex-shrink: 0;
}
.header h1 {
font-size: 16px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.header-right {
gap: 4px;
flex-shrink: 0;
}
.status {
font-size: 11px;
padding: 4px 8px;
border-radius: 4px;
}
.status span {
display: none;
}
.status-dot {
width: 8px;
height: 8px;
}
.lang-toggle,
.theme-toggle {
width: 32px;
height: 32px;
font-size: 13px;
}
.messages {
padding: 16px 12px;
border-radius: 8px;
}
.message-content {
max-width: 85%;
font-size: 14px;
padding: 10px 12px;
}
.message-avatar {
width: 28px;
height: 28px;
font-size: 12px;
}
.input-form {
padding: 10px 12px;
border-radius: 8px;
}
#messageInput {
font-size: 14px;
min-height: 60px;
max-height: 150px;
padding: 4px 6px;
}
.input-bottom {
flex-wrap: nowrap;
gap: 8px;
}
.input-actions {
gap: 6px;
}
.action-button {
width: 28px;
height: 28px;
font-size: 14px;
}
#sendButton {
padding: 6px 14px;
font-size: 13px;
flex-shrink: 0;
}
/* 优化代码块在移动端的显示 */
.message-content pre {
font-size: 12px;
padding: 12px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.message-content code {
font-size: 12px;
}
/* 优化表格在移动端的显示 */
.message-content table {
font-size: 12px;
display: block;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.message-content th,
.message-content td {
padding: 8px 10px;
}
}
/* 小屏手机优化 */
@media (max-width: 480px) {
.container {
padding: 8px;
gap: 8px;
}
.header {
padding: 10px 12px;
}
.header h1 {
font-size: 14px;
}
.messages {
padding: 12px 8px;
}
.message-content {
max-width: 88%;
font-size: 13px;
}
#messageInput {
font-size: 13px;
min-height: 50px;
}
}
/* 横屏优化 */
@media (max-width: 768px) and (orientation: landscape) {
.container {
padding: 8px;
}
.header {
padding: 8px 12px;
}
.messages {
padding: 12px;
}
#messageInput {
min-height: 40px;
max-height: 80px;
}
}
/* 触控优化 - 增大点击区域 */
@media (hover: none) and (pointer: coarse) {
.action-button,
.theme-toggle,
.lang-toggle {
min-width: 44px;
min-height: 44px;
}
#sendButton {
min-height: 44px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="header-left">
<img src="/assets/logo.jpg" alt="PicoClaw" class="logo">
<h1 id="pageTitle">PicoClaw WebSocket Chat</h1>
</div>
<div class="header-right">
<div class="status disconnected" id="status">
<div class="status-dot"></div>
<span>Connecting...</span>
</div>
<div class="lang-toggle" id="langToggle" title="切换语言">
<span id="langText">EN</span>
</div>
<div class="theme-toggle" id="themeToggle" title="切换主题">
<span id="themeIcon"><i class="fas fa-moon"></i></span>
</div>
</div>
</div>
<div class="messages" id="messages">
<div class="typing-indicator" id="typingIndicator">
<div class="typing-dot"></div>
<div class="typing-dot"></div>
<div class="typing-dot"></div>
</div>
</div>
<div class="input-area">
<form class="input-form" id="messageForm">
<textarea
id="messageInput"
placeholder="请输入Enter键发送Shift+Enter键换行\"/\" 打开自定义技能"
autocomplete="off"
rows="4"
disabled
></textarea>
<div class="input-bottom">
<div class="input-actions">
<button type="button" class="action-button" id="mentionButton" title="提及">
<i class="fas fa-at"></i>
</button>
<button type="button" class="action-button" id="imageButton" title="图片">
<i class="fas fa-image"></i>
</button>
<button type="button" class="action-button" id="attachButton" title="附件">
<i class="fas fa-paperclip"></i>
</button>
</div>
<button type="submit" id="sendButton" disabled>发送</button>
</div>
</form>
</div>
</div>
<script>
// Simple markdown-like formatting without external dependencies
function parseMarkdown(text) {
// Escape HTML to prevent XSS
let html = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
// Code blocks (```)
html = html.replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>');
// Bold (**text**)
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
// Italic (*text*)
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
// Inline code (`code`)
html = html.replace(/`(.+?)`/g, '<code>$1</code>');
// Links [text](url)
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
// Headers
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
// Line breaks
html = html.replace(/\n/g, '<br>');
return html;
}
let ws;
const messagesDiv = document.getElementById('messages');
const messageInput = document.getElementById('messageInput');
const sendButton = document.getElementById('sendButton');
const statusDiv = document.getElementById('status');
const messageForm = document.getElementById('messageForm');
const typingIndicator = document.getElementById('typingIndicator');
const themeToggle = document.getElementById('themeToggle');
const themeIcon = document.getElementById('themeIcon');
const langToggle = document.getElementById('langToggle');
const langText = document.getElementById('langText');
// Language Management
const savedLang = localStorage.getItem('picoclaw-lang') || 'zh';
let currentLang = savedLang;
updateLangText(currentLang);
langToggle.addEventListener('click', () => {
currentLang = currentLang === 'zh' ? 'en' : 'zh';
localStorage.setItem('picoclaw-lang', currentLang);
updateLangText(currentLang);
updateUILanguage(currentLang);
});
function updateLangText(lang) {
langText.textContent = lang === 'zh' ? 'EN' : '中';
langToggle.title = lang === 'zh' ? 'Switch to English' : '切换到中文';
}
function updateUILanguage(lang) {
const translations = {
zh: {
pageTitle: 'PicoClaw WebSocket 对话',
connecting: '连接中...',
connected: '已连接',
disconnected: '已断开',
send: '发送',
placeholder: '请输入Enter键发送Shift+Enter键换行"/" 打开自定义技能',
themeToggle: currentTheme === 'dark' ? '切换到亮色模式' : '切换到暗色模式',
welcome: '欢迎使用 PicoClaw — 极致轻量的个人 AI 助手',
sendFailed: '发送失败,请重试',
mention: '提及',
image: '图片',
attach: '附件'
},
en: {
pageTitle: 'PicoClaw WebSocket Chat',
connecting: 'Connecting...',
connected: 'Connected',
disconnected: 'Disconnected',
send: 'Send',
placeholder: 'Type a message, Enter to send, Shift+Enter for new line, "/" for skills',
themeToggle: currentTheme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode',
welcome: 'Welcome to PicoClaw — Ultra-lightweight personal AI assistant',
sendFailed: 'Send failed, please retry',
mention: 'Mention',
image: 'Image',
attach: 'Attach'
}
};
const t = translations[lang];
document.getElementById('pageTitle').textContent = t.pageTitle;
sendButton.textContent = t.send;
messageInput.placeholder = t.placeholder;
themeToggle.title = t.themeToggle;
// Update action buttons
document.getElementById('mentionButton').title = t.mention;
document.getElementById('imageButton').title = t.image;
document.getElementById('attachButton').title = t.attach;
// Update status text based on current connection state
const statusSpan = statusDiv.querySelector('span');
if (statusDiv.classList.contains('connected')) {
statusSpan.textContent = t.connected;
} else {
statusSpan.textContent = statusDiv.classList.contains('disconnected') ? t.disconnected : t.connecting;
}
// Update system messages
const systemMessages = document.querySelectorAll('.message.system .message-content');
systemMessages.forEach(msg => {
const content = msg.textContent.trim();
if (content.includes('Connected to PicoClaw') || content.includes('欢迎使用') || content.includes('Welcome to')) {
msg.textContent = t.welcome;
}
});
}
// Theme Management
const savedTheme = localStorage.getItem('picoclaw-theme') || 'dark';
let currentTheme = savedTheme;
document.documentElement.setAttribute('data-theme', savedTheme);
updateThemeIcon(savedTheme);
updateUILanguage(currentLang);
themeToggle.addEventListener('click', () => {
currentTheme = document.documentElement.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
currentTheme = newTheme;
document.documentElement.setAttribute('data-theme', newTheme);
localStorage.setItem('picoclaw-theme', newTheme);
updateThemeIcon(newTheme);
updateUILanguage(currentLang);
});
function updateThemeIcon(theme) {
themeIcon.innerHTML = theme === 'dark' ? '<i class="fas fa-sun"></i>' : '<i class="fas fa-moon"></i>';
updateUILanguage(currentLang);
}
let reconnectCount = 0;
let reconnectMessageElement = null;
let connectionAttempted = false; // Track if we've tried to connect
let hasEverConnected = false; // Track if we've successfully connected before
let lastMessageTime = null;
// Scroll to bottom helper
function scrollToBottom() {
requestAnimationFrame(() => {
messagesDiv.scrollTop = messagesDiv.scrollHeight;
});
}
// Format relative time
function getRelativeTime(timestamp) {
const now = Date.now();
const diff = Math.floor((now - timestamp) / 1000); // seconds
const translations = {
zh: {
justNow: '刚刚',
minutesAgo: (n) => `${n}分钟前`,
hoursAgo: (n) => `${n}小时前`,
today: '今天',
yesterday: '昨天'
},
en: {
justNow: 'Just now',
minutesAgo: (n) => `${n} min${n > 1 ? 's' : ''} ago`,
hoursAgo: (n) => `${n} hour${n > 1 ? 's' : ''} ago`,
today: 'Today',
yesterday: 'Yesterday'
}
};
const t = translations[currentLang];
const date = new Date(timestamp);
const dateStr = date.toLocaleTimeString(currentLang === 'zh' ? 'zh-CN' : 'en-US', {
hour: '2-digit',
minute: '2-digit'
});
if (diff < 60) {
return t.justNow;
} else if (diff < 3600) {
const minutes = Math.floor(diff / 60);
return t.minutesAgo(minutes);
} else if (diff < 86400) {
const hours = Math.floor(diff / 3600);
if (hours < 3) {
return t.hoursAgo(hours);
}
return `${t.today} ${dateStr}`;
} else if (diff < 172800) {
return `${t.yesterday} ${dateStr}`;
} else {
return date.toLocaleDateString(currentLang === 'zh' ? 'zh-CN' : 'en-US', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
}
// Check if should show timestamp (5 minutes threshold)
function shouldShowTimestamp(currentTime) {
if (!lastMessageTime) return true;
return (currentTime - lastMessageTime) > 300000; // 5 minutes
}
function connect() {
// Get token from multiple sources (priority order):
// 1. URL parameter: ?token=xxx
// 2. localStorage
// 3. Prompt user
let token = new URLSearchParams(window.location.search).get('token');
if (!token) {
token = localStorage.getItem('picoclaw-token');
}
// If still no token and this is first connection attempt, check if token might be required
// by attempting connection. Server will return 401 if token is needed.
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
let wsUrl = protocol + '//' + window.location.host + '/ws';
// Add token to URL if available
if (token) {
wsUrl += '?token=' + encodeURIComponent(token);
// Save token for future use
localStorage.setItem('picoclaw-token', token);
}
// Close existing connection if any
if (ws && ws.readyState !== WebSocket.CLOSED) {
ws.close();
}
ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('Connected to PicoClaw');
// Reset reconnect count on successful connection
reconnectCount = 0;
const t = currentLang === 'zh'
? { connected: '已连接', welcome: '欢迎使用 PicoClaw — 极致轻量的个人 AI 助手', reconnected: '连接已恢复' }
: { connected: 'Connected', welcome: 'Welcome to PicoClaw — Ultra-lightweight personal AI assistant', reconnected: 'Connection restored' };
statusDiv.innerHTML = '<div class="status-dot"></div><span>' + t.connected + '</span>';
statusDiv.className = 'status connected';
messageInput.disabled = false;
sendButton.disabled = false;
messageInput.focus();
// Show welcome message only on first connection
if (!window.hasConnected) {
addMessage('system', t.welcome, 'system');
window.hasConnected = true;
} else if (reconnectMessageElement) {
// Update reconnection message to show success
updateReconnectMessage(t.reconnected);
reconnectMessageElement = null;
}
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
typingIndicator.classList.remove('active');
addMessage(msg.type, msg.content, msg.sender, msg.timestamp);
} catch (error) {
console.error('Failed to parse message:', error);
}
};
ws.onclose = (event) => {
console.log('Disconnected from PicoClaw', event.code, event.reason, 'hasEverConnected:', hasEverConnected, 'connectionAttempted:', connectionAttempted, 'hasToken:', !!localStorage.getItem('picoclaw-token'));
// Scenario 1: First connection attempt failed without token
// This likely means server requires token (close code 1006 = abnormal closure from HTTP error)
if (!hasEverConnected && connectionAttempted && !localStorage.getItem('picoclaw-token') && event.code === 1006) {
const t = currentLang === 'zh'
? {
authRequired: '连接失败',
authPrompt: '服务器可能需要访问令牌Token\n\n如果已配置 token请输入\n如果未配置 token请留空并点击「确定」',
retry: '重试中...',
noAuth: '正在重试连接...'
}
: {
authRequired: 'Connection Failed',
authPrompt: 'Server may require access token\n\nIf token is configured, enter it;\nIf not configured, leave empty and click OK',
retry: 'Retrying...',
noAuth: 'Retrying connection...'
};
statusDiv.innerHTML = '<div class="status-dot"></div><span>' + t.authRequired + '</span>';
statusDiv.className = 'status disconnected';
messageInput.disabled = true;
sendButton.disabled = true;
// Prompt for token (user can leave empty if server has no token)
setTimeout(() => {
const token = prompt(t.authPrompt);
// User clicked OK (with or without token)
if (token !== null) {
if (token.trim()) {
// User entered a token, save and retry
localStorage.setItem('picoclaw-token', token.trim());
statusDiv.innerHTML = '<div class="status-dot"></div><span>' + t.retry + '</span>';
addMessage('system', t.retry, 'system');
setTimeout(connect, 500);
} else {
// User left empty and clicked OK - server might not require token
addMessage('system', t.noAuth, 'system');
setTimeout(connect, 2000);
}
} else {
// User clicked Cancel - don't retry
const cancelMsg = currentLang === 'zh' ? '已取消连接' : 'Connection cancelled';
addMessage('system', cancelMsg, 'system');
addRetryButton();
}
}, 100);
return;
}
// Scenario 2: Connection failed with token present
// This means the token is invalid (close code 1006 from HTTP 401)
if (event.code === 1006 && localStorage.getItem('picoclaw-token') && !hasEverConnected) {
localStorage.removeItem('picoclaw-token');
const t = currentLang === 'zh'
? {
authRequired: '认证失败',
authPrompt: 'Token 无效或已过期,请重新输入:',
authFailed: '认证失败,请检查令牌是否正确',
tokenRequired: '需要有效的访问令牌才能连接',
retry: '重试中...',
cancelled: '已取消连接'
}
: {
authRequired: 'Authentication Failed',
authPrompt: 'Token invalid or expired, please enter again:',
authFailed: 'Authentication failed, please check your token',
tokenRequired: 'Valid access token required to connect',
retry: 'Retrying...',
cancelled: 'Connection cancelled'
};
statusDiv.innerHTML = '<div class="status-dot"></div><span>' + t.authRequired + '</span>';
statusDiv.className = 'status disconnected';
messageInput.disabled = true;
sendButton.disabled = true;
// Show auth failure message
addMessage('system', t.authFailed, 'system');
// Prompt for token with retry support
setTimeout(() => {
const token = prompt(t.authPrompt);
if (token && token.trim()) {
localStorage.setItem('picoclaw-token', token.trim());
statusDiv.innerHTML = '<div class="status-dot"></div><span>' + t.retry + '</span>';
addMessage('system', t.retry, 'system');
setTimeout(connect, 500);
} else {
// User cancelled
addMessage('system', t.cancelled + ' - ' + t.tokenRequired, 'system');
statusDiv.innerHTML = '<div class="status-dot"></div><span>' + t.cancelled + '</span>';
messageInput.disabled = true;
sendButton.disabled = true;
addRetryButton();
}
}, 100);
return;
}
// Scenario 3: Normal disconnection after successful connection
// Increment reconnect count and try to reconnect
reconnectCount++;
const t = currentLang === 'zh'
? {
disconnected: '已断开',
connectionLost: `连接已中断,正在尝试第 ${reconnectCount} 次重连...`
}
: {
disconnected: 'Disconnected',
connectionLost: `Connection lost, attempting reconnection #${reconnectCount}...`
};
statusDiv.innerHTML = '<div class="status-dot"></div><span>' + t.disconnected + '</span>';
statusDiv.className = 'status disconnected';
messageInput.disabled = true;
sendButton.disabled = true;
typingIndicator.classList.remove('active');
// Show or update disconnection message
if (!reconnectMessageElement) {
reconnectMessageElement = addMessage('system', t.connectionLost, 'system');
} else {
updateReconnectMessage(t.connectionLost);
}
// Attempt to reconnect after 3 seconds
setTimeout(connect, 3000);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error, 'hasEverConnected:', hasEverConnected, 'hasToken:', !!localStorage.getItem('picoclaw-token'));
// Mark that connection has been attempted
connectionAttempted = true;
};
}
function addMessage(type, content, sender, timestamp) {
const currentTime = timestamp || Date.now();
// Add timestamp if needed
if (shouldShowTimestamp(currentTime)) {
const timestampDiv = document.createElement('div');
timestampDiv.className = 'message-timestamp';
timestampDiv.textContent = getRelativeTime(currentTime);
messagesDiv.insertBefore(timestampDiv, typingIndicator);
}
lastMessageTime = currentTime;
const messageDiv = document.createElement('div');
messageDiv.className = 'message ' + sender;
messageDiv.dataset.timestamp = currentTime;
// Add avatar
const avatarDiv = document.createElement('div');
avatarDiv.className = 'message-avatar';
if (sender === 'user') {
avatarDiv.innerHTML = '<i class="fas fa-mitten"></i>';
} else if (sender === 'assistant') {
avatarDiv.innerHTML = '<i class="fas fa-wand-magic-sparkles"></i>';
} else {
avatarDiv.innerHTML = '<i class="fas fa-tower-broadcast"></i>';
}
const contentDiv = document.createElement('div');
contentDiv.className = 'message-content';
// 根据发送者类型决定是否渲染 Markdown
if (sender === 'assistant' || sender === 'system') {
// AI 回复和系统消息渲染 Markdown
contentDiv.innerHTML = parseMarkdown(content);
} else {
// 用户消息保持纯文本
contentDiv.textContent = content;
}
messageDiv.appendChild(avatarDiv);
messageDiv.appendChild(contentDiv);
messagesDiv.insertBefore(messageDiv, typingIndicator);
// Scroll to bottom after DOM update
scrollToBottom();
return messageDiv; // Return the element for later updates
}
function updateReconnectMessage(newContent) {
if (reconnectMessageElement) {
const contentDiv = reconnectMessageElement.querySelector('.message-content');
if (contentDiv) {
// Render Markdown for system messages
contentDiv.innerHTML = parseMarkdown(newContent);
}
}
}
// Add a retry button for reconnection with token
function addRetryButton() {
const t = currentLang === 'zh'
? { retryButton: '点击重新连接', clickToRetry: '点击下方按钮重新输入 Token 并连接' }
: { retryButton: 'Retry Connection', clickToRetry: 'Click button below to re-enter token and connect' };
// Create a message with a retry button
const messageDiv = document.createElement('div');
messageDiv.className = 'message system';
messageDiv.dataset.timestamp = Date.now();
const contentDiv = document.createElement('div');
contentDiv.className = 'message-content';
contentDiv.style.cursor = 'pointer';
contentDiv.style.userSelect = 'none';
contentDiv.innerHTML = parseMarkdown(
t.clickToRetry + '\n\n**[' + t.retryButton + ']**'
);
messageDiv.appendChild(contentDiv);
messagesDiv.insertBefore(messageDiv, typingIndicator);
// Make it clickable
contentDiv.addEventListener('click', () => {
// Remove the retry button message
messageDiv.remove();
// Trigger reconnection with token prompt
promptForToken();
});
scrollToBottom();
}
// Prompt user for token and attempt reconnection
function promptForToken() {
const t = currentLang === 'zh'
? {
authPrompt: '请输入访问令牌Token',
retry: '重试中...',
cancelled: '已取消连接',
tokenRequired: '需要有效的访问令牌才能连接'
}
: {
authPrompt: 'Please enter access token:',
retry: 'Retrying...',
cancelled: 'Connection cancelled',
tokenRequired: 'Valid access token required to connect'
};
const token = prompt(t.authPrompt);
if (token && token.trim()) {
localStorage.setItem('picoclaw-token', token.trim());
statusDiv.innerHTML = '<div class="status-dot"></div><span>' + t.retry + '</span>';
addMessage('system', t.retry, 'system');
setTimeout(connect, 500);
} else {
addMessage('system', t.cancelled + ' - ' + t.tokenRequired, 'system');
addRetryButton();
}
}
function sendMessage(content) {
if (!content.trim() || !ws || ws.readyState !== WebSocket.OPEN) {
return;
}
const msg = {
type: 'chat',
content: content,
sender: 'user',
timestamp: Date.now()
};
try {
ws.send(JSON.stringify(msg));
addMessage('chat', content, 'user');
messageInput.value = '';
// Show typing indicator
typingIndicator.classList.add('active');
scrollToBottom();
} catch (error) {
console.error('Failed to send message:', error);
const t = currentLang === 'zh' ? { sendFailed: '发送失败,请重试' } : { sendFailed: 'Send failed, please retry' };
addMessage('system', t.sendFailed, 'system');
}
}
messageForm.addEventListener('submit', (e) => {
e.preventDefault();
sendMessage(messageInput.value);
});
// Handle Enter key in textarea
messageInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (messageInput.value.trim()) {
sendMessage(messageInput.value);
}
}
});
// Connect on page load
connect();
</script>
</body>
</html>