1473 lines
51 KiB
HTML
1473 lines
51 KiB
HTML
<!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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
|
||
// 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>
|