Files
clawpanel/src/pages/chat.js
晴天 8b690cb6a7 fix(openclaw): update recommended kernel target and delta protocol handling
面板原本只声明 maxProtocol=3 的握手范围,且 chat delta 处理只接受前缀扩展。
连接到 2026.5.12+ 内核时会出现握手兼容问题;agent 输出在内容回滚或重排场景也会
丢失最新文本,前端 streaming 气泡停留在旧前缀。

## Connect 握手协议范围
- 将桌面端 connect frame 的 maxProtocol 从 3 扩展到 4
- 将 Web 模式 connect frame 的 maxProtocol 从 3 扩展到 4
- minProtocol 继续保留 3,用于兼容历史内核
- 范围 [3, 4] 同时覆盖旧内核和 5.12+ 内核,不需要按版本分支

## Chat delta replace 语义
- 当 payload.replace=true 时,无条件覆盖当前 AI 文本缓存
- 保留前缀扩展场景的增量更新逻辑
- 修复内容回滚或重排时文本长度变短导致 UI 不刷新的问题

## 推荐内核版本
- official 推荐目标升到 2026.5.12
- chinese 推荐目标升到 2026.5.12-zh.1
- KERNEL_FLOOR 不变,继续兼容历史安装

## 验证
- cargo check:PASS
- npm run build:PASS
2026-05-15 18:30:04 +08:00

3481 lines
141 KiB
JavaScript
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.
/**
* 聊天页面 - 完整版,对接 OpenClaw Gateway
* 支持流式响应、Markdown 渲染、会话管理、Agent 选择、快捷指令
*/
import { api, invalidate, isTauriRuntime } from '../lib/tauri-api.js'
import { navigate } from '../router.js'
import { wsClient, uuid } from '../lib/ws-client.js'
import { renderMarkdown } from '../lib/markdown.js'
import { saveMessage, saveMessages, getLocalMessages, isStorageAvailable } from '../lib/message-db.js'
import { toast } from '../components/toast.js'
import { showModal, showConfirm } from '../components/modal.js'
import { icon as svgIcon } from '../lib/icons.js'
import { t } from '../lib/i18n.js'
const RENDER_THROTTLE = 30
const STORAGE_SESSION_KEY = 'clawpanel-last-session'
const STORAGE_MODEL_KEY = 'clawpanel-chat-selected-model'
const STORAGE_SIDEBAR_KEY = 'clawpanel-chat-sidebar-open'
const STORAGE_SESSION_NAMES_KEY = 'clawpanel-chat-session-names'
const STORAGE_WORKSPACE_PANEL_KEY = 'clawpanel-chat-workspace-open'
const COMMANDS = [
{ title: 'chat.cmdSession', commands: [
{ cmd: '/new', desc: 'chat.cmdNewSession', action: 'exec' },
{ cmd: '/reset', desc: 'chat.cmdResetSession', action: 'exec' },
{ cmd: '/stop', desc: 'chat.cmdStopGen', action: 'exec' },
]},
{ title: 'chat.cmdModel', commands: [
{ cmd: '/model ', desc: 'chat.cmdSwitchModel', action: 'fill' },
{ cmd: '/model list', desc: 'chat.cmdListModels', action: 'exec' },
{ cmd: '/model status', desc: 'chat.cmdModelStatus', action: 'exec' },
]},
{ title: 'chat.cmdThinkMode', commands: [
{ cmd: '/think off', desc: 'chat.cmdThinkOff', action: 'exec' },
{ cmd: '/think low', desc: 'chat.cmdThinkLow', action: 'exec' },
{ cmd: '/think medium', desc: 'chat.cmdThinkMedium', action: 'exec' },
{ cmd: '/think high', desc: 'chat.cmdThinkHigh', action: 'exec' },
]},
{ title: 'chat.cmdFastMode', commands: [
{ cmd: '/fast', desc: 'chat.cmdFastToggle', action: 'exec' },
{ cmd: '/fast on', desc: 'chat.cmdFastOn', action: 'exec' },
{ cmd: '/fast off', desc: 'chat.cmdFastOff', action: 'exec' },
]},
{ title: 'chat.cmdVerbose', commands: [
{ cmd: '/verbose off', desc: 'chat.cmdVerboseOff', action: 'exec' },
{ cmd: '/verbose low', desc: 'chat.cmdVerboseLow', action: 'exec' },
{ cmd: '/verbose high', desc: 'chat.cmdVerboseHigh', action: 'exec' },
{ cmd: '/reasoning off', desc: 'chat.cmdReasoningOff', action: 'exec' },
{ cmd: '/reasoning low', desc: 'chat.cmdReasoningLow', action: 'exec' },
{ cmd: '/reasoning medium', desc: 'chat.cmdReasoningMedium', action: 'exec' },
{ cmd: '/reasoning high', desc: 'chat.cmdReasoningHigh', action: 'exec' },
]},
{ title: 'chat.cmdInfo', commands: [
{ cmd: '/help', desc: 'chat.cmdHelp', action: 'exec' },
{ cmd: '/status', desc: 'chat.cmdStatus', action: 'exec' },
{ cmd: '/context', desc: 'chat.cmdContext', action: 'exec' },
]},
]
let _sessionKey = null, _page = null, _messagesEl = null, _textarea = null
let _sendBtn = null, _statusDot = null, _typingEl = null, _scrollBtn = null
let _sessionListEl = null, _cmdPanelEl = null, _attachPreviewEl = null, _fileInputEl = null
let _modelSelectEl = null
let _currentAiBubble = null, _currentAiText = '', _currentAiImages = [], _currentAiVideos = [], _currentAiAudios = [], _currentAiFiles = [], _currentAiTools = [], _currentRunId = null
let _isStreaming = false, _isSending = false, _messageQueue = [], _streamStartTime = 0
let _lastRenderTime = 0, _renderPending = false, _lastHistoryHash = ''
let _autoScrollEnabled = true, _lastScrollTop = 0, _touchStartY = 0
let _isLoadingHistory = false
let _streamSafetyTimer = null, _unsubEvent = null, _unsubReady = null, _unsubStatus = null
let _seenRunIds = new Set()
let _pageActive = false
const _toolEventTimes = new Map()
const _toolEventData = new Map()
const _toolRunIndex = new Map()
const _toolEventSeen = new Set()
let _errorTimer = null, _lastErrorMsg = null
let _responseWatchdog = null, _postFinalCheck = null
let _ultimateTimer = null, _sendTimestamp = 0
let _attachments = []
let _hasEverConnected = false
let _availableModels = []
let _primaryModel = ''
let _selectedModel = ''
let _isApplyingModel = false
// ── 托管 Agent ──
const HOSTED_STATUS = { IDLE: 'idle', RUNNING: 'running', WAITING: 'waiting_reply', PAUSED: 'paused', ERROR: 'error' }
const HOSTED_SESSIONS_KEY = 'clawpanel-hosted-agent-sessions'
const HOSTED_SYSTEM_PROMPT = `你是一个托管调度 Agent。你的职责是根据用户设定的目标持续引导 OpenClaw AI Agent 完成任务。
规则:
1. 你每一轮只输出一条简洁的指令1-3 句话),发给 OpenClaw 执行
2. 根据 OpenClaw 的回复评估进展,决定下一步指令
3. 如果任务已完成或无法继续,回复包含"完成"或"停止"来结束循环
4. 不要重复相同的指令,不要输出解释性文字,只输出下一步要执行的指令`
const HOSTED_DEFAULTS = { enabled: false, prompt: '', autoRunAfterTarget: true, stopPolicy: 'self', maxSteps: 50, stepDelayMs: 1200, retryLimit: 2, autoStopMinutes: 0 }
const HOSTED_RUNTIME_DEFAULT = { status: HOSTED_STATUS.IDLE, stepCount: 0, lastRunAt: 0, lastRunId: '', lastError: '', pending: false, errorCount: 0 }
const HOSTED_CONTEXT_MAX = 30
const HOSTED_COMPRESS_THRESHOLD = 20
let _hostedBtn = null, _hostedPanelEl = null, _hostedBadgeEl = null
let _hostedPromptEl = null, _hostedMaxStepsEl = null, _hostedStepDelayEl = null, _hostedRetryLimitEl = null
let _hostedAutoStopEl = null
let _hostedSaveBtn = null, _hostedStopBtn = null, _hostedCloseBtn = null
let _hostedDefaults = null
let _hostedSessionConfig = null
let _hostedBoundSessionKey = null
let _hostedRuntime = { ...HOSTED_RUNTIME_DEFAULT }
let _hostedBusy = false
let _hostedAbort = null
let _hostedLastTargetTs = 0
let _hostedAutoStopTimer = null
let _hostedStartTime = 0
let _workspaceBtn = null, _workspacePanelEl = null, _workspaceAgentBadgeEl = null, _workspaceAgentTitleEl = null
let _workspacePathEl = null, _workspaceCoreListEl = null, _workspaceTreeEl = null, _workspaceCurrentFileEl = null
let _workspaceMetaEl = null, _workspaceEditorEl = null, _workspacePreviewEl = null, _workspaceEmptyEl = null
let _workspaceSaveBtn = null, _workspaceReloadBtn = null, _workspacePreviewBtn = null
let _workspaceInfo = null, _workspaceCoreFiles = [], _workspaceTreeCache = new Map(), _workspaceExpandedDirs = new Set()
let _workspaceCurrentAgentId = 'main', _workspaceCurrentFile = null, _workspacePreviewMode = false, _workspaceDirty = false
let _workspaceLoadedContent = '', _workspaceLoading = false
let _workspaceLoadSeq = 0, _workspaceOpenSeq = 0
export async function render() {
const page = document.createElement('div')
page.className = 'page chat-page'
_pageActive = true
_page = page
page.innerHTML = `
<div class="chat-sidebar" id="chat-sidebar">
<div class="chat-sidebar-header">
<span>${t('chat.sessionList')}</span>
<div class="chat-sidebar-header-actions">
<button class="chat-sidebar-btn" id="btn-toggle-sidebar" title="${t('chat.sessionList')}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<button class="chat-sidebar-btn" id="btn-new-session" title="${t('chat.newSession')}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
</div>
</div>
<div class="chat-session-list" id="chat-session-list"></div>
</div>
<div class="chat-main">
<div class="chat-header">
<div class="chat-status">
<button class="chat-toggle-sidebar" id="btn-toggle-sidebar-main" title="${t('chat.sessionList')}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<span class="status-dot" id="chat-status-dot"></span>
<span class="chat-title" id="chat-title">${t('chat.chatTitle')}</span>
</div>
<div class="chat-header-actions">
<div class="chat-model-group">
<select class="form-input" id="chat-model-select" style="width:200px;max-width:28vw;padding:6px 10px;font-size:var(--font-size-xs)">
<option value="">${t('chat.loadingModels')}</option>
</select>
<button class="btn btn-sm btn-ghost" id="btn-refresh-models" title="${t('chat.refreshModels')}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
</button>
</div>
<button class="btn btn-sm btn-ghost chat-workspace-trigger" id="btn-chat-workspace" title="${t('chat.openWorkspace')}">
${svgIcon('folder', 16)}
<span class="chat-workspace-trigger-label">${t('chat.workspace')}</span>
<span class="chat-workspace-trigger-agent" id="chat-workspace-trigger-agent">main</span>
</button>
<button class="btn btn-sm btn-ghost" id="btn-cmd" title="${t('chat.shortcuts')}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M18 3a3 3 0 00-3 3v12a3 3 0 003 3 3 3 0 003-3 3 3 0 00-3-3H6a3 3 0 00-3 3 3 3 0 003 3 3 3 0 003-3V6a3 3 0 00-3-3 3 3 0 00-3 3 3 3 0 003 3h12a3 3 0 003-3 3 3 0 00-3-3z"/></svg>
</button>
<button class="btn btn-sm btn-ghost" id="btn-reset-session" title="${t('chat.resetSession')}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 102.13-9.36L1 10"/></svg>
</button>
</div>
</div>
<div class="chat-workspace-panel" id="chat-workspace-panel" style="display:none">
<div class="chat-workspace-header">
<div class="chat-workspace-header-copy">
<div class="chat-workspace-title-row">
<strong>${t('chat.workspaceFiles')}</strong>
<span class="chat-workspace-agent-badge" id="chat-workspace-agent-badge">main</span>
</div>
<div class="chat-workspace-agent-title" id="chat-workspace-agent-title"></div>
<div class="chat-workspace-path" id="chat-workspace-path"></div>
</div>
<div class="chat-workspace-header-actions">
<button class="chat-workspace-icon-btn" id="chat-workspace-refresh" title="${t('common.refresh')}">${svgIcon('refresh-cw', 14)}</button>
<button class="chat-workspace-icon-btn" id="chat-workspace-close" title="${t('common.close')}">${svgIcon('x', 14)}</button>
</div>
</div>
<div class="chat-workspace-body">
<div class="chat-workspace-sidebar-pane">
<div class="chat-workspace-section">
<div class="chat-workspace-section-title">${t('chat.coreFiles')}</div>
<div class="chat-workspace-core-list" id="chat-workspace-core-list"></div>
</div>
<div class="chat-workspace-section">
<div class="chat-workspace-section-title">${t('chat.workspaceExplorer')}</div>
<div class="chat-workspace-tree" id="chat-workspace-tree"></div>
</div>
</div>
<div class="chat-workspace-editor-pane">
<div class="chat-workspace-editor-toolbar">
<div class="chat-workspace-current-file" id="chat-workspace-current-file">${t('chat.selectWorkspaceFile')}</div>
<div class="chat-workspace-editor-actions">
<button class="btn btn-sm btn-ghost" id="chat-workspace-reload" disabled>${svgIcon('refresh-cw', 14)} ${t('chat.reloadWorkspaceFile')}</button>
<button class="btn btn-sm btn-ghost" id="chat-workspace-preview-toggle" disabled>${svgIcon('eye', 14)} <span id="chat-workspace-preview-label">${t('chat.previewWorkspaceFile')}</span></button>
<button class="btn btn-sm btn-primary" id="chat-workspace-save" disabled>${t('common.save')}</button>
</div>
</div>
<div class="chat-workspace-editor-meta" id="chat-workspace-editor-meta"></div>
<textarea class="chat-workspace-editor" id="chat-workspace-editor" spellcheck="false" disabled placeholder="${t('chat.selectWorkspaceFile')}"></textarea>
<div class="chat-workspace-preview" id="chat-workspace-preview" style="display:none"></div>
<div class="chat-workspace-empty" id="chat-workspace-empty">${t('chat.workspaceEmptyState')}</div>
</div>
</div>
</div>
<div class="chat-messages" id="chat-messages">
<div class="typing-indicator" id="typing-indicator" style="display:none">
<span></span><span></span><span></span>
<span class="typing-hint"></span>
</div>
</div>
<button class="chat-scroll-btn" id="chat-scroll-btn" style="display:none">↓</button>
<div class="chat-cmd-panel" id="chat-cmd-panel" style="display:none"></div>
<div class="chat-attachments-preview" id="chat-attachments-preview" style="display:none"></div>
<div class="chat-input-area">
<input type="file" id="chat-file-input" accept="image/*" multiple style="display:none">
<button class="chat-attach-btn" id="chat-attach-btn" title="${t('chat.uploadImage')}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/></svg>
</button>
<div class="chat-input-wrapper">
<textarea id="chat-input" rows="1" placeholder="${t('chat.inputPlaceholder')}"></textarea>
</div>
<button class="chat-send-btn" id="chat-send-btn" disabled>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</button>
<button class="chat-hosted-btn btn btn-sm btn-ghost" id="chat-hosted-btn" title="${t('chat.hostedAgent')}">
<span class="chat-hosted-label">⊕</span>
<span class="chat-hosted-badge idle" id="chat-hosted-badge">${t('chat.hostedBadge')}</span>
</button>
</div>
<div class="hosted-agent-panel" id="hosted-agent-panel" style="display:none">
<div class="hosted-agent-header">
<strong>${t('chat.hostedAgent')}</strong>
<button class="hosted-agent-close" id="hosted-agent-close" title="${t('common.close')}">&times;</button>
</div>
<div class="hosted-agent-body">
<div class="form-group">
<label class="form-label" style="color:var(--accent);font-weight:600">${t('chat.taskGoal')}</label>
<textarea class="form-input hosted-agent-prompt" id="hosted-agent-prompt" rows="3" placeholder="${t('chat.taskGoalPlaceholder')}"></textarea>
<div class="form-hint">${t('chat.hostedHint')}</div>
</div>
<div class="ha-slider-group">
<div class="ha-slider-label">${t('chat.maxReplies')} <span class="ha-slider-val" id="ha-steps-val">50</span></div>
<input type="range" class="ha-slider" id="hosted-agent-max-steps" min="5" max="205" step="5" value="50">
<div class="ha-slider-ticks"><span>5</span><span>50</span><span>100</span><span>200</span><span>∞</span></div>
</div>
<div class="ha-timer-group">
<div class="ha-timer-header">
<span>${t('chat.timerAutoStop')}</span>
<label class="ha-toggle"><input type="checkbox" id="hosted-agent-timer-on"><span class="ha-toggle-track"></span></label>
</div>
<div class="ha-timer-body" id="ha-timer-body" style="display:none">
<input type="range" class="ha-slider" id="hosted-agent-auto-stop" min="5" max="120" step="5" value="30">
<div class="ha-slider-ticks"><span>5m</span><span>30m</span><span>60m</span><span>120m</span></div>
<div class="ha-countdown" id="ha-countdown" style="display:none">
<div class="ha-countdown-bar"><div class="ha-countdown-fill" id="ha-countdown-fill"></div></div>
<span class="ha-countdown-text" id="ha-countdown-text">${t('chat.remaining')} --:--</span>
</div>
</div>
</div>
<input type="hidden" id="hosted-agent-step-delay" value="1200">
<input type="hidden" id="hosted-agent-retry" value="2">
</div>
<div class="hosted-agent-actions">
<button class="btn btn-primary" id="hosted-agent-save" style="flex:1">${t('chat.startHosted')}</button>
</div>
<div class="hosted-agent-footer" id="hosted-agent-status">${t('chat.ready')}</div>
</div>
<div class="chat-disconnect-bar" id="chat-disconnect-bar" style="display:none">${t('chat.disconnected')}</div>
<div class="chat-connect-overlay" id="chat-connect-overlay" style="display:none">
<div class="chat-connect-card">
<div class="chat-connect-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="48" height="48"><path d="M8.5 14.5A2.5 2.5 0 0011 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 11-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 002.5 2.5z"/></svg>
</div>
<div class="chat-connect-title">${t('chat.gatewayNotReady')}</div>
<div class="chat-connect-desc" id="chat-connect-desc">${t('chat.connectingGateway')}</div>
<div class="chat-connect-actions">
<button class="btn btn-primary btn-sm" id="btn-fix-connect">${t('chat.fixAndReconnect')}</button>
<button class="btn btn-secondary btn-sm" id="btn-goto-gateway">${t('chat.gatewaySettings')}</button>
</div>
<div class="chat-connect-hint">${t('chat.firstUseHint')}</div>
</div>
</div>
</div>
`
_messagesEl = page.querySelector('#chat-messages')
_textarea = page.querySelector('#chat-input')
_sendBtn = page.querySelector('#chat-send-btn')
_statusDot = page.querySelector('#chat-status-dot')
_typingEl = page.querySelector('#typing-indicator')
_scrollBtn = page.querySelector('#chat-scroll-btn')
_sessionListEl = page.querySelector('#chat-session-list')
_cmdPanelEl = page.querySelector('#chat-cmd-panel')
_attachPreviewEl = page.querySelector('#chat-attachments-preview')
_fileInputEl = page.querySelector('#chat-file-input')
_modelSelectEl = page.querySelector('#chat-model-select')
_hostedBtn = page.querySelector('#chat-hosted-btn')
_hostedBadgeEl = page.querySelector('#chat-hosted-badge')
_hostedPanelEl = page.querySelector('#hosted-agent-panel')
_hostedPromptEl = page.querySelector('#hosted-agent-prompt')
_hostedMaxStepsEl = page.querySelector('#hosted-agent-max-steps')
_hostedStepDelayEl = page.querySelector('#hosted-agent-step-delay')
_hostedRetryLimitEl = page.querySelector('#hosted-agent-retry')
_hostedAutoStopEl = page.querySelector('#hosted-agent-auto-stop')
_hostedSaveBtn = page.querySelector('#hosted-agent-save')
_hostedCloseBtn = page.querySelector('#hosted-agent-close')
_workspaceBtn = page.querySelector('#btn-chat-workspace')
_workspacePanelEl = page.querySelector('#chat-workspace-panel')
_workspaceAgentBadgeEl = page.querySelector('#chat-workspace-agent-badge')
_workspaceAgentTitleEl = page.querySelector('#chat-workspace-agent-title')
_workspacePathEl = page.querySelector('#chat-workspace-path')
_workspaceCoreListEl = page.querySelector('#chat-workspace-core-list')
_workspaceTreeEl = page.querySelector('#chat-workspace-tree')
_workspaceCurrentFileEl = page.querySelector('#chat-workspace-current-file')
_workspaceMetaEl = page.querySelector('#chat-workspace-editor-meta')
_workspaceEditorEl = page.querySelector('#chat-workspace-editor')
_workspacePreviewEl = page.querySelector('#chat-workspace-preview')
_workspaceEmptyEl = page.querySelector('#chat-workspace-empty')
_workspaceSaveBtn = page.querySelector('#chat-workspace-save')
_workspaceReloadBtn = page.querySelector('#chat-workspace-reload')
_workspacePreviewBtn = page.querySelector('#chat-workspace-preview-toggle')
page.querySelector('#chat-sidebar')?.classList.toggle('open', getSidebarOpen())
bindEvents(page)
bindConnectOverlay(page)
const workspaceOpen = getWorkspacePanelOpen()
applyWorkspacePanelVisibility(workspaceOpen)
if (!workspaceOpen) syncWorkspaceContext(false)
// 首次使用引导提示
showPageGuide(_messagesEl)
loadHostedDefaults().then(() => { loadHostedSessionConfig(); renderHostedPanel(); updateHostedBadge() })
loadModelOptions()
// 非阻塞:先返回 DOM后台连接 Gateway
connectGateway()
return page
}
const GUIDE_KEY = 'clawpanel-guide-chat-dismissed'
function showPageGuide(container) {
if (localStorage.getItem(GUIDE_KEY)) return
if (!container || container.querySelector('.chat-page-guide')) return
const guide = document.createElement('div')
guide.className = 'chat-page-guide'
guide.innerHTML = `
<div class="chat-guide-inner">
<div class="chat-guide-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="28" height="28"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
</div>
<div class="chat-guide-content">
<b>${t('chat.guideTitle')}</b>
<p>${t('chat.guideDesc')}</p>
<p style="opacity:0.7;font-size:11px">${t('chat.guideHint')}</p>
</div>
<button class="chat-guide-close" title="${t('chat.guideClose')}">&times;</button>
</div>
`
guide.querySelector('.chat-guide-close').onclick = () => {
localStorage.setItem(GUIDE_KEY, '1')
guide.remove()
}
container.insertBefore(guide, container.firstChild)
}
// ── 事件绑定 ──
function bindEvents(page) {
if (_modelSelectEl) {
_modelSelectEl.addEventListener('change', () => {
_selectedModel = _modelSelectEl.value
if (_selectedModel) localStorage.setItem(STORAGE_MODEL_KEY, _selectedModel)
else localStorage.removeItem(STORAGE_MODEL_KEY)
applySelectedModel()
})
}
_textarea.addEventListener('input', () => {
_textarea.style.height = 'auto'
_textarea.style.height = Math.min(_textarea.scrollHeight, 150) + 'px'
updateSendState()
// 输入 / 时显示指令面板
if (_textarea.value === '/') showCmdPanel()
else if (!_textarea.value.startsWith('/')) hideCmdPanel()
})
_textarea.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && e.keyCode !== 229) { e.preventDefault(); sendMessage() }
if (e.key === 'Escape') hideCmdPanel()
})
_sendBtn.addEventListener('click', () => {
if (_isStreaming) stopGeneration()
else sendMessage()
})
if (_hostedBtn) _hostedBtn.addEventListener('click', (e) => { e.stopPropagation(); toggleHostedPanel() })
if (_hostedCloseBtn) _hostedCloseBtn.addEventListener('click', () => hideHostedPanel())
if (_hostedSaveBtn) _hostedSaveBtn.addEventListener('click', () => toggleHostedRun())
// 滑块实时值显示
if (_hostedMaxStepsEl) _hostedMaxStepsEl.addEventListener('input', () => {
const valEl = page.querySelector('#ha-steps-val')
if (valEl) valEl.textContent = parseInt(_hostedMaxStepsEl.value) >= 205 ? '∞' : _hostedMaxStepsEl.value
})
// 定时器开关
const timerToggle = page.querySelector('#hosted-agent-timer-on')
const timerBody = page.querySelector('#ha-timer-body')
if (timerToggle && timerBody) {
timerToggle.addEventListener('change', () => { timerBody.style.display = timerToggle.checked ? '' : 'none' })
}
const toggleSidebar = () => {
const sidebar = page.querySelector('#chat-sidebar')
if (!sidebar) return
const nextOpen = !sidebar.classList.contains('open')
sidebar.classList.toggle('open', nextOpen)
setSidebarOpen(nextOpen)
}
page.querySelector('#btn-toggle-sidebar')?.addEventListener('click', toggleSidebar)
page.querySelector('#btn-toggle-sidebar-main')?.addEventListener('click', toggleSidebar)
page.querySelector('#btn-new-session').addEventListener('click', () => showNewSessionDialog())
page.querySelector('#btn-cmd').addEventListener('click', () => toggleCmdPanel())
page.querySelector('#btn-reset-session').addEventListener('click', () => resetCurrentSession())
page.querySelector('#btn-refresh-models')?.addEventListener('click', () => loadModelOptions(true))
_workspaceBtn?.addEventListener('click', async (e) => {
e.stopPropagation()
if (getWorkspacePanelOpen() && _workspaceDirty) {
const yes = await confirmWorkspaceDiscardIfNeeded()
if (!yes) return
discardWorkspaceChanges()
}
toggleWorkspacePanel()
})
page.querySelector('#chat-workspace-close')?.addEventListener('click', async () => {
if (_workspaceDirty) {
const yes = await confirmWorkspaceDiscardIfNeeded()
if (!yes) return
discardWorkspaceChanges()
}
toggleWorkspacePanel(false)
})
page.querySelector('#chat-workspace-refresh')?.addEventListener('click', async () => {
if (_workspaceDirty) {
const yes = await confirmWorkspaceDiscardIfNeeded()
if (!yes) return
discardWorkspaceChanges()
}
loadWorkspacePanelData(true)
})
_workspaceCoreListEl?.addEventListener('click', async (e) => {
const item = e.target.closest('[data-core-path]')
if (!item) return
const relativePath = item.dataset.corePath || ''
if (!relativePath) return
if (item.dataset.coreExists === '1') await openWorkspaceFile(relativePath, { kind: 'core' })
else {
const yes = await confirmWorkspaceDiscardIfNeeded()
if (!yes) return
discardWorkspaceChanges()
prepareWorkspaceDraftFile(relativePath, { kind: 'core' })
}
})
_workspaceTreeEl?.addEventListener('click', async (e) => {
const toggle = e.target.closest('[data-tree-toggle]')
if (toggle) {
try {
await toggleWorkspaceDirectory(toggle.dataset.treeToggle || '')
} catch (err) {
toast(`${t('chat.workspaceLoadFailed')}: ${err?.message || err}`, 'error')
}
return
}
const link = e.target.closest('[data-tree-path]')
if (!link) return
const relativePath = link.dataset.treePath || ''
if (!relativePath) return
if (link.dataset.treeType === 'dir') {
try {
await toggleWorkspaceDirectory(relativePath)
} catch (err) {
toast(`${t('chat.workspaceLoadFailed')}: ${err?.message || err}`, 'error')
}
return
}
await openWorkspaceFile(relativePath, { kind: 'tree' })
})
_workspaceEditorEl?.addEventListener('input', () => {
if (!_workspaceCurrentFile || !_workspaceEditorEl) return
_workspaceDirty = _workspaceEditorEl.value !== _workspaceLoadedContent
if (_workspacePreviewMode) renderWorkspacePreview()
updateWorkspaceEditorState()
})
_workspaceEditorEl?.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
e.preventDefault()
saveWorkspaceCurrentFile()
}
})
_workspaceReloadBtn?.addEventListener('click', () => reloadWorkspaceCurrentFile())
_workspacePreviewBtn?.addEventListener('click', () => toggleWorkspacePreview())
_workspaceSaveBtn?.addEventListener('click', () => saveWorkspaceCurrentFile())
// 文件上传
page.querySelector('#chat-attach-btn').addEventListener('click', () => _fileInputEl.click())
_fileInputEl.addEventListener('change', handleFileSelect)
// 粘贴图片Ctrl+V
_textarea.addEventListener('paste', handlePaste)
_messagesEl.addEventListener('scroll', () => {
const { scrollTop, scrollHeight, clientHeight } = _messagesEl
_scrollBtn.style.display = (scrollHeight - scrollTop - clientHeight < 80) ? 'none' : 'flex'
if (scrollTop < _lastScrollTop - 2) _autoScrollEnabled = false
if (isAtBottom()) _autoScrollEnabled = true
_lastScrollTop = scrollTop
})
_messagesEl.addEventListener('wheel', (e) => {
if (e.deltaY < 0) _autoScrollEnabled = false
}, { passive: true })
_messagesEl.addEventListener('touchstart', (e) => {
_touchStartY = e.touches?.[0]?.clientY || 0
}, { passive: true })
_messagesEl.addEventListener('touchmove', (e) => {
const y = e.touches?.[0]?.clientY || 0
if (y > _touchStartY + 2) _autoScrollEnabled = false
}, { passive: true })
_scrollBtn.addEventListener('click', () => {
_autoScrollEnabled = true
scrollToBottom(true)
})
_messagesEl.addEventListener('click', (e) => {
const copyBtn = e.target.closest('.msg-copy-btn')
if (copyBtn) {
e.stopPropagation()
const msgWrap = copyBtn.closest('.msg')
const bubble = msgWrap?.querySelector('.msg-bubble')
if (bubble) {
const text = bubble.innerText || bubble.textContent || ''
navigator.clipboard.writeText(text.trim()).then(() => {
copyBtn.classList.add('copied')
copyBtn.innerHTML = svgIcon('check', 12)
setTimeout(() => { copyBtn.classList.remove('copied'); copyBtn.innerHTML = svgIcon('copy', 12) }, 1500)
}).catch(() => {})
}
return
}
hideCmdPanel()
})
}
async function loadModelOptions(showToast = false) {
if (!_modelSelectEl) return
// 显示加载状态
_modelSelectEl.innerHTML = `<option value="">${t('chat.loadingModels')}</option>`
_modelSelectEl.disabled = true
try {
invalidate('read_openclaw_config')
const configPromise = api.readOpenclawConfig()
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('timeout(8s)')), 8000))
const config = await Promise.race([configPromise, timeoutPromise])
const providers = config?.models?.providers || {}
_primaryModel = config?.agents?.defaults?.model?.primary || ''
const models = []
const seen = new Set()
if (_primaryModel) {
seen.add(_primaryModel)
models.push(_primaryModel)
}
for (const [providerKey, provider] of Object.entries(providers)) {
for (const item of (provider?.models || [])) {
const modelId = typeof item === 'string' ? item : item?.id
if (!modelId) continue
const full = `${providerKey}/${modelId}`
if (seen.has(full)) continue
seen.add(full)
models.push(full)
}
}
_availableModels = models
const saved = localStorage.getItem(STORAGE_MODEL_KEY) || ''
_selectedModel = models.includes(saved) ? saved : (_primaryModel || models[0] || '')
renderModelSelect()
if (showToast) toast(`${t('chat.refreshModels')} (${models.length})`, 'success')
} catch (e) {
_availableModels = []
_primaryModel = ''
_selectedModel = ''
renderModelSelect(`${t('common.loadFailed')}: ${e.message || e}`)
if (showToast) toast(`${t('common.loadFailed')}: ${e.message || e}`, 'error')
}
}
function renderModelSelect(errorText = '') {
if (!_modelSelectEl) return
if (!_availableModels.length) {
_modelSelectEl.innerHTML = `<option value="">${escapeAttr(errorText || t('chat.loadingModels'))}</option>`
_modelSelectEl.disabled = true
_modelSelectEl.title = errorText || ''
return
}
_modelSelectEl.disabled = _isApplyingModel
_modelSelectEl.innerHTML = _availableModels.map(full => {
const suffix = full === _primaryModel ? ` ${t('chat.defaultSuffix')}` : ''
return `<option value="${escapeAttr(full)}" ${full === _selectedModel ? 'selected' : ''}>${full}${suffix}</option>`
}).join('')
_modelSelectEl.title = _selectedModel || ''
}
function escapeAttr(str) {
return (str || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
/** 本地会话别名缓存 */
function getSessionNames() {
try { return JSON.parse(localStorage.getItem(STORAGE_SESSION_NAMES_KEY) || '{}') } catch { return {} }
}
function setSessionName(key, name) {
const names = getSessionNames()
if (name) names[key] = name
else delete names[key]
localStorage.setItem(STORAGE_SESSION_NAMES_KEY, JSON.stringify(names))
}
function getDisplayLabel(key) {
const custom = getSessionNames()[key]
return custom || parseSessionLabel(key)
}
function getSidebarOpen() {
return localStorage.getItem(STORAGE_SIDEBAR_KEY) === '1'
}
function setSidebarOpen(open) {
localStorage.setItem(STORAGE_SIDEBAR_KEY, open ? '1' : '0')
}
function getWorkspacePanelOpen() {
return localStorage.getItem(STORAGE_WORKSPACE_PANEL_KEY) === '1'
}
function setWorkspacePanelOpen(open) {
localStorage.setItem(STORAGE_WORKSPACE_PANEL_KEY, open ? '1' : '0')
}
function formatWorkspaceFileSize(bytes) {
const size = Number(bytes) || 0
if (size < 1024) return `${size} B`
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
return `${(size / (1024 * 1024)).toFixed(1)} MB`
}
function formatWorkspaceFileTime(value) {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
return date.toLocaleString()
}
function isMarkdownWorkspaceFile(relativePath) {
return /\.(md|markdown|mdx)$/i.test(relativePath || '')
}
async function confirmWorkspaceDiscardIfNeeded() {
if (!_workspaceDirty) return true
return showConfirm(t('chat.confirmDiscardWorkspaceChanges'))
}
function discardWorkspaceChanges() {
if (!_workspaceCurrentFile) {
_workspaceDirty = false
updateWorkspaceEditorState()
return
}
if (_workspaceEditorEl) _workspaceEditorEl.value = _workspaceLoadedContent
_workspaceDirty = false
if (_workspacePreviewMode) renderWorkspacePreview()
updateWorkspaceEditorState()
}
function getCurrentWorkspaceAgentId() {
return parseSessionAgent(_sessionKey) || wsClient.snapshot?.sessionDefaults?.defaultAgentId || 'main'
}
function getWorkspaceAgentTitle() {
if (_sessionKey) return getDisplayLabel(_sessionKey)
if (_workspaceCurrentAgentId === 'main') return t('chat.mainSession')
return _workspaceCurrentAgentId || t('chat.workspace')
}
async function syncWorkspaceContext(reload = true) {
const nextAgentId = getCurrentWorkspaceAgentId()
const prevAgentId = _workspaceCurrentAgentId
_workspaceCurrentAgentId = nextAgentId || 'main'
const triggerAgentEl = _page?.querySelector('#chat-workspace-trigger-agent')
if (triggerAgentEl) triggerAgentEl.textContent = _workspaceCurrentAgentId
if (_workspaceAgentBadgeEl) _workspaceAgentBadgeEl.textContent = _workspaceCurrentAgentId
if (_workspaceAgentTitleEl) {
_workspaceAgentTitleEl.textContent = getWorkspaceAgentTitle()
}
if (!_workspacePanelEl || !getWorkspacePanelOpen()) return
if (!reload && prevAgentId === _workspaceCurrentAgentId && _workspaceInfo) return
if (prevAgentId !== _workspaceCurrentAgentId) {
_workspaceDirty = false
_workspaceCurrentFile = null
}
await loadWorkspacePanelData(prevAgentId === _workspaceCurrentAgentId)
}
function applyWorkspacePanelVisibility(open) {
if (!_workspacePanelEl) return
_workspacePanelEl.style.display = open ? '' : 'none'
_workspaceBtn?.classList.toggle('is-active', open)
if (open) syncWorkspaceContext(true)
}
function toggleWorkspacePanel(force) {
const nextOpen = typeof force === 'boolean' ? force : !getWorkspacePanelOpen()
setWorkspacePanelOpen(nextOpen)
applyWorkspacePanelVisibility(nextOpen)
}
function renderWorkspacePanelMeta() {
if (_workspaceAgentBadgeEl) _workspaceAgentBadgeEl.textContent = _workspaceCurrentAgentId
if (_workspaceAgentTitleEl) {
_workspaceAgentTitleEl.textContent = getWorkspaceAgentTitle()
}
if (_workspacePathEl) {
const path = _workspaceInfo?.workspacePath || ''
_workspacePathEl.textContent = path || t('chat.workspaceUnavailable')
_workspacePathEl.title = path || ''
}
}
function renderWorkspaceCoreFiles() {
if (!_workspaceCoreListEl) return
if (!_workspaceCoreFiles.length) {
_workspaceCoreListEl.innerHTML = `<div class="chat-workspace-note">${t('chat.workspaceNoCoreFiles')}</div>`
return
}
_workspaceCoreListEl.innerHTML = _workspaceCoreFiles.map(file => {
const active = _workspaceCurrentFile?.relativePath === file.name ? ' active' : ''
const status = file.exists ? t('common.edit') : t('common.add')
return `
<button class="chat-workspace-core-item${active}" data-core-path="${escapeAttr(file.name)}" data-core-exists="${file.exists ? '1' : '0'}" title="${escapeAttr(file.path || file.name)}">
<span class="chat-workspace-core-icon">${svgIcon(file.exists ? 'file-text' : 'file-plain', 14)}</span>
<span class="chat-workspace-core-copy">
<span class="chat-workspace-core-name">${escapeAttr(file.name)}</span>
<span class="chat-workspace-core-status ${file.exists ? 'exists' : 'missing'}">${status}</span>
</span>
</button>
`
}).join('')
}
function renderWorkspaceTreeNode(entry, depth) {
const isDir = entry.type === 'dir'
const expanded = isDir && _workspaceExpandedDirs.has(entry.relativePath)
const active = _workspaceCurrentFile?.relativePath === entry.relativePath ? ' active' : ''
const children = expanded
? (_workspaceTreeCache.get(entry.relativePath) || []).map(child => renderWorkspaceTreeNode(child, depth + 1)).join('')
: ''
return `
<div class="chat-workspace-tree-node">
<div class="chat-workspace-tree-row${active}" style="padding-left:${12 + depth * 14}px">
${isDir
? `<button class="chat-workspace-tree-toggle" data-tree-toggle="${escapeAttr(entry.relativePath)}">${expanded ? '▾' : '▸'}</button>`
: '<span class="chat-workspace-tree-toggle is-spacer"></span>'}
<button class="chat-workspace-tree-link" data-tree-path="${escapeAttr(entry.relativePath)}" data-tree-type="${entry.type}" data-tree-editable="${entry.editable ? '1' : '0'}" title="${escapeAttr(entry.relativePath)}">
${svgIcon(isDir ? 'folder' : (entry.previewable ? 'file-text' : 'file'), 14)}
<span class="chat-workspace-tree-name">${escapeAttr(entry.name)}</span>
</button>
</div>
${children}
</div>
`
}
function renderWorkspaceTree() {
if (!_workspaceTreeEl) return
const rootEntries = _workspaceTreeCache.get('') || []
if (!rootEntries.length) {
_workspaceTreeEl.innerHTML = `<div class="chat-workspace-note">${t('chat.workspaceTreeEmpty')}</div>`
return
}
_workspaceTreeEl.innerHTML = rootEntries.map(entry => renderWorkspaceTreeNode(entry, 0)).join('')
}
function renderWorkspacePreview() {
if (!_workspacePreviewEl || !_workspaceEditorEl) return
_workspacePreviewEl.innerHTML = renderMarkdown(_workspaceEditorEl.value || '')
}
function updateWorkspaceEditorState() {
const hasFile = !!_workspaceCurrentFile
const canSaveDraft = hasFile && _workspaceCurrentFile?.exists === false
if (_workspaceCurrentFileEl) {
_workspaceCurrentFileEl.textContent = hasFile
? `${_workspaceCurrentFile.relativePath}${_workspaceDirty ? ' *' : ''}`
: t('chat.selectWorkspaceFile')
}
if (_workspaceSaveBtn) _workspaceSaveBtn.disabled = !hasFile || (!canSaveDraft && !_workspaceDirty) || _workspaceLoading
if (_workspaceReloadBtn) _workspaceReloadBtn.disabled = !hasFile || _workspaceLoading
if (_workspacePreviewBtn) _workspacePreviewBtn.disabled = !hasFile || !_workspaceCurrentFile?.previewable || _workspaceLoading
const previewLabelEl = _page?.querySelector('#chat-workspace-preview-label')
if (previewLabelEl) previewLabelEl.textContent = _workspacePreviewMode ? t('chat.editWorkspaceFile') : t('chat.previewWorkspaceFile')
if (_workspaceEditorEl) {
_workspaceEditorEl.disabled = !hasFile || _workspaceLoading
_workspaceEditorEl.style.display = hasFile && !_workspacePreviewMode ? '' : 'none'
}
if (_workspacePreviewEl) {
_workspacePreviewEl.style.display = hasFile && _workspacePreviewMode ? '' : 'none'
}
if (_workspaceEmptyEl) {
_workspaceEmptyEl.style.display = hasFile ? 'none' : ''
}
if (hasFile && _workspacePreviewMode) renderWorkspacePreview()
}
function resetWorkspaceEditor(emptyText = t('chat.workspaceEmptyState')) {
_workspaceCurrentFile = null
_workspacePreviewMode = false
_workspaceDirty = false
_workspaceLoadedContent = ''
if (_workspaceMetaEl) _workspaceMetaEl.textContent = ''
if (_workspaceEditorEl) {
_workspaceEditorEl.value = ''
_workspaceEditorEl.placeholder = t('chat.selectWorkspaceFile')
}
if (_workspacePreviewEl) {
_workspacePreviewEl.innerHTML = ''
_workspacePreviewEl.style.display = 'none'
}
if (_workspaceEmptyEl) _workspaceEmptyEl.textContent = emptyText
renderWorkspaceCoreFiles()
renderWorkspaceTree()
updateWorkspaceEditorState()
}
function prepareWorkspaceDraftFile(relativePath, options = {}) {
const { kind = 'core', previewable = isMarkdownWorkspaceFile(relativePath) } = options
_workspaceCurrentFile = { agentId: _workspaceCurrentAgentId, relativePath, kind, previewable, exists: false }
_workspacePreviewMode = false
_workspaceDirty = false
_workspaceLoadedContent = ''
if (_workspaceEditorEl) {
_workspaceEditorEl.value = ''
_workspaceEditorEl.placeholder = t('chat.workspaceDraftHint')
}
if (_workspaceMetaEl) _workspaceMetaEl.textContent = t('chat.workspaceDraftHint')
renderWorkspaceCoreFiles()
renderWorkspaceTree()
updateWorkspaceEditorState()
}
async function loadWorkspacePanelData(preserveCurrentFile = false) {
if (!_workspaceCoreListEl || !_workspaceTreeEl) return
const loadSeq = ++_workspaceLoadSeq
const agentId = _workspaceCurrentAgentId || 'main'
_workspaceLoading = true
renderWorkspacePanelMeta()
_workspaceCoreListEl.innerHTML = `<div class="chat-workspace-note">${t('common.loading')}</div>`
_workspaceTreeEl.innerHTML = `<div class="chat-workspace-note">${t('common.loading')}</div>`
updateWorkspaceEditorState()
try {
const previousFile = preserveCurrentFile ? _workspaceCurrentFile : null
const [info, coreFiles, rootEntries] = await Promise.all([
api.getAgentWorkspaceInfo(agentId),
api.listAgentFiles(agentId),
api.listAgentWorkspaceEntries(agentId, ''),
])
if (loadSeq !== _workspaceLoadSeq || agentId !== _workspaceCurrentAgentId) return
_workspaceInfo = info || null
_workspaceCoreFiles = Array.isArray(coreFiles) ? coreFiles : []
_workspaceTreeCache = new Map([['', Array.isArray(rootEntries) ? rootEntries : []]])
_workspaceExpandedDirs = new Set()
renderWorkspacePanelMeta()
renderWorkspaceCoreFiles()
renderWorkspaceTree()
if (previousFile && previousFile.agentId === agentId) {
if (previousFile.kind === 'core' && previousFile.exists === false) {
prepareWorkspaceDraftFile(previousFile.relativePath, previousFile)
} else {
await openWorkspaceFile(previousFile.relativePath, { kind: previousFile.kind, force: true, silent: true })
}
} else {
resetWorkspaceEditor(t('chat.workspaceEmptyState'))
}
} catch (e) {
if (loadSeq !== _workspaceLoadSeq || agentId !== _workspaceCurrentAgentId) return
_workspaceInfo = null
_workspaceCoreFiles = []
_workspaceTreeCache = new Map([['', []]])
_workspaceExpandedDirs = new Set()
resetWorkspaceEditor(t('chat.workspaceUnavailable'))
renderWorkspacePanelMeta()
const message = e?.message || String(e)
_workspaceCoreListEl.innerHTML = `<div class="chat-workspace-note is-error">${escapeAttr(message)}</div>`
_workspaceTreeEl.innerHTML = `<div class="chat-workspace-note is-error">${escapeAttr(message)}</div>`
toast(`${t('chat.workspaceLoadFailed')}: ${message}`, 'error')
} finally {
if (loadSeq !== _workspaceLoadSeq) return
_workspaceLoading = false
updateWorkspaceEditorState()
}
}
async function toggleWorkspaceDirectory(relativePath) {
if (!relativePath) return
if (_workspaceExpandedDirs.has(relativePath)) {
_workspaceExpandedDirs.delete(relativePath)
renderWorkspaceTree()
return
}
try {
if (!_workspaceTreeCache.has(relativePath)) {
const entries = await api.listAgentWorkspaceEntries(_workspaceCurrentAgentId, relativePath)
_workspaceTreeCache.set(relativePath, Array.isArray(entries) ? entries : [])
}
_workspaceExpandedDirs.add(relativePath)
renderWorkspaceTree()
} catch (e) {
toast(`${t('common.loadFailed')}: ${e?.message || e}`, 'error')
}
}
async function openWorkspaceFile(relativePath, options = {}) {
const { kind = 'tree', force = false, silent = false } = options
if (!force && !(await confirmWorkspaceDiscardIfNeeded())) return
const openSeq = ++_workspaceOpenSeq
const agentId = _workspaceCurrentAgentId
try {
const file = await api.readAgentWorkspaceFile(agentId, relativePath)
if (openSeq !== _workspaceOpenSeq || agentId !== _workspaceCurrentAgentId) return
_workspaceCurrentFile = {
agentId,
relativePath,
kind,
previewable: !!file.previewable,
exists: true,
}
_workspaceLoadedContent = file.content || ''
_workspacePreviewMode = false
_workspaceDirty = false
if (_workspaceEditorEl) {
_workspaceEditorEl.value = _workspaceLoadedContent
_workspaceEditorEl.placeholder = t('chat.selectWorkspaceFile')
}
const metaParts = []
if (typeof file.size === 'number') metaParts.push(formatWorkspaceFileSize(file.size))
const timeText = formatWorkspaceFileTime(file.mtime)
if (timeText) metaParts.push(timeText)
if (_workspaceMetaEl) _workspaceMetaEl.textContent = metaParts.join(' · ')
renderWorkspaceCoreFiles()
renderWorkspaceTree()
updateWorkspaceEditorState()
} catch (e) {
if (openSeq !== _workspaceOpenSeq || agentId !== _workspaceCurrentAgentId) return
if (!silent) toast(`${t('chat.workspaceOpenFailed')}: ${e?.message || e}`, 'error')
}
}
async function reloadWorkspaceCurrentFile(force = false) {
if (!_workspaceCurrentFile) return
if (!force && !(await confirmWorkspaceDiscardIfNeeded())) return
if (_workspaceCurrentFile.kind === 'core' && _workspaceCurrentFile.exists === false) {
prepareWorkspaceDraftFile(_workspaceCurrentFile.relativePath, _workspaceCurrentFile)
return
}
await openWorkspaceFile(_workspaceCurrentFile.relativePath, { kind: _workspaceCurrentFile.kind, force: true })
}
function toggleWorkspacePreview() {
if (!_workspaceCurrentFile?.previewable) return
_workspacePreviewMode = !_workspacePreviewMode
updateWorkspaceEditorState()
}
async function saveWorkspaceCurrentFile() {
if (!_workspaceCurrentFile || !_workspaceEditorEl) return
const text = _workspaceEditorEl.value
const wasExisting = _workspaceCurrentFile.exists !== false
try {
await api.writeAgentWorkspaceFile(_workspaceCurrentAgentId, _workspaceCurrentFile.relativePath, text)
_workspaceCurrentFile = { ..._workspaceCurrentFile, exists: true }
_workspaceLoadedContent = text
_workspaceDirty = false
try {
await loadWorkspacePanelData(true)
} catch (refreshError) {
console.warn('[chat] workspace refresh after save failed:', refreshError)
}
toast(wasExisting ? t('common.saveSuccess') : t('chat.workspaceFileCreated'), 'success')
} catch (e) {
toast(`${t('common.saveFailed')}: ${e?.message || e}`, 'error')
}
}
async function applySelectedModel() {
if (!_selectedModel) {
toast(t('chat.loadingModels'), 'warning')
return
}
if (!wsClient.gatewayReady || !_sessionKey) {
toast(t('chat.gatewayNotReadySend'), 'warning')
return
}
_isApplyingModel = true
renderModelSelect()
try {
await wsClient.chatSend(_sessionKey, `/model ${_selectedModel}`)
toast(`${_selectedModel}`, 'success')
} catch (e) {
toast(`${t('chat.sendFailed')}${e.message || e}`, 'error')
} finally {
_isApplyingModel = false
renderModelSelect()
}
}
// ── 连接引导遮罩 ──
function bindConnectOverlay(page) {
const fixBtn = page.querySelector('#btn-fix-connect')
const gwBtn = page.querySelector('#btn-goto-gateway')
if (fixBtn) {
fixBtn.addEventListener('click', async () => {
fixBtn.disabled = true
fixBtn.textContent = t('chat.fixing')
const desc = document.getElementById('chat-connect-desc')
try {
if (desc) desc.textContent = t('chat.writingConfig')
await api.autoPairDevice()
await api.reloadGateway()
if (desc) desc.textContent = t('chat.fixDoneReconnecting')
// 断开旧连接,重新发起
wsClient.disconnect()
setTimeout(() => connectGateway(), 3000)
} catch (e) {
if (desc) desc.textContent = `${t('chat.fixFailed')}${e.message || e}`
} finally {
fixBtn.disabled = false
fixBtn.textContent = t('chat.fixAndReconnect')
}
})
}
if (gwBtn) {
gwBtn.addEventListener('click', () => navigate('/gateway'))
}
}
// ── 文件上传 ──
async function handleFileSelect(e) {
const files = Array.from(e.target.files || [])
if (!files.length) return
for (const file of files) {
if (!file.type.startsWith('image/')) {
toast(t('chat.imageOnly'), 'warning')
continue
}
if (file.size > 5 * 1024 * 1024) {
toast(`${file.name} > 5MB`, 'warning')
continue
}
try {
const base64 = await fileToBase64(file)
_attachments.push({
type: 'image',
mimeType: file.type,
fileName: file.name,
content: base64,
})
renderAttachments()
} catch (e) {
toast(`${t('chat.readFileFailed')} ${file.name}`, 'error')
}
}
_fileInputEl.value = ''
}
async function handlePaste(e) {
const items = Array.from(e.clipboardData?.items || [])
const imageItems = items.filter(item => item.type.startsWith('image/'))
if (!imageItems.length) return
e.preventDefault()
for (const item of imageItems) {
const file = item.getAsFile()
if (!file) continue
if (file.size > 5 * 1024 * 1024) { toast(t('chat.imageSizeLimit'), 'warning'); continue }
try {
const base64 = await fileToBase64(file)
_attachments.push({ type: 'image', mimeType: file.type || 'image/png', fileName: `paste-${Date.now()}.png`, content: base64 })
renderAttachments()
} catch (_) { toast(t('chat.readFileFailed'), 'error') }
}
}
function fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const dataUrl = reader.result
const match = /^data:[^;]+;base64,(.+)$/.exec(dataUrl)
if (!match) { reject(new Error('invalid data URL')); return }
resolve(match[1])
}
reader.onerror = reject
reader.readAsDataURL(file)
})
}
function renderAttachments() {
if (!_attachPreviewEl) return
if (!_attachments.length) {
_attachPreviewEl.style.display = 'none'
return
}
_attachPreviewEl.style.display = 'flex'
_attachPreviewEl.innerHTML = _attachments.map((att, idx) => `
<div class="chat-attachment-item">
<img src="data:${att.mimeType};base64,${att.content}" alt="${att.fileName}">
<button class="chat-attachment-del" data-idx="${idx}">×</button>
</div>
`).join('')
_attachPreviewEl.querySelectorAll('.chat-attachment-del').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.idx)
_attachments.splice(idx, 1)
renderAttachments()
})
})
updateSendState()
}
// ── Gateway 连接 ──
async function connectGateway() {
try {
// 清理旧的订阅,避免重复监听
if (_unsubStatus) { _unsubStatus(); _unsubStatus = null }
if (_unsubReady) { _unsubReady(); _unsubReady = null }
if (_unsubEvent) { _unsubEvent(); _unsubEvent = null }
// 订阅状态变化(订阅式,返回 unsub
_unsubStatus = wsClient.onStatusChange((status, errorMsg) => {
if (!_pageActive) return
updateStatusDot(status)
const bar = document.getElementById('chat-disconnect-bar')
const overlay = document.getElementById('chat-connect-overlay')
const desc = document.getElementById('chat-connect-desc')
if (status === 'ready' || status === 'connected') {
_hasEverConnected = true
if (bar) bar.style.display = 'none'
if (overlay) overlay.style.display = 'none'
// WS 已连接,主动刷新 Gateway 状态以消除顶部横条延迟
import('../lib/app-state.js').then(m => m.refreshGatewayStatus()).catch(() => {})
} else if (status === 'error') {
// 连接错误:显示引导遮罩而非底部条
if (bar) bar.style.display = 'none'
if (overlay) {
overlay.style.display = 'flex'
if (desc) desc.textContent = errorMsg || t('chat.connectFailed')
}
} else if (status === 'reconnecting' || status === 'disconnected') {
// 首次连接或多次重连失败时,显示引导遮罩而非底部小条
if (!_hasEverConnected) {
if (overlay) { overlay.style.display = 'flex'; if (desc) desc.textContent = t('chat.connectingGateway') }
} else {
if (bar) { bar.textContent = t('chat.disconnected'); bar.style.display = 'flex' }
}
} else {
if (bar) bar.style.display = 'none'
}
})
_unsubReady = wsClient.onReady((hello, sessionKey, err) => {
if (!_pageActive) return
const overlay = document.getElementById('chat-connect-overlay')
if (err?.error) {
if (overlay) {
overlay.style.display = 'flex'
const desc = document.getElementById('chat-connect-desc')
if (desc) desc.textContent = err.message || t('chat.connectFailed')
}
return
}
if (overlay) overlay.style.display = 'none'
showTyping(false) // Gateway 就绪后关闭加载动画
// 重连后恢复:保留当前 sessionKey不重复加载历史
if (!_sessionKey) {
const saved = localStorage.getItem(STORAGE_SESSION_KEY)
_sessionKey = saved || sessionKey
updateSessionTitle()
loadHistory()
} else {
syncWorkspaceContext(false)
}
// 始终刷新会话列表(无论是否有 sessionKey
refreshSessionList()
})
_unsubEvent = wsClient.onEvent((msg) => {
if (!_pageActive) return
handleEvent(msg)
})
// 如果已连接且 Gateway 就绪,直接复用
if (wsClient.connected && wsClient.gatewayReady) {
const saved = localStorage.getItem(STORAGE_SESSION_KEY)
_sessionKey = saved || wsClient.sessionKey
updateStatusDot('ready')
showTyping(false) // 确保关闭加载动画
updateSessionTitle()
loadHistory()
refreshSessionList()
return
}
// 如果正在连接中(重连等),等待 onReady 回调即可
if (wsClient.connected || wsClient.connecting || wsClient.gatewayReady) return
// 未连接,发起新连接
const config = await api.readOpenclawConfig()
const gw = config?.gateway || {}
const host = isTauriRuntime() ? `127.0.0.1:${gw.port || 18789}` : location.host
const token = gw.auth?.token || gw.authToken || ''
wsClient.connect(host, token)
} catch (e) {
toast(`${t('common.loadFailed')}: ${e.message}`, 'error')
}
}
// ── 会话管理 ──
async function refreshSessionList() {
if (!_sessionListEl || !wsClient.gatewayReady) return
try {
const result = await wsClient.sessionsList(50)
const sessions = result?.sessions || result || []
renderSessionList(sessions)
} catch (e) {
console.error('[chat] refreshSessionList error:', e)
}
}
function renderSessionList(sessions) {
if (!_sessionListEl) return
if (!sessions.length) {
_sessionListEl.innerHTML = `<div class="chat-session-empty">${t('chat.noSessions')}</div>`
return
}
sessions.sort((a, b) => (b.updatedAt || b.lastActivity || 0) - (a.updatedAt || a.lastActivity || 0))
_sessionListEl.innerHTML = sessions.map(s => {
const key = s.sessionKey || s.key || ''
const active = key === _sessionKey ? ' active' : ''
const label = parseSessionLabel(key)
const ts = s.updatedAt || s.lastActivity || s.createdAt || 0
const timeStr = ts ? formatSessionTime(ts) : ''
const msgCount = s.messageCount || s.messages || 0
const agentId = parseSessionAgent(key)
const displayLabel = getDisplayLabel(key) || label
const cpCount = s.compactionCheckpointCount || 0
return `<div class="chat-session-card${active}" data-key="${escapeAttr(key)}">
<div class="chat-session-card-header">
<span class="chat-session-label" title="${t('chat.doubleClickRename')}">${escapeAttr(displayLabel)}</span>
<div style="display:flex;gap:2px;align-items:center">
${cpCount > 0 ? `<button class="chat-session-del" data-compaction="${escapeAttr(key)}" title="${t('chat.compactionHistory')}" style="color:var(--text-tertiary);font-size:11px">⟳${cpCount}</button>` : ''}
<button class="chat-session-del" data-del="${escapeAttr(key)}" title="${t('common.delete')}">×</button>
</div>
</div>
<div class="chat-session-card-meta">
${agentId && agentId !== 'main' ? `<span class="chat-session-agent">${escapeAttr(agentId)}</span>` : ''}
${msgCount > 0 ? `<span>${msgCount} msgs</span>` : ''}
${timeStr ? `<span>${timeStr}</span>` : ''}
</div>
</div>`
}).join('')
_sessionListEl.onclick = (e) => {
const cpBtn = e.target.closest('[data-compaction]')
if (cpBtn) { e.stopPropagation(); showCompactionHistory(cpBtn.dataset.compaction); return }
const delBtn = e.target.closest('[data-del]')
if (delBtn) { e.stopPropagation(); deleteSession(delBtn.dataset.del); return }
const item = e.target.closest('[data-key]')
if (item) void switchSession(item.dataset.key)
}
_sessionListEl.ondblclick = (e) => {
const labelEl = e.target.closest('.chat-session-label')
if (!labelEl) return
const card = labelEl.closest('[data-key]')
if (!card) return
e.stopPropagation()
renameSession(card.dataset.key, labelEl)
}
}
function formatSessionTime(ts) {
const d = new Date(typeof ts === 'number' && ts < 1e12 ? ts * 1000 : ts)
if (isNaN(d.getTime())) return ''
const now = new Date()
const diffMs = now - d
if (diffMs < 60000) return t('chat.justNow')
if (diffMs < 3600000) return t('chat.minutesAgo', { n: Math.floor(diffMs / 60000) })
if (diffMs < 86400000) return t('chat.hoursAgo', { n: Math.floor(diffMs / 3600000) })
if (diffMs < 604800000) return t('chat.daysAgo', { n: Math.floor(diffMs / 86400000) })
return `${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}`
}
function parseSessionAgent(key) {
const parts = (key || '').split(':')
return parts.length >= 2 ? parts[1] : ''
}
function parseSessionLabel(key) {
const parts = (key || '').split(':')
if (parts.length < 3) return key || t('common.unknown')
const agent = parts[1] || 'main'
const channel = parts.slice(2).join(':')
if (agent === 'main' && channel === 'main') return t('chat.mainSession')
if (agent === 'main') return channel
return `${agent} / ${channel}`
}
async function switchSession(newKey, options = {}) {
const { forceWorkspace = false } = options
if (newKey === _sessionKey) return false
const nextAgentId = parseSessionAgent(newKey) || 'main'
if (!forceWorkspace && _workspaceDirty && nextAgentId !== _workspaceCurrentAgentId) {
const yes = await confirmWorkspaceDiscardIfNeeded()
if (!yes) return false
discardWorkspaceChanges()
}
_sessionKey = newKey
localStorage.setItem(STORAGE_SESSION_KEY, newKey)
_lastHistoryHash = ''
resetStreamState()
updateSessionTitle()
clearMessages()
loadHistory()
refreshSessionList()
return true
}
async function showNewSessionDialog() {
const defaultAgent = wsClient.snapshot?.sessionDefaults?.defaultAgentId || 'main'
// 先用默认选项立即显示弹窗
const initialOptions = [
{ value: 'main', label: `main ${t('chat.defaultSuffix')}` },
{ value: '__new__', label: `+ ${t('chat.newAgent')}` }
]
showModal({
title: t('chat.newSession'),
fields: [
{ name: 'name', label: t('chat.sessionName'), value: '', placeholder: t('chat.sessionNamePlaceholder') },
{ name: 'agent', label: 'Agent', type: 'select', value: defaultAgent, options: initialOptions },
],
onConfirm: (result) => {
const name = (result.name || '').trim()
if (!name) { toast(t('chat.enterSessionName'), 'warning'); return }
const agent = result.agent || defaultAgent
if (agent === '__new__') {
navigate('/agents')
toast(t('chat.createAgentHint'), 'info')
return
}
switchSession(`agent:${agent}:${name}`).then((switched) => {
if (switched) toast(t('chat.sessionCreated'), 'success')
})
}
})
// 异步加载完整 Agent 列表并更新下拉框
try {
const agents = await api.listAgents()
const agentOptions = agents.map(a => ({
value: a.id,
label: `${a.id}${a.isDefault ? ` ${t('chat.defaultSuffix')}` : ''}${a.identityName ? ' — ' + a.identityName.split(',')[0] : ''}`
}))
agentOptions.push({ value: '__new__', label: `+ ${t('chat.newAgent')}` })
// 更新弹窗中的下拉框选项
const selectEl = document.querySelector('.modal-overlay [data-name="agent"]')
if (selectEl) {
const currentValue = selectEl.value
selectEl.innerHTML = agentOptions.map(o =>
`<option value="${o.value}" ${o.value === currentValue ? 'selected' : ''}>${o.label}</option>`
).join('')
}
} catch (e) {
console.warn('[chat] 加载 Agent 列表失败:', e)
}
}
async function deleteSession(key) {
const mainKey = wsClient.snapshot?.sessionDefaults?.mainSessionKey || 'agent:main:main'
if (key === mainKey) { toast(t('chat.cannotDeleteMain'), 'warning'); return }
const label = parseSessionLabel(key)
const yes = await showConfirm({
title: t('chat.deleteSessionTitle', { label }),
message: t('chat.confirmDeleteSession', { label }),
impact: [
t('chat.deleteSessionImpactHistory'),
t('chat.deleteSessionImpactCannotUndo'),
],
confirmText: t('chat.deleteSessionBtn'),
cancelText: t('chat.deleteSessionCancel'),
})
if (!yes) return
try {
await wsClient.sessionsDelete(key)
toast(t('chat.sessionDeleted'), 'success')
if (key === _sessionKey) void switchSession(mainKey, { forceWorkspace: true })
else refreshSessionList()
} catch (e) {
toast(`${t('common.operationFailed')}: ${e.message}`, 'error')
}
}
// ===== 4.9: Sessions Compaction History =====
async function showCompactionHistory(key) {
if (!key || !wsClient.gatewayReady) return
const label = getDisplayLabel(key)
toast(t('chat.compactionLoading'), 'info')
try {
const result = await wsClient.sessionsCompactionList(key)
const checkpoints = result?.checkpoints || []
if (!checkpoints.length) {
toast(t('chat.compactionEmpty'), 'info')
return
}
const listHtml = checkpoints.map((cp, idx) => {
const id = cp.id || cp.checkpointId || `cp-${idx}`
const ts = cp.timestamp || cp.createdAt || 0
const timeStr = ts ? new Date(typeof ts === 'number' && ts < 1e12 ? ts * 1000 : ts).toLocaleString() : '—'
const tokensBefore = cp.tokensBefore ?? '—'
const tokensAfter = cp.tokensAfter ?? '—'
return `<div style="padding:10px 0;border-bottom:1px solid var(--border-primary);display:flex;justify-content:space-between;align-items:center;gap:8px">
<div style="min-width:0;flex:1">
<div style="font-size:13px;font-weight:500">#${idx + 1} · ${escapeAttr(timeStr)}</div>
<div style="font-size:12px;color:var(--text-tertiary);margin-top:2px">${tokensBefore}${tokensAfter} tokens</div>
</div>
<div style="display:flex;gap:4px;flex-shrink:0">
<button class="btn btn-sm btn-secondary" data-cp-branch="${escapeAttr(id)}">${t('chat.compactionBranch')}</button>
<button class="btn btn-sm btn-warning" data-cp-restore="${escapeAttr(id)}">${t('chat.compactionRestore')}</button>
</div>
</div>`
}).join('')
const overlay = document.createElement('div')
overlay.className = 'modal-overlay'
overlay.innerHTML = `<div class="modal" style="max-width:520px;max-height:80vh;overflow:auto">
<div class="modal-header"><h3>${escapeAttr(t('chat.compactionHistory'))}: ${escapeAttr(label)}</h3></div>
<div class="modal-body" style="padding:0 var(--space-md)">${listHtml}</div>
<div class="modal-footer"><button class="btn btn-secondary" data-cp-close>${t('common.close')}</button></div>
</div>`
document.body.appendChild(overlay)
overlay.addEventListener('click', async (e) => {
if (e.target === overlay || e.target.closest('[data-cp-close]')) {
overlay.remove()
return
}
const branchBtn = e.target.closest('[data-cp-branch]')
if (branchBtn) {
branchBtn.disabled = true
try {
const res = await wsClient.sessionsCompactionBranch(key, branchBtn.dataset.cpBranch)
toast(t('chat.compactionBranchDone'), 'success')
overlay.remove()
if (res?.key) void switchSession(res.key)
else refreshSessionList()
} catch (err) {
toast(`${t('common.operationFailed')}: ${err.message}`, 'error')
branchBtn.disabled = false
}
return
}
const restoreBtn = e.target.closest('[data-cp-restore]')
if (restoreBtn) {
const yes = await showConfirm(t('chat.compactionConfirmRestore'))
if (!yes) return
restoreBtn.disabled = true
try {
await wsClient.sessionsCompactionRestore(key, restoreBtn.dataset.cpRestore)
toast(t('chat.compactionRestoreDone'), 'success')
overlay.remove()
if (key === _sessionKey) {
clearMessages()
_lastHistoryHash = ''
loadHistory()
}
refreshSessionList()
} catch (err) {
toast(`${t('common.operationFailed')}: ${err.message}`, 'error')
restoreBtn.disabled = false
}
}
})
} catch (e) {
const msg = String(e?.message || e || '').toLowerCase()
if (msg.includes('unknown method') || msg.includes('not found') || msg.includes('unsupported')) {
toast(t('chat.compactionUnsupported'), 'warning')
} else {
toast(`${t('common.operationFailed')}: ${e.message}`, 'error')
}
}
}
async function resetCurrentSession() {
if (!_sessionKey) return
const label = getDisplayLabel(_sessionKey)
const yes = await showConfirm({
title: t('chat.resetSessionTitle', { label }),
message: t('chat.confirmResetSession', { label }),
impact: [
t('chat.resetSessionImpactHistory'),
t('chat.resetSessionImpactContext'),
],
confirmText: t('chat.resetSessionBtn'),
cancelText: t('chat.resetSessionCancel'),
})
if (!yes) return
try {
await wsClient.sessionsReset(_sessionKey)
clearMessages()
_lastHistoryHash = ''
appendSystemMessage(t('chat.sessionResetDone'))
toast(t('chat.sessionResetDone'), 'success')
} catch (e) {
toast(`${t('common.operationFailed')}: ${e.message}`, 'error')
}
}
function updateSessionTitle() {
const el = _page?.querySelector('#chat-title')
if (el) el.textContent = getDisplayLabel(_sessionKey)
syncWorkspaceContext(false)
}
function renameSession(key, labelEl) {
const current = getDisplayLabel(key)
const input = document.createElement('input')
input.type = 'text'
input.value = current
input.className = 'chat-session-rename-input'
input.style.cssText = 'width:100%;padding:2px 6px;border:1px solid var(--accent);border-radius:4px;background:var(--bg-secondary);color:var(--text-primary);font-size:12px;outline:none'
const originalText = labelEl.textContent
labelEl.textContent = ''
labelEl.appendChild(input)
input.focus()
input.select()
let done = false
const finish = () => {
if (done) return
done = true
const newName = input.value.trim()
if (newName && newName !== parseSessionLabel(key)) {
setSessionName(key, newName)
toast(t('chat.sessionRenamed'), 'success')
} else if (!newName || newName === parseSessionLabel(key)) {
setSessionName(key, '') // clear custom name
}
labelEl.textContent = getDisplayLabel(key)
// 如果是当前会话,同步更新顶部标题
if (key === _sessionKey) updateSessionTitle()
}
input.addEventListener('blur', finish)
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); input.blur() }
if (e.key === 'Escape') { input.value = originalText; input.blur() }
})
}
// ── 快捷指令面板 ──
function showCmdPanel() {
if (!_cmdPanelEl) return
let html = ''
for (const group of COMMANDS) {
html += `<div class="cmd-group-title">${t(group.title)}</div>`
for (const c of group.commands) {
html += `<div class="cmd-item" data-cmd="${c.cmd}" data-action="${c.action}">
<span class="cmd-name">${c.cmd}</span>
<span class="cmd-desc">${t(c.desc)}</span>
</div>`
}
}
_cmdPanelEl.innerHTML = html
_cmdPanelEl.style.display = 'block'
_cmdPanelEl.onclick = (e) => {
const item = e.target.closest('.cmd-item')
if (!item) return
hideCmdPanel()
if (item.dataset.action === 'fill') {
_textarea.value = item.dataset.cmd
_textarea.focus()
updateSendState()
} else {
_textarea.value = item.dataset.cmd
sendMessage()
}
}
}
function hideCmdPanel() {
if (_cmdPanelEl) _cmdPanelEl.style.display = 'none'
}
function toggleCmdPanel() {
if (_cmdPanelEl?.style.display === 'block') hideCmdPanel()
else { _textarea.value = '/'; showCmdPanel(); _textarea.focus() }
}
// ── 消息发送 ──
function sendMessage() {
const text = _textarea.value.trim()
if (!text && !_attachments.length) return
if (!wsClient.gatewayReady || !_sessionKey) {
toast(t('chat.gatewayNotReadySend'), 'warning')
return
}
hideCmdPanel()
_textarea.value = ''
_textarea.style.height = 'auto'
updateSendState()
const attachments = [..._attachments]
_attachments = []
renderAttachments()
if (_isSending || _isStreaming) { _messageQueue.push({ text, attachments }); return }
doSend(text, attachments)
}
async function doSend(text, attachments = []) {
if (!wsClient.gatewayReady || !_sessionKey) {
toast(t('chat.gatewayNotReadySend'), 'warning')
return
}
appendUserMessage(text, attachments)
saveMessage({
id: uuid(), sessionKey: _sessionKey, role: 'user', content: text, timestamp: Date.now(),
attachments: attachments?.length ? attachments.map(a => ({ category: a.category || 'image', mimeType: a.mimeType || '', content: a.content || '', url: a.url || '' })) : undefined
})
showTyping(true)
_isSending = true
_startResponseWatchdog()
try {
await wsClient.chatSend(_sessionKey, text, attachments.length ? attachments : undefined)
} catch (err) {
showTyping(false)
_cancelResponseWatchdog()
_sendTimestamp = 0
appendSystemMessage(`${t('chat.sendFailed')}${err.message}`)
} finally {
_isSending = false
updateSendState()
}
}
function processMessageQueue() {
if (_messageQueue.length === 0 || _isSending || _isStreaming) return
const msg = _messageQueue.shift()
if (typeof msg === 'string') doSend(msg, [])
else doSend(msg.text, msg.attachments || [])
}
function stopGeneration() {
if (_currentRunId) wsClient.chatAbort(_sessionKey, _currentRunId).catch(() => {})
}
// ── 事件处理(参照 clawapp 实现) ──
function handleEvent(msg) {
const { event, payload } = msg
if (!payload) return
// ── 处理所有 agent 事件OpenClaw 4.5+ 结构化进度) ──
if (event === 'agent') {
// 任何 agent 事件都说明 OpenClaw 在活跃处理,重置看门狗
_resetWatchdogOnActivity()
const stream = payload?.stream
const data = payload?.data || {}
// tool 事件(已有逻辑)
if (stream === 'tool' && data.toolCallId) {
const ts = payload.ts
const toolCallId = data.toolCallId
const runKey = `${payload.runId}:${toolCallId}`
if (_toolEventSeen.has(runKey)) return
_toolEventSeen.add(runKey)
if (ts) _toolEventTimes.set(toolCallId, ts)
const current = _toolEventData.get(toolCallId) || {}
if (data.args && current.input == null) current.input = data.args
if (data.meta && current.output == null) current.output = data.meta
if (typeof data.isError === 'boolean' && current.status == null) current.status = data.isError ? 'error' : 'ok'
if (current.time == null) current.time = ts || null
_toolEventData.set(toolCallId, current)
if (payload.runId) {
const list = _toolRunIndex.get(payload.runId) || []
if (!list.includes(toolCallId)) list.push(toolCallId)
_toolRunIndex.set(payload.runId, list)
}
const toolName = data.name || data.toolName || ''
if (toolName && !_isStreaming) {
showTyping(true, t('chat.usingTool', { name: toolName }))
}
}
// lifecycle 事件:处理开始/结束
if (stream === 'lifecycle') {
const phase = data.phase
if (phase === 'start' && !_isStreaming) {
showTyping(true, t('chat.aiProcessing'))
}
}
// item 事件4.5+ 结构化执行步骤tool/command/patch/search/analysis
if (stream === 'item') {
const title = data.title || data.name || ''
const kind = data.kind || ''
if ((data.phase === 'start' || data.phase === 'update') && !_isStreaming) {
const hint = kind === 'command' ? t('chat.commandRunning')
: kind === 'search' ? t('chat.aiSearching')
: kind === 'analysis' ? t('chat.aiAnalyzing')
: title ? t('chat.aiExecuting', { title })
: t('chat.aiProcessing')
showTyping(true, hint)
}
}
// plan 事件4.5+ 计划更新)
if (stream === 'plan' && !_isStreaming) {
showTyping(true, t('chat.aiPlanning'))
}
// approval 事件(操作审批)
if (stream === 'approval' && !_isStreaming) {
showTyping(true, t('chat.waitingApproval'))
}
// thinking 事件(推理/思考)
if (stream === 'thinking' && !_isStreaming) {
showTyping(true, t('chat.aiThinking'))
}
// command_output 事件(命令输出增量)
if (stream === 'command_output' && !_isStreaming) {
showTyping(true, t('chat.commandRunning'))
}
// compaction 事件
if (stream === 'compaction') {
showCompactionHint(true)
}
// error 事件
if (stream === 'error' && data.message && !_isStreaming) {
showTyping(true, `${data.message}`)
}
}
if (event === 'chat') handleChatEvent(payload)
// Compaction 状态指示:上游 2026.3.12 新增 status_reaction 事件
if (event === 'chat.status_reaction' || event === 'status_reaction') {
const reaction = payload.reaction || payload.emoji || ''
if (reaction.includes('compact') || reaction === '🗜️' || reaction === '📦') {
showCompactionHint(true)
} else if (!reaction || reaction === 'thinking' || reaction === '💭') {
showCompactionHint(false)
}
}
}
function handleChatEvent(payload) {
const hostedSessionKey = getHostedBoundSessionKey()
const isCurrentSession = !payload.sessionKey || !_sessionKey || payload.sessionKey === _sessionKey
const isHostedSession = !!payload.sessionKey && !!hostedSessionKey && payload.sessionKey === hostedSessionKey
// sessionKey 过滤:当前会话照常渲染;托管绑定会话在后台继续驱动循环
if (!isCurrentSession && !isHostedSession) return
if (!isCurrentSession && isHostedSession) {
if (payload.state === 'final' && shouldCaptureHostedTarget(payload)) {
const c = extractChatContent(payload.message)
const capturedText = c?.text || ''
if (capturedText) {
appendHostedTarget(capturedText)
if (detectStopFromText(capturedText)) {
stopHostedAgent()
} else {
maybeTriggerHostedRun()
}
}
}
if (payload.state === 'error' && _hostedSessionConfig?.enabled) {
_hostedRuntime.errorCount = (_hostedRuntime.errorCount || 0) + 1
_hostedRuntime.lastError = payload.errorMessage || payload.error?.message || t('common.error')
_hostedRuntime.pending = false
if (_hostedRuntime.errorCount >= _hostedSessionConfig.retryLimit) {
_hostedRuntime.status = HOSTED_STATUS.ERROR
}
persistHostedRuntime()
updateHostedBadge()
}
return
}
const { state } = payload
const runId = payload.runId
// 重复 run 过滤:跳过已完成的 runId 的后续事件Gateway 可能对同一消息触发多个 run
if (runId && state === 'final' && _seenRunIds.has(runId)) {
console.log('[chat] 跳过重复 final, runId:', runId)
return
}
if (runId && state === 'delta' && _seenRunIds.has(runId) && !_isStreaming) {
console.log('[chat] 跳过已完成 run 的 delta, runId:', runId)
return
}
if (state === 'delta') {
_cancelResponseWatchdog()
const c = extractChatContent(payload.message)
if (c?.images?.length) _currentAiImages = c.images
if (c?.videos?.length) _currentAiVideos = c.videos
if (c?.audios?.length) _currentAiAudios = c.audios
if (c?.files?.length) _currentAiFiles = c.files
if (c?.tools?.length) _currentAiTools = c.tools
// 增量 delta 协议下,当输出文本不是前缀扩展时(例如内容回滚或重排),
// 对端会带 replace=true此时新文本可能比当前缓存短必须无条件覆盖否则会丢失最新内容。
const isReplace = payload.replace === true
if (c?.text && (isReplace || c.text.length > _currentAiText.length)) {
showTyping(false)
if (!_currentAiBubble) {
_currentAiBubble = createStreamBubble()
_currentRunId = payload.runId
_isStreaming = true
_streamStartTime = Date.now()
updateSendState()
}
_currentAiText = c.text
// 每次收到 delta 重置安全超时90s 无新 delta 则强制结束)
clearTimeout(_streamSafetyTimer)
_streamSafetyTimer = setTimeout(() => {
if (_isStreaming) {
console.warn('[chat] 流式输出超时90s 无新数据),强制结束')
if (_currentAiBubble && _currentAiText) {
_currentAiBubble.innerHTML = renderMarkdown(_currentAiText)
}
appendSystemMessage(t('chat.streamTimeout'))
resetStreamState()
processMessageQueue()
}
}, 90000)
throttledRender()
}
return
}
if (state === 'final') {
_cancelResponseWatchdog()
const c = extractChatContent(payload.message)
const finalText = c?.text || ''
const finalImages = c?.images || []
const finalVideos = c?.videos || []
const finalAudios = c?.audios || []
const finalFiles = c?.files || []
let finalTools = c?.tools || []
if (!finalTools.length && runId) {
const ids = _toolRunIndex.get(runId) || []
finalTools = ids.map(id => mergeToolEventData({ id, name: 'tool' })).filter(Boolean)
}
if (finalImages.length) _currentAiImages = finalImages
if (finalVideos.length) _currentAiVideos = finalVideos
if (finalAudios.length) _currentAiAudios = finalAudios
if (finalFiles.length) _currentAiFiles = finalFiles
if (finalTools.length) _currentAiTools = finalTools
const hasContent = finalText || _currentAiImages.length || _currentAiVideos.length || _currentAiAudios.length || _currentAiFiles.length || _currentAiTools.length
// 忽略空 finalGateway 会为一条消息触发多个 run部分是空 final
if (!_currentAiBubble && !hasContent) return
// 标记 runId 为已处理,防止重复
if (runId) {
_seenRunIds.add(runId)
if (_seenRunIds.size > 200) {
const first = _seenRunIds.values().next().value
_seenRunIds.delete(first)
}
}
showTyping(false)
// 如果流式阶段没有创建 bubble从 final message 中提取
if (!_currentAiBubble && hasContent) {
_currentAiBubble = createStreamBubble()
_currentAiText = finalText
}
if (_currentAiBubble) {
if (_currentAiText) _currentAiBubble.innerHTML = renderMarkdown(_currentAiText)
appendImagesToEl(_currentAiBubble, _currentAiImages)
appendVideosToEl(_currentAiBubble, _currentAiVideos)
appendAudiosToEl(_currentAiBubble, _currentAiAudios)
appendFilesToEl(_currentAiBubble, _currentAiFiles)
appendToolsToEl(_currentAiBubble, finalTools.length ? finalTools : _currentAiTools)
}
// 添加时间戳 + 耗时 + token 消耗
const wrapper = _currentAiBubble?.parentElement
if (wrapper) {
const meta = document.createElement('div')
meta.className = 'msg-meta'
let parts = [`<span class="msg-time">${formatTime(new Date())}</span>`]
// 计算响应耗时
let durStr = ''
if (payload.durationMs) {
durStr = (payload.durationMs / 1000).toFixed(1) + 's'
} else if (_streamStartTime) {
durStr = ((Date.now() - _streamStartTime) / 1000).toFixed(1) + 's'
}
if (durStr) parts.push(`<span class="meta-sep">·</span><span class="msg-duration">⏱ ${durStr}</span>`)
// token 消耗(从 payload.usage 或 payload.message.usage 提取)
const usage = payload.usage || payload.message?.usage || null
if (usage) {
const inp = usage.input_tokens || usage.prompt_tokens || 0
const out = usage.output_tokens || usage.completion_tokens || 0
const total = usage.total_tokens || (inp + out)
if (total > 0) {
let tokenStr = `${total} tokens`
if (inp && out) tokenStr = `${inp}${out}`
parts.push(`<span class="meta-sep">·</span><span class="msg-tokens">${tokenStr}</span>`)
}
}
parts.push(`<button class="msg-copy-btn" title="${t('common.copy')}">${svgIcon('copy', 12)}</button>`)
meta.innerHTML = parts.join('')
wrapper.appendChild(meta)
}
if (_currentAiText || _currentAiImages.length) {
saveMessage({
id: payload.runId || uuid(), sessionKey: _sessionKey, role: 'assistant',
content: _currentAiText, timestamp: Date.now(),
attachments: _currentAiImages.map(i => ({ category: 'image', mimeType: i.mediaType || 'image/png', url: i.url, content: i.data })).filter(a => a.url || a.content)
})
}
// 托管 Agent捕获 AI 回复,检测停止信号,决定是否继续
if (shouldCaptureHostedTarget(payload)) {
const capturedText = finalText || _currentAiText || ''
if (capturedText) {
appendHostedTarget(capturedText)
if (detectStopFromText(capturedText)) {
appendHostedOutput(t('chat.hostedAutoStopSignal'))
stopHostedAgent()
} else {
maybeTriggerHostedRun()
}
}
}
resetStreamState()
_schedulePostFinalCheck()
processMessageQueue()
return
}
if (state === 'aborted') {
showTyping(false)
if (_currentAiBubble && _currentAiText) {
_currentAiBubble.innerHTML = renderMarkdown(_currentAiText)
}
appendSystemMessage(t('chat.generationStopped'))
resetStreamState()
processMessageQueue()
return
}
if (state === 'error') {
const errMsg = payload.errorMessage || payload.error?.message || t('common.error')
// 连接级错误origin/pairing/auth拦截不作为聊天消息显示
if (/origin not allowed|NOT_PAIRED|PAIRING_REQUIRED|auth.*fail/i.test(errMsg)) {
console.warn('[chat] 拦截连接级错误,不显示为聊天消息:', errMsg)
const overlay = document.getElementById('chat-connect-overlay')
if (overlay) {
overlay.style.display = 'flex'
const desc = document.getElementById('chat-connect-desc')
if (desc) desc.textContent = t('chat.connectionRejected')
}
return
}
// 防抖:如果是相同错误且在 2 秒内,忽略(避免重复显示)
const now = Date.now()
if (_lastErrorMsg === errMsg && _errorTimer && (now - _errorTimer < 2000)) {
console.warn('[chat] 忽略重复错误:', errMsg)
return
}
_lastErrorMsg = errMsg
_errorTimer = now
// 如果正在流式输出,说明消息已经部分成功,不显示错误
if (_isStreaming || _currentAiBubble) {
console.warn('[chat] 流式中收到错误,但消息已部分成功,忽略错误提示:', errMsg)
return
}
showTyping(false)
appendSystemMessage(`${t('chat.errorPrefix')}${errMsg}`)
resetStreamState()
processMessageQueue()
return
}
}
/** 从 Gateway message 对象提取文本和所有媒体(参照 clawapp extractContent */
function extractChatContent(message) {
if (!message || typeof message !== 'object') return null
const tools = []
collectToolsFromMessage(message, tools)
if (message.role === 'tool' || message.role === 'toolResult') {
const output = typeof message.content === 'string' ? message.content : null
if (!tools.length) {
tools.push({
name: message.name || message.tool || message.tool_name || 'tool',
input: message.input || message.args || message.parameters || null,
output: output || message.output || message.result || null,
status: message.status || 'ok',
})
} else if (output && !tools[0].output) {
tools[0].output = output
}
return { text: '', images: [], videos: [], audios: [], files: [], tools }
}
const content = message.content
if (typeof content === 'string') return { text: stripThinkingTags(content), images: [], videos: [], audios: [], files: [], tools }
if (Array.isArray(content)) {
const texts = [], images = [], videos = [], audios = [], files = []
for (const block of content) {
if (block.type === 'text' && typeof block.text === 'string') texts.push(block.text)
else if (block.type === 'image' && !block.omitted) {
if (block.data) images.push({ mediaType: block.mimeType || 'image/png', data: block.data })
else if (block.source?.type === 'base64' && block.source.data) images.push({ mediaType: block.source.media_type || 'image/png', data: block.source.data })
else if (block.url || block.source?.url) images.push({ url: block.url || block.source.url, mediaType: block.mimeType || 'image/png' })
}
else if (block.type === 'image_url' && block.image_url?.url) images.push({ url: block.image_url.url, mediaType: 'image/png' })
else if (block.type === 'video') {
if (block.data) videos.push({ mediaType: block.mimeType || 'video/mp4', data: block.data })
else if (block.url) videos.push({ url: block.url, mediaType: block.mimeType || 'video/mp4' })
}
else if (block.type === 'audio' || block.type === 'voice') {
if (block.data) audios.push({ mediaType: block.mimeType || 'audio/mpeg', data: block.data, duration: block.duration })
else if (block.url) audios.push({ url: block.url, mediaType: block.mimeType || 'audio/mpeg', duration: block.duration })
}
else if (block.type === 'file' || block.type === 'document') {
files.push({ url: block.url || '', name: block.fileName || block.name || 'file', mimeType: block.mimeType || '', size: block.size, data: block.data })
}
else if (block.type === 'tool' || block.type === 'tool_use' || block.type === 'tool_call' || block.type === 'toolCall') {
const callId = block.id || block.tool_call_id || block.toolCallId
upsertTool(tools, {
id: callId,
name: block.name || block.tool || block.tool_name || block.toolName || 'tool',
input: block.input || block.args || block.parameters || block.arguments || null,
output: null,
status: block.status || 'ok',
time: resolveToolTime(callId, message.timestamp),
})
}
else if (block.type === 'tool_result' || block.type === 'toolResult') {
const resId = block.id || block.tool_call_id || block.toolCallId
upsertTool(tools, {
id: resId,
name: block.name || block.tool || block.tool_name || block.toolName || 'tool',
input: block.input || block.args || null,
output: block.output || block.result || block.content || null,
status: block.status || 'ok',
time: resolveToolTime(resId, message.timestamp),
})
}
}
if (tools.length) {
tools.forEach(t => {
if (typeof t.input === 'string') t.input = stripAnsi(t.input)
if (typeof t.output === 'string') t.output = stripAnsi(t.output)
})
}
// 从 mediaUrl/mediaUrls 提取
const mediaUrls = message.mediaUrls || (message.mediaUrl ? [message.mediaUrl] : [])
for (const url of mediaUrls) {
if (!url) continue
if (/\.(mp4|webm|mov|mkv)(\?|$)/i.test(url)) videos.push({ url, mediaType: 'video/mp4' })
else if (/\.(mp3|wav|ogg|m4a|aac|flac)(\?|$)/i.test(url)) audios.push({ url, mediaType: 'audio/mpeg' })
else if (/\.(jpe?g|png|gif|webp|heic|svg)(\?|$)/i.test(url)) images.push({ url, mediaType: 'image/png' })
else files.push({ url, name: url.split('/').pop().split('?')[0] || 'file', mimeType: '' })
}
const text = texts.length ? stripThinkingTags(texts.join('\n')) : ''
return { text, images, videos, audios, files, tools }
}
if (typeof message.text === 'string') return { text: stripThinkingTags(message.text), images: [], videos: [], audios: [], files: [], tools: [] }
return null
}
function stripAnsi(text) {
if (!text) return ''
return text.replace(/\u001b\[[0-9;]*[A-Za-z]/g, '')
}
function escapeHtml(text) {
return (text || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;')
}
function stripThinkingTags(text) {
const safe = stripAnsi(text)
return safe
.replace(/<\s*think(?:ing)?\s*>[\s\S]*?<\s*\/\s*think(?:ing)?\s*>/gi, '')
.replace(/Conversation info \(untrusted metadata\):\s*```json[\s\S]*?```\s*/gi, '')
.replace(/\[Queued messages while agent was busy\]\s*---\s*Queued #\d+\s*/gi, '')
.trim()
}
function normalizeTime(raw) {
if (!raw) return null
if (raw instanceof Date) return raw.getTime()
if (typeof raw === 'string') {
const num = Number(raw)
if (!Number.isNaN(num)) raw = num
else {
const parsed = Date.parse(raw)
return Number.isNaN(parsed) ? null : parsed
}
}
if (typeof raw === 'number' && raw < 1e12) return raw * 1000
return raw
}
function resolveToolTime(toolId, messageTimestamp) {
const eventTs = toolId ? _toolEventTimes.get(toolId) : null
return normalizeTime(eventTs) || normalizeTime(messageTimestamp) || null
}
function getToolTime(tool) {
const raw = tool?.end_time || tool?.endTime || tool?.timestamp || tool?.time || tool?.started_at || tool?.startedAt || null
return normalizeTime(raw)
}
function safeStringify(value) {
if (value == null) return ''
const seen = new WeakSet()
try {
return JSON.stringify(value, (key, val) => {
if (typeof val === 'bigint') return val.toString()
if (typeof val === 'object' && val !== null) {
if (seen.has(val)) return '[Circular]'
seen.add(val)
}
return val
}, 2)
} catch {
try { return String(value) } catch { return '' }
}
}
function formatTime(date) {
const now = new Date()
const h = date.getHours().toString().padStart(2, '0')
const m = date.getMinutes().toString().padStart(2, '0')
const isToday = date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate()
if (isToday) return `${h}:${m}`
const mon = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
return `${mon}-${day} ${h}:${m}`
}
function formatFileSize(bytes) {
if (!bytes || bytes <= 0) return ''
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
/** 创建流式 AI 气泡 */
function createStreamBubble() {
if (!_messagesEl || !_typingEl) return null
showTyping(false)
const wrap = document.createElement('div')
wrap.className = 'msg msg-ai'
const bubble = document.createElement('div')
bubble.className = 'msg-bubble'
bubble.innerHTML = '<span class="stream-cursor"></span>'
wrap.appendChild(bubble)
_messagesEl.insertBefore(wrap, _typingEl)
scrollToBottom()
return bubble
}
// ── 流式渲染(节流) ──
function throttledRender() {
if (_renderPending) return
const now = performance.now()
if (now - _lastRenderTime >= RENDER_THROTTLE) {
doRender()
} else {
_renderPending = true
requestAnimationFrame(() => { _renderPending = false; doRender() })
}
}
function doRender() {
_lastRenderTime = performance.now()
if (_currentAiBubble && _currentAiText) {
_currentAiBubble.innerHTML = renderMarkdown(_currentAiText)
scrollToBottom()
}
}
// ── 响应看门狗:防止页面卡在等待状态 ──
const WATCHDOG_INTERVAL = 15000 // 15s 轮询间隔
const ULTIMATE_TIMEOUT = 180000 // 3 分钟终极超时
function _startResponseWatchdog() {
// 只清除轮询定时器,不清除终极超时(终极超时应持续到收到响应)
clearTimeout(_responseWatchdog)
_responseWatchdog = null
_sendTimestamp = _sendTimestamp || Date.now()
// 启动终极超时3分钟内如果没有收到任何 chat 事件则放弃)
if (!_ultimateTimer) {
_ultimateTimer = setTimeout(() => {
_ultimateTimer = null
if (!_isStreaming && _sessionKey && _pageActive) {
console.warn('[chat] 终极超时: 3分钟无 chat 回复')
showTyping(false)
appendSystemMessage(t('chat.responseTimeout', { seconds: Math.round(ULTIMATE_TIMEOUT / 1000) }))
_cancelResponseWatchdog()
resetStreamState()
processMessageQueue()
}
}, ULTIMATE_TIMEOUT)
}
_responseWatchdog = setTimeout(async () => {
_responseWatchdog = null
// 如果还在等待(未开始流式),强制刷新历史
if (!_isStreaming && _sessionKey && _messagesEl && _pageActive) {
const elapsed = Math.round((Date.now() - _sendTimestamp) / 1000)
console.log(`[chat] 响应看门狗触发:${elapsed}s 无 delta刷新历史`)
const oldHash = _lastHistoryHash
_lastHistoryHash = ''
await loadHistory()
// 如果历史有更新,关闭 typing 指示器
if (_lastHistoryHash && _lastHistoryHash !== oldHash) {
showTyping(false)
_cancelUltimateTimer()
} else {
// 历史没更新,更新 typing 提示显示已等待时间
if (elapsed >= 30) {
showTyping(true, `${t('chat.stillWaiting')} (${t('chat.elapsedTime', { seconds: elapsed })})`)
}
// 继续等待,再设一轮看门狗
_startResponseWatchdog()
}
}
}, WATCHDOG_INTERVAL)
}
function _resetWatchdogOnActivity() {
// agent 事件说明 OpenClaw 在活跃处理,重置轮询看门狗(但不重置终极超时)
if (_responseWatchdog) {
clearTimeout(_responseWatchdog)
_responseWatchdog = setTimeout(async () => {
_responseWatchdog = null
if (!_isStreaming && _sessionKey && _messagesEl && _pageActive) {
const elapsed = _sendTimestamp ? Math.round((Date.now() - _sendTimestamp) / 1000) : 0
console.log(`[chat] agent 活跃后看门狗触发:${elapsed}s`)
const oldHash = _lastHistoryHash
_lastHistoryHash = ''
await loadHistory()
if (_lastHistoryHash && _lastHistoryHash !== oldHash) {
showTyping(false)
_cancelUltimateTimer()
} else {
_startResponseWatchdog()
}
}
}, WATCHDOG_INTERVAL)
}
}
function _cancelResponseWatchdog() {
clearTimeout(_responseWatchdog)
_responseWatchdog = null
_cancelUltimateTimer()
}
function _cancelUltimateTimer() {
clearTimeout(_ultimateTimer)
_ultimateTimer = null
}
function _schedulePostFinalCheck() {
clearTimeout(_postFinalCheck)
_postFinalCheck = setTimeout(async () => {
_postFinalCheck = null
if (_sessionKey && _messagesEl && _pageActive && !_isStreaming && !_isSending) {
_lastHistoryHash = ''
await loadHistory()
}
}, 2000)
}
// ensureAiBubble 已被 createStreamBubble 替代
function resetStreamState() {
clearTimeout(_streamSafetyTimer)
clearInterval(_typingElapsedInterval)
_typingElapsedInterval = null
if (_currentAiBubble && (_currentAiText || _currentAiImages.length || _currentAiVideos.length || _currentAiAudios.length || _currentAiFiles.length || _currentAiTools.length)) {
_currentAiBubble.innerHTML = renderMarkdown(_currentAiText)
appendImagesToEl(_currentAiBubble, _currentAiImages)
appendVideosToEl(_currentAiBubble, _currentAiVideos)
appendAudiosToEl(_currentAiBubble, _currentAiAudios)
appendFilesToEl(_currentAiBubble, _currentAiFiles)
appendToolsToEl(_currentAiBubble, _currentAiTools)
}
_renderPending = false
_lastRenderTime = 0
_currentAiBubble = null
_currentAiText = ''
_currentAiImages = []
_currentAiVideos = []
_currentAiAudios = []
_currentAiFiles = []
_currentAiTools = []
_currentRunId = null
_isStreaming = false
_streamStartTime = 0
_lastErrorMsg = null
_errorTimer = null
_sendTimestamp = 0
showTyping(false)
updateSendState()
}
// ── 历史消息加载 ──
async function loadHistory() {
if (!_sessionKey || !_messagesEl) return
_isLoadingHistory = true
const hasExisting = _messagesEl.querySelector('.msg')
if (!hasExisting && isStorageAvailable()) {
const local = await getLocalMessages(_sessionKey, 200)
if (local.length) {
clearMessages()
local.forEach(msg => {
if (!msg.content && !msg.attachments?.length) return
const msgTime = msg.timestamp ? new Date(msg.timestamp) : new Date()
if (msg.role === 'user') appendUserMessage(msg.content || '', msg.attachments || null, msgTime)
else if (msg.role === 'assistant') {
const images = (msg.attachments || []).filter(a => a.category === 'image').map(a => ({ mediaType: a.mimeType, data: a.content, url: a.url }))
appendAiMessage(msg.content || '', msgTime, images, [], [], [], [])
}
})
scrollToBottom()
}
}
if (!wsClient.gatewayReady) { _isLoadingHistory = false; return }
try {
const result = await wsClient.chatHistory(_sessionKey, 200)
if (!result?.messages?.length) {
if (_messagesEl && !_messagesEl.querySelector('.msg')) appendSystemMessage(t('chat.noMessages'))
return
}
const deduped = dedupeHistory(result.messages)
const hash = deduped.map(m => `${m.role}:${(m.text || '').length}`).join('|')
if (hash === _lastHistoryHash && hasExisting) return
_lastHistoryHash = hash
// 正在发送/流式输出时不全量重绘,避免覆盖本地乐观渲染
if (hasExisting && (_isSending || _isStreaming || _messageQueue.length > 0)) {
saveMessages(result.messages.map(m => {
const c = extractContent(m)
const role = (m.role === 'tool' || m.role === 'toolResult') ? 'assistant' : m.role
return { id: m.id || uuid(), sessionKey: _sessionKey, role, content: c?.text || '', timestamp: m.timestamp || Date.now() }
}))
_isLoadingHistory = false
return
}
clearMessages()
let hasOmittedImages = false
deduped.forEach(msg => {
if (!msg.text && !msg.images?.length && !msg.videos?.length && !msg.audios?.length && !msg.files?.length && !msg.tools?.length) return
const msgTime = msg.timestamp ? new Date(msg.timestamp) : new Date()
if (msg.role === 'user') {
const userAtts = msg.images?.length ? msg.images.map(i => ({
mimeType: i.mediaType || i.media_type || 'image/png',
content: i.data || i.source?.data || '',
category: 'image',
})).filter(a => a.content) : []
if (msg.images?.length && !userAtts.length) hasOmittedImages = true
appendUserMessage(msg.text, userAtts, msgTime)
} else if (msg.role === 'assistant') {
appendAiMessage(msg.text, msgTime, msg.images, msg.videos, msg.audios, msg.files, msg.tools)
}
})
if (hasOmittedImages) {
appendSystemMessage(t('chat.imageHistoryHint'))
}
saveMessages(result.messages.map(m => {
const c = extractContent(m)
const role = (m.role === 'tool' || m.role === 'toolResult') ? 'assistant' : m.role
return { id: m.id || uuid(), sessionKey: _sessionKey, role, content: c?.text || '', timestamp: m.timestamp || Date.now() }
}))
scrollToBottom()
} catch (e) {
console.error('[chat] loadHistory error:', e)
if (_messagesEl && !_messagesEl.querySelector('.msg')) appendSystemMessage(`${t('common.loadFailed')}: ${e.message}`)
} finally {
_isLoadingHistory = false
}
}
function dedupeHistory(messages) {
const deduped = []
for (const msg of messages) {
const role = (msg.role === 'tool' || msg.role === 'toolResult') ? 'assistant' : msg.role
const c = extractContent(msg)
if (!c.text && !c.images.length && !c.videos.length && !c.audios.length && !c.files.length && !c.tools.length) continue
const tools = (c.tools || []).map(t => {
const id = t.id || t.tool_call_id
const time = t.time || resolveToolTime(id, msg.timestamp)
return { ...t, time, messageTimestamp: msg.timestamp }
})
const last = deduped[deduped.length - 1]
if (last && last.role === role) {
if (role === 'user' && last.text === c.text) continue
if (role === 'assistant') {
// 同文本去重Gateway 重试产生的重复回复)
if (c.text && last.text === c.text) continue
// 不同文本则合并
last.text = [last.text, c.text].filter(Boolean).join('\n')
last.images = [...(last.images || []), ...c.images]
last.videos = [...(last.videos || []), ...c.videos]
last.audios = [...(last.audios || []), ...c.audios]
last.files = [...(last.files || []), ...c.files]
tools.forEach(t => upsertTool(last.tools, t))
continue
}
}
deduped.push({ role, text: c.text, images: c.images, videos: c.videos, audios: c.audios, files: c.files, tools, timestamp: msg.timestamp })
}
return deduped
}
function extractContent(msg) {
const tools = []
collectToolsFromMessage(msg, tools)
if (msg.role === 'tool' || msg.role === 'toolResult') {
const output = typeof msg.content === 'string' ? msg.content : null
if (!tools.length) {
upsertTool(tools, {
id: msg.id || msg.tool_call_id || msg.toolCallId,
name: msg.name || msg.tool || msg.tool_name || 'tool',
input: msg.input || msg.args || msg.parameters || null,
output: output || msg.output || msg.result || null,
status: msg.status || 'ok',
time: resolveToolTime(msg.tool_call_id || msg.toolCallId || msg.id, msg.timestamp),
})
} else if (output && !tools[0].output) {
tools[0].output = output
}
return { text: '', images: [], videos: [], audios: [], files: [], tools }
}
if (Array.isArray(msg.content)) {
const texts = [], images = [], videos = [], audios = [], files = []
for (const block of msg.content) {
if (block.type === 'text' && typeof block.text === 'string') texts.push(block.text)
else if (block.type === 'image' && !block.omitted) {
if (block.data) images.push({ mediaType: block.mimeType || 'image/png', data: block.data })
else if (block.source?.type === 'base64' && block.source.data) images.push({ mediaType: block.source.media_type || 'image/png', data: block.source.data })
else if (block.url || block.source?.url) images.push({ url: block.url || block.source.url, mediaType: block.mimeType || 'image/png' })
}
else if (block.type === 'image_url' && block.image_url?.url) images.push({ url: block.image_url.url, mediaType: 'image/png' })
else if (block.type === 'video') {
if (block.data) videos.push({ mediaType: block.mimeType || 'video/mp4', data: block.data })
else if (block.url) videos.push({ url: block.url, mediaType: block.mimeType || 'video/mp4' })
}
else if (block.type === 'audio' || block.type === 'voice') {
if (block.data) audios.push({ mediaType: block.mimeType || 'audio/mpeg', data: block.data, duration: block.duration })
else if (block.url) audios.push({ url: block.url, mediaType: block.mimeType || 'audio/mpeg', duration: block.duration })
}
else if (block.type === 'file' || block.type === 'document') {
files.push({ url: block.url || '', name: block.fileName || block.name || 'file', mimeType: block.mimeType || '', size: block.size, data: block.data })
}
else if (block.type === 'tool' || block.type === 'tool_use' || block.type === 'tool_call' || block.type === 'toolCall') {
const callId = block.id || block.tool_call_id || block.toolCallId
upsertTool(tools, {
id: callId,
name: block.name || block.tool || block.tool_name || block.toolName || 'tool',
input: block.input || block.args || block.parameters || block.arguments || null,
output: null,
status: block.status || 'ok',
time: resolveToolTime(callId, msg.timestamp),
})
}
else if (block.type === 'tool_result' || block.type === 'toolResult') {
const resId = block.id || block.tool_call_id || block.toolCallId
upsertTool(tools, {
id: resId,
name: block.name || block.tool || block.tool_name || block.toolName || 'tool',
input: block.input || block.args || null,
output: block.output || block.result || block.content || null,
status: block.status || 'ok',
time: resolveToolTime(resId, msg.timestamp),
})
}
}
if (tools.length) {
tools.forEach(t => {
if (typeof t.input === 'string') t.input = stripAnsi(t.input)
if (typeof t.output === 'string') t.output = stripAnsi(t.output)
})
}
const mediaUrls = msg.mediaUrls || (msg.mediaUrl ? [msg.mediaUrl] : [])
for (const url of mediaUrls) {
if (!url) continue
if (/\.(mp4|webm|mov|mkv)(\?|$)/i.test(url)) videos.push({ url, mediaType: 'video/mp4' })
else if (/\.(mp3|wav|ogg|m4a|aac|flac)(\?|$)/i.test(url)) audios.push({ url, mediaType: 'audio/mpeg' })
else if (/\.(jpe?g|png|gif|webp|heic|svg)(\?|$)/i.test(url)) images.push({ url, mediaType: 'image/png' })
else files.push({ url, name: url.split('/').pop().split('?')[0] || 'file', mimeType: '' })
}
return { text: stripThinkingTags(texts.join('\n')), images, videos, audios, files, tools }
}
const text = typeof msg.text === 'string' ? msg.text : (typeof msg.content === 'string' ? msg.content : '')
return { text: stripThinkingTags(text), images: [], videos: [], audios: [], files: [], tools }
}
// ── DOM 操作 ──
function appendUserMessage(text, attachments = [], msgTime) {
const wrap = document.createElement('div')
wrap.className = 'msg msg-user'
const bubble = document.createElement('div')
bubble.className = 'msg-bubble'
if (attachments && attachments.length > 0) {
const mediaContainer = document.createElement('div')
mediaContainer.style.cssText = 'display:flex;gap:4px;margin-bottom:8px;flex-wrap:wrap'
attachments.forEach(att => {
const cat = att.category || att.type || 'image'
const src = att.data ? `data:${att.mimeType || att.mediaType || 'image/png'};base64,${att.data}`
: att.content ? `data:${att.mimeType || 'image/png'};base64,${att.content}`
: att.url || ''
if (cat === 'image' && src) {
const img = document.createElement('img')
img.src = src
img.className = 'msg-img'
img.onclick = () => showLightbox(img.src)
mediaContainer.appendChild(img)
} else if (cat === 'video' && src) {
const video = document.createElement('video')
video.src = src
video.className = 'msg-video'
video.controls = true
video.preload = 'metadata'
video.playsInline = true
mediaContainer.appendChild(video)
} else if (cat === 'audio' && src) {
const audio = document.createElement('audio')
audio.src = src
audio.className = 'msg-audio'
audio.controls = true
audio.preload = 'metadata'
mediaContainer.appendChild(audio)
} else if (att.fileName || att.name) {
const card = document.createElement('div')
card.className = 'msg-file-card'
card.innerHTML = `<span class="msg-file-icon">${svgIcon('paperclip', 16)}</span><span class="msg-file-name">${att.fileName || att.name}</span>`
mediaContainer.appendChild(card)
}
})
if (mediaContainer.children.length) bubble.appendChild(mediaContainer)
}
if (text) {
const textNode = document.createElement('div')
textNode.textContent = text
bubble.appendChild(textNode)
}
const meta = document.createElement('div')
meta.className = 'msg-meta'
meta.innerHTML = `<span class="msg-time">${formatTime(msgTime || new Date())}</span><button class="msg-copy-btn" title="${t('common.copy')}">${svgIcon('copy', 12)}</button>`
wrap.appendChild(bubble)
wrap.appendChild(meta)
_messagesEl.insertBefore(wrap, _typingEl)
scrollToBottom()
}
function appendAiMessage(text, msgTime, images, videos, audios, files, tools) {
const wrap = document.createElement('div')
wrap.className = 'msg msg-ai'
const bubble = document.createElement('div')
bubble.className = 'msg-bubble'
appendToolsToEl(bubble, tools)
const textEl = document.createElement('div')
textEl.className = 'msg-text'
textEl.innerHTML = renderMarkdown(text || '')
bubble.appendChild(textEl)
appendImagesToEl(bubble, images)
appendVideosToEl(bubble, videos)
appendAudiosToEl(bubble, audios)
appendFilesToEl(bubble, files)
// 图片点击灯箱
bubble.querySelectorAll('img').forEach(img => { if (!img.onclick) img.onclick = () => showLightbox(img.src) })
const meta = document.createElement('div')
meta.className = 'msg-meta'
meta.innerHTML = `<span class="msg-time">${formatTime(msgTime || new Date())}</span><button class="msg-copy-btn" title="${t('common.copy')}">${svgIcon('copy', 12)}</button>`
wrap.appendChild(bubble)
wrap.appendChild(meta)
_messagesEl.insertBefore(wrap, _typingEl)
scrollToBottom()
}
/** 渲染图片到消息气泡(支持 Anthropic/OpenAI/直接格式) */
function appendImagesToEl(el, images) {
if (!images?.length) return
const container = document.createElement('div')
container.style.cssText = 'display:flex;gap:6px;margin-top:8px;flex-wrap:wrap'
images.forEach(img => {
const imgEl = document.createElement('img')
// Anthropic 格式: { type: 'image', source: { data, media_type } }
if (img.source?.data) {
imgEl.src = `data:${img.source.media_type || 'image/png'};base64,${img.source.data}`
// 直接格式: { data, mediaType }
} else if (img.data) {
imgEl.src = `data:${img.mediaType || img.media_type || 'image/png'};base64,${img.data}`
// OpenAI 格式: { type: 'image_url', image_url: { url } }
} else if (img.image_url?.url) {
imgEl.src = img.image_url.url
// URL 格式
} else if (img.url) {
imgEl.src = img.url
} else {
return
}
imgEl.style.cssText = 'max-width:300px;max-height:300px;border-radius:6px;cursor:pointer'
imgEl.onclick = () => showLightbox(imgEl.src)
container.appendChild(imgEl)
})
if (container.children.length) el.appendChild(container)
}
/** 渲染视频到消息气泡 */
function appendVideosToEl(el, videos) {
if (!videos?.length) return
videos.forEach(vid => {
const videoEl = document.createElement('video')
videoEl.className = 'msg-video'
videoEl.controls = true
videoEl.preload = 'metadata'
videoEl.playsInline = true
if (vid.data) videoEl.src = `data:${vid.mediaType};base64,${vid.data}`
else if (vid.url) videoEl.src = vid.url
el.appendChild(videoEl)
})
}
/** 渲染音频到消息气泡 */
function appendAudiosToEl(el, audios) {
if (!audios?.length) return
audios.forEach(aud => {
const audioEl = document.createElement('audio')
audioEl.className = 'msg-audio'
audioEl.controls = true
audioEl.preload = 'metadata'
if (aud.data) audioEl.src = `data:${aud.mediaType};base64,${aud.data}`
else if (aud.url) audioEl.src = aud.url
el.appendChild(audioEl)
})
}
/** 渲染文件卡片到消息气泡 */
function appendFilesToEl(el, files) {
if (!files?.length) return
files.forEach(f => {
const card = document.createElement('div')
card.className = 'msg-file-card'
const ext = (f.name || '').split('.').pop().toLowerCase()
const fileIconMap = { pdf: 'file', doc: 'file-text', docx: 'file-text', txt: 'file-plain', md: 'file-plain', json: 'clipboard', csv: 'bar-chart', zip: 'package', rar: 'package' }
const fileIcon = svgIcon(fileIconMap[ext] || 'paperclip', 16)
const size = f.size ? formatFileSize(f.size) : ''
card.innerHTML = `<span class="msg-file-icon">${fileIcon}</span><div class="msg-file-info"><span class="msg-file-name">${f.name || 'file'}</span>${size ? `<span class="msg-file-size">${size}</span>` : ''}</div>`
if (f.url) {
card.style.cursor = 'pointer'
card.onclick = () => window.open(f.url, '_blank')
} else if (f.data) {
card.style.cursor = 'pointer'
card.onclick = () => {
const a = document.createElement('a')
a.href = `data:${f.mimeType || 'application/octet-stream'};base64,${f.data}`
a.download = f.name || 'file'
a.click()
}
}
el.appendChild(card)
})
}
function mergeToolEventData(entry) {
const id = entry?.id || entry?.tool_call_id
if (!id) return entry
const extra = _toolEventData.get(id)
if (!extra) return entry
if (entry.input == null && extra.input != null) entry.input = extra.input
if (entry.output == null && extra.output != null) entry.output = extra.output
if (entry.status == null && extra.status != null) entry.status = extra.status
if (entry.time == null) entry.time = extra.time || _toolEventTimes.get(id) || null
return entry
}
function upsertTool(tools, entry) {
if (!entry) return
const id = entry.id || entry.tool_call_id
let target = null
if (id) target = tools.find(t => t.id === id || t.tool_call_id === id)
if (!target && entry.name) target = tools.find(t => t.name === entry.name && !t.output)
if (target) {
if (entry.input != null && target.input == null) target.input = entry.input
if (entry.output != null && target.output == null) target.output = entry.output
if (entry.status && target.status == null) target.status = entry.status
if (entry.time && target.time == null) target.time = entry.time
return
}
tools.push(mergeToolEventData(entry))
}
function collectToolsFromMessage(message, tools) {
if (!message || !tools) return
const toolCalls = message.tool_calls || message.toolCalls || message.tools
if (Array.isArray(toolCalls)) {
toolCalls.forEach(call => {
const fn = call.function || null
const name = call.name || call.tool || call.tool_name || fn?.name
const input = call.input || call.args || call.parameters || call.arguments || fn?.arguments || null
const callId = call.id || call.tool_call_id
upsertTool(tools, {
id: callId,
name: name || 'tool',
input,
output: null,
status: call.status || 'ok',
time: resolveToolTime(callId, message?.timestamp),
})
})
}
const toolResults = message.tool_results || message.toolResults
if (Array.isArray(toolResults)) {
toolResults.forEach(res => {
const resId = res.id || res.tool_call_id
upsertTool(tools, {
id: resId,
name: res.name || res.tool || res.tool_name || 'tool',
input: res.input || res.args || null,
output: res.output || res.result || res.content || null,
status: res.status || 'ok',
time: resolveToolTime(resId, message?.timestamp),
})
})
}
}
/** 渲染工具调用到消息气泡 */
function appendToolsToEl(el, tools) {
if (!el) return
const existing = el.querySelector?.('.msg-tool')
if (!tools?.length) {
if (existing) existing.remove()
return
}
const container = document.createElement('div')
container.className = 'msg-tool'
tools.forEach(tool => {
const details = document.createElement('details')
details.className = 'msg-tool-item'
const summary = document.createElement('summary')
const status = tool.status === 'error' ? t('chat.toolFailed') : t('chat.toolSuccess')
const timeValue = getToolTime(tool) || resolveToolTime(tool.id || tool.tool_call_id, tool.messageTimestamp)
const timeText = timeValue ? formatTime(new Date(timeValue)) : ''
summary.innerHTML = `${escapeHtml(tool.name || 'tool')} · ${status}${timeText ? ' · ' + timeText : ''}`
const body = document.createElement('div')
body.className = 'msg-tool-body'
const inputJson = stripAnsi(safeStringify(tool.input))
const outputJson = stripAnsi(safeStringify(tool.output))
body.innerHTML = `<div class="msg-tool-block"><div class="msg-tool-title">${t('chat.toolParams')}</div><pre>${escapeHtml(inputJson || '-')}</pre></div>`
+ `<div class="msg-tool-block"><div class="msg-tool-title">${t('chat.toolResult')}</div><pre>${escapeHtml(outputJson || '-')}</pre></div>`
details.appendChild(summary)
details.appendChild(body)
container.appendChild(details)
})
if (existing) existing.remove()
el.insertBefore(container, el.firstChild)
}
/** 图片灯箱查看 */
function showLightbox(src) {
const existing = document.querySelector('.chat-lightbox')
if (existing) existing.remove()
const lb = document.createElement('div')
lb.className = 'chat-lightbox'
lb.innerHTML = `<img src="${src}" class="chat-lightbox-img" />`
lb.onclick = (e) => { if (e.target === lb || e.target.tagName !== 'IMG') lb.remove() }
document.body.appendChild(lb)
// ESC 关闭
const onKey = (e) => { if (e.key === 'Escape') { lb.remove(); document.removeEventListener('keydown', onKey) } }
document.addEventListener('keydown', onKey)
}
function appendSystemMessage(text) {
const wrap = document.createElement('div')
wrap.className = 'msg msg-system'
wrap.textContent = text
_messagesEl.insertBefore(wrap, _typingEl)
scrollToBottom()
}
function clearMessages() {
if (!_messagesEl) return
_messagesEl.querySelectorAll('.msg').forEach(m => m.remove())
_autoScrollEnabled = true
_lastScrollTop = 0
}
let _typingElapsedInterval = null
function showTyping(show, hint) {
if (_typingEl) {
_typingEl.style.display = show ? 'flex' : 'none'
// 更新提示文字(如工具调用状态)
const hintEl = _typingEl.querySelector('.typing-hint')
if (hintEl) hintEl.textContent = hint || ''
// 管理已用时间显示
let elapsedEl = _typingEl.querySelector('.typing-elapsed')
if (show && _sendTimestamp) {
if (!elapsedEl) {
elapsedEl = document.createElement('span')
elapsedEl.className = 'typing-elapsed'
_typingEl.appendChild(elapsedEl)
}
const updateElapsed = () => {
if (!_sendTimestamp || !_typingEl) return
const sec = Math.round((Date.now() - _sendTimestamp) / 1000)
if (sec >= 5 && elapsedEl) elapsedEl.textContent = t('chat.elapsedTime', { seconds: sec })
}
updateElapsed()
clearInterval(_typingElapsedInterval)
_typingElapsedInterval = setInterval(updateElapsed, 5000)
} else {
clearInterval(_typingElapsedInterval)
_typingElapsedInterval = null
if (elapsedEl) elapsedEl.textContent = ''
}
}
if (show) scrollToBottom()
}
function showCompactionHint(show) {
let hint = _page?.querySelector('#compaction-hint')
if (show && !hint && _messagesEl) {
hint = document.createElement('div')
hint.id = 'compaction-hint'
hint.className = 'msg msg-system compaction-hint'
hint.innerHTML = `🗜️ ${t('chat.compacting')}`
_messagesEl.insertBefore(hint, _typingEl)
scrollToBottom()
} else if (!show && hint) {
hint.remove()
}
}
function scrollToBottom(force = false) {
if (!_messagesEl) return
if (!force && !_autoScrollEnabled) return
requestAnimationFrame(() => { _messagesEl.scrollTop = _messagesEl.scrollHeight })
}
function isAtBottom() {
if (!_messagesEl) return true
return _messagesEl.scrollHeight - _messagesEl.scrollTop - _messagesEl.clientHeight < 80
}
function updateSendState() {
if (!_sendBtn || !_textarea) return
if (_isStreaming) {
_sendBtn.disabled = false
_sendBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>'
_sendBtn.title = t('chat.cmdStopGen')
} else {
_sendBtn.disabled = !_textarea.value.trim() && !_attachments.length
_sendBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>'
_sendBtn.title = t('chat.send')
}
}
function updateStatusDot(status) {
if (!_statusDot) return
_statusDot.className = 'status-dot'
if (status === 'ready' || status === 'connected') _statusDot.classList.add('online')
else if (status === 'connecting' || status === 'reconnecting') _statusDot.classList.add('connecting')
else _statusDot.classList.add('offline')
}
// ── 托管 Agent 核心逻辑 ──
function toggleHostedPanel() {
if (!_hostedPanelEl) return
const next = _hostedPanelEl.style.display !== 'block'
_hostedPanelEl.style.display = next ? 'block' : 'none'
if (next) renderHostedPanel()
}
function hideHostedPanel() {
if (_hostedPanelEl) _hostedPanelEl.style.display = 'none'
}
function getHostedSessionKey() {
return _sessionKey || localStorage.getItem(STORAGE_SESSION_KEY) || 'agent:main:main'
}
function getHostedBoundSessionKey() {
return _hostedSessionConfig?.boundSessionKey || _hostedBoundSessionKey || ''
}
async function loadHostedDefaults() {
try {
const panel = await api.readPanelConfig()
_hostedDefaults = panel?.hostedAgent?.default || null
} catch { _hostedDefaults = null }
}
function loadHostedSessionConfig() {
let data = {}
try { data = JSON.parse(localStorage.getItem(HOSTED_SESSIONS_KEY) || '{}') } catch { data = {} }
const key = getHostedSessionKey()
const current = data[key] || {}
_hostedSessionConfig = { ...HOSTED_DEFAULTS, ..._hostedDefaults, ...current }
if (_hostedSessionConfig.enabled && !_hostedSessionConfig.boundSessionKey) {
_hostedSessionConfig.boundSessionKey = key
}
_hostedBoundSessionKey = _hostedSessionConfig.boundSessionKey || null
if (!_hostedSessionConfig.state) _hostedSessionConfig.state = { ...HOSTED_RUNTIME_DEFAULT }
if (!_hostedSessionConfig.history) _hostedSessionConfig.history = []
_hostedRuntime = { ...HOSTED_RUNTIME_DEFAULT, ..._hostedSessionConfig.state }
updateHostedBadge()
}
function saveHostedSessionConfig(nextConfig, key = null) {
let data = {}
try { data = JSON.parse(localStorage.getItem(HOSTED_SESSIONS_KEY) || '{}') } catch { data = {} }
data[key || getHostedSessionKey()] = nextConfig
localStorage.setItem(HOSTED_SESSIONS_KEY, JSON.stringify(data))
}
function persistHostedRuntime(persistKey = null) {
if (!_hostedSessionConfig) return
_hostedSessionConfig.state = { ..._hostedRuntime }
const key = persistKey || getHostedBoundSessionKey() || getHostedSessionKey()
saveHostedSessionConfig(_hostedSessionConfig, key)
}
function updateHostedBadge() {
if (!_hostedBadgeEl || !_hostedSessionConfig) return
const status = _hostedRuntime.status || HOSTED_STATUS.IDLE
const enabled = _hostedSessionConfig.enabled
let text = t('chat.hostedNotEnabled'), cls = 'chat-hosted-badge'
if (!enabled) { text = t('chat.hostedNotEnabled'); cls += ' idle' }
else if (status === HOSTED_STATUS.RUNNING) { text = t('chat.hostedRunning'); cls += ' running' }
else if (status === HOSTED_STATUS.WAITING) { text = t('chat.hostedWaiting'); cls += ' waiting' }
else if (status === HOSTED_STATUS.PAUSED) { text = t('chat.hostedPaused'); cls += ' paused' }
else if (status === HOSTED_STATUS.ERROR) { text = t('chat.hostedErrorStatus'); cls += ' error' }
else { text = t('chat.hostedStandby'); cls += ' idle' }
_hostedBadgeEl.className = cls
_hostedBadgeEl.textContent = text
}
let _countdownInterval = null
function renderHostedPanel() {
if (!_hostedPanelEl || !_hostedSessionConfig) return
const isRunning = _hostedSessionConfig.enabled && _hostedRuntime.status !== HOSTED_STATUS.IDLE
if (_hostedPromptEl) { _hostedPromptEl.value = _hostedSessionConfig.prompt || ''; _hostedPromptEl.disabled = isRunning }
if (_hostedMaxStepsEl) {
_hostedMaxStepsEl.value = _hostedSessionConfig.maxSteps || HOSTED_DEFAULTS.maxSteps
_hostedMaxStepsEl.disabled = isRunning
const valEl = _hostedPanelEl.querySelector('#ha-steps-val')
if (valEl) valEl.textContent = _hostedMaxStepsEl.value
}
if (_hostedAutoStopEl) { _hostedAutoStopEl.value = _hostedSessionConfig.autoStopMinutes || 30; _hostedAutoStopEl.disabled = isRunning }
const timerToggle = _hostedPanelEl.querySelector('#hosted-agent-timer-on')
const timerBody = _hostedPanelEl.querySelector('#ha-timer-body')
if (timerToggle) { timerToggle.checked = (_hostedSessionConfig.autoStopMinutes || 0) > 0; timerToggle.disabled = isRunning }
if (timerBody) timerBody.style.display = timerToggle?.checked ? '' : 'none'
if (_hostedSaveBtn) {
_hostedSaveBtn.textContent = isRunning ? `${t('chat.stopHosted')}` : `${t('chat.startHosted')}`
_hostedSaveBtn.className = isRunning ? 'btn btn-ghost' : 'btn btn-primary'
_hostedSaveBtn.style.flex = '1'
}
// 主按钮同时作为停止按钮,无需额外 stop btn
// 状态栏
const statusEl = _hostedPanelEl.querySelector('#hosted-agent-status')
if (statusEl) {
let msg = t('chat.ready')
if (_hostedRuntime.lastError) msg = `${t('chat.errorPrefix')}${_hostedRuntime.lastError}`
else if (isRunning) {
const remaining = Math.max(0, _hostedSessionConfig.maxSteps - _hostedRuntime.stepCount)
msg = `${t('chat.hostedRunning')} · ${t('chat.remaining')} ${remaining}`
}
statusEl.textContent = msg
}
// 倒计时
updateCountdown()
}
function updateCountdown() {
const cdEl = _hostedPanelEl?.querySelector('#ha-countdown')
const fillEl = _hostedPanelEl?.querySelector('#ha-countdown-fill')
const textEl = _hostedPanelEl?.querySelector('#ha-countdown-text')
if (!cdEl || !fillEl || !textEl) return
if (!_hostedAutoStopTimer || !_hostedStartTime || !_hostedSessionConfig?.autoStopMinutes) {
cdEl.style.display = 'none'
clearInterval(_countdownInterval); _countdownInterval = null
return
}
cdEl.style.display = ''
const totalMs = _hostedSessionConfig.autoStopMinutes * 60000
const elapsed = Date.now() - _hostedStartTime
const remaining = Math.max(0, totalMs - elapsed)
const pct = Math.max(0, Math.min(100, (remaining / totalMs) * 100))
fillEl.style.width = pct + '%'
const mins = Math.floor(remaining / 60000)
const secs = Math.floor((remaining % 60000) / 1000)
textEl.textContent = `${t('chat.remaining')} ${mins}:${secs.toString().padStart(2, '0')}`
if (!_countdownInterval) {
_countdownInterval = setInterval(() => updateCountdown(), 1000)
}
if (remaining <= 0) { clearInterval(_countdownInterval); _countdownInterval = null }
}
function toggleHostedRun() {
if (!_hostedSessionConfig) return
if (_hostedSessionConfig.enabled && _hostedRuntime.status !== HOSTED_STATUS.IDLE) {
stopHostedAgent()
} else {
startHostedAgent()
}
}
async function startHostedAgent() {
if (!_hostedSessionConfig) return
const prompt = (_hostedPromptEl?.value || '').trim()
if (!prompt) { toast(t('chat.enterTaskGoal'), 'warning'); return }
const rawSteps = parseInt(_hostedMaxStepsEl?.value || HOSTED_DEFAULTS.maxSteps, 10)
const maxSteps = rawSteps >= 205 ? 999999 : Math.max(1, rawSteps)
const stepDelayMs = Math.max(200, parseInt(_hostedStepDelayEl?.value || HOSTED_DEFAULTS.stepDelayMs, 10))
const retryLimit = Math.max(0, parseInt(_hostedRetryLimitEl?.value || HOSTED_DEFAULTS.retryLimit, 10))
const timerOn = _page?.querySelector('#hosted-agent-timer-on')?.checked
const autoStopMinutes = timerOn ? Math.max(0, parseInt(_hostedAutoStopEl?.value || 0, 10)) : 0
const boundSessionKey = getHostedSessionKey()
_hostedBoundSessionKey = boundSessionKey
_hostedSessionConfig = { ..._hostedSessionConfig, prompt, enabled: true, maxSteps, stepDelayMs, retryLimit, autoStopMinutes, boundSessionKey }
const sysContent = HOSTED_SYSTEM_PROMPT + '\n\nUser goal: ' + prompt
if (!_hostedSessionConfig.history?.length) _hostedSessionConfig.history = [{ role: 'system', content: sysContent }]
else if (_hostedSessionConfig.history[0]?.role === 'system') _hostedSessionConfig.history[0].content = sysContent
else _hostedSessionConfig.history.unshift({ role: 'system', content: sysContent })
_hostedRuntime = { ...HOSTED_RUNTIME_DEFAULT, status: HOSTED_STATUS.RUNNING }
_hostedStartTime = Date.now()
persistHostedRuntime()
renderHostedPanel()
updateHostedBadge()
// 启动定时停止
clearTimeout(_hostedAutoStopTimer)
if (autoStopMinutes > 0) {
_hostedAutoStopTimer = setTimeout(() => {
appendHostedOutput(t('chat.hostedTimerExpired', { min: autoStopMinutes }))
stopHostedAgent()
}, autoStopMinutes * 60000)
}
if (!wsClient.gatewayReady || !_sessionKey) { toast(t('chat.gatewayNotReadySend'), 'warning'); return }
toast(t('chat.hostedStarted'), 'success')
runHostedAgentStep()
}
function stopHostedAgent() {
if (!_hostedSessionConfig) return
const boundSessionKey = getHostedBoundSessionKey() || getHostedSessionKey()
if (_hostedAbort) { _hostedAbort.abort(); _hostedAbort = null }
clearTimeout(_hostedAutoStopTimer); _hostedAutoStopTimer = null
clearInterval(_countdownInterval); _countdownInterval = null
_hostedBusy = false
_hostedSessionConfig.enabled = false
_hostedRuntime.status = HOSTED_STATUS.IDLE
_hostedRuntime.pending = false
_hostedRuntime.stepCount = 0
_hostedRuntime.lastError = ''
_hostedRuntime.errorCount = 0
_hostedStartTime = 0
persistHostedRuntime(boundSessionKey)
_hostedBoundSessionKey = null
renderHostedPanel()
updateHostedBadge()
toast(t('chat.hostedStopped'), 'info')
}
function shouldCaptureHostedTarget(payload) {
if (!_hostedSessionConfig?.enabled) return false
const hostedSessionKey = getHostedBoundSessionKey()
if (payload?.sessionKey && hostedSessionKey && payload.sessionKey !== hostedSessionKey) return false
if (_hostedRuntime.status === HOSTED_STATUS.PAUSED || _hostedRuntime.status === HOSTED_STATUS.ERROR || _hostedRuntime.status === HOSTED_STATUS.IDLE) return false
if (payload?.message?.role && payload.message.role !== 'assistant') return false
const ts = payload?.timestamp || Date.now()
if (ts && ts === _hostedLastTargetTs) return false
_hostedLastTargetTs = ts
return true
}
function appendHostedTarget(text) {
if (!_hostedSessionConfig) return
if (!_hostedSessionConfig.history) _hostedSessionConfig.history = []
_hostedSessionConfig.history.push({ role: 'target', content: text, ts: Date.now() })
persistHostedRuntime()
}
function maybeTriggerHostedRun() {
if (!_hostedSessionConfig?.enabled) return
if (_hostedRuntime.status === HOSTED_STATUS.IDLE || _hostedRuntime.status === HOSTED_STATUS.PAUSED || _hostedRuntime.status === HOSTED_STATUS.ERROR) return
if (_hostedRuntime.pending || _hostedBusy) return
if (!wsClient.gatewayReady) { _hostedRuntime.status = HOSTED_STATUS.PAUSED; persistHostedRuntime(); updateHostedBadge(); renderHostedPanel(); return }
_hostedRuntime.status = HOSTED_STATUS.IDLE
runHostedAgentStep()
}
function compressHostedContext() {
if (!_hostedSessionConfig?.history) return
const history = _hostedSessionConfig.history
if (history.length <= HOSTED_COMPRESS_THRESHOLD) return
const sysEntry = history[0]?.role === 'system' ? history[0] : null
const recent = history.slice(-8)
const older = history.slice(sysEntry ? 1 : 0, -8)
const summary = older.map(h => `[${h.role}] ${(h.content || '').slice(0, 80)}`).join('\n')
const compressed = []
if (sysEntry) compressed.push(sysEntry)
compressed.push({ role: 'user', content: `[Context summary - compressed ${older.length} entries]\n${summary}`, ts: Date.now() })
compressed.push(...recent)
_hostedSessionConfig.history = compressed
persistHostedRuntime()
}
function buildHostedMessages() {
compressHostedContext()
const history = _hostedSessionConfig?.history || []
const mapped = history.slice(-HOSTED_CONTEXT_MAX).map(item => {
if (item.role === 'system') return { role: 'system', content: item.content }
if (item.role === 'assistant') return { role: 'assistant', content: item.content }
return { role: 'user', content: item.content }
})
const hasUserMsg = mapped.some(m => m.role === 'user' || m.role === 'assistant')
if (!hasUserMsg && _hostedSessionConfig?.prompt) {
mapped.push({ role: 'user', content: _hostedSessionConfig.prompt })
}
return mapped
}
function detectStopFromText(text) {
if (!text) return false
return /\b(完成|无需继续|结束|停止|done|stop|final)\b/i.test(text)
}
async function runHostedAgentStep() {
if (_hostedBusy || !_hostedSessionConfig?.enabled) return
const prompt = (_hostedSessionConfig.prompt || '').trim()
const hostedSessionKey = getHostedBoundSessionKey() || getHostedSessionKey()
if (!prompt) return
if (!wsClient.gatewayReady || !hostedSessionKey) {
_hostedRuntime.status = HOSTED_STATUS.PAUSED
_hostedRuntime.lastError = 'Gateway not ready'
persistHostedRuntime(); updateHostedBadge()
appendHostedOutput(t('chat.hostedNeedIntervention'))
return
}
if (_hostedRuntime.errorCount >= _hostedSessionConfig.retryLimit) {
_hostedRuntime.status = HOSTED_STATUS.ERROR
persistHostedRuntime(); updateHostedBadge()
appendHostedOutput(t('chat.hostedErrorThreshold'))
return
}
if (_hostedRuntime.stepCount >= _hostedSessionConfig.maxSteps) {
_hostedRuntime.status = HOSTED_STATUS.IDLE
persistHostedRuntime(); updateHostedBadge()
return
}
_hostedBusy = true
_hostedRuntime.pending = true
_hostedRuntime.status = HOSTED_STATUS.RUNNING
_hostedRuntime.lastRunAt = Date.now()
_hostedRuntime.lastRunId = uuid()
persistHostedRuntime(); updateHostedBadge()
const delay = _hostedSessionConfig.stepDelayMs || HOSTED_DEFAULTS.stepDelayMs
if (delay > 0) await new Promise(r => setTimeout(r, delay))
try {
const messages = buildHostedMessages()
let resultText = ''
await callHostedAI(messages, (chunk) => { resultText += chunk })
_hostedRuntime.stepCount += 1
_hostedRuntime.errorCount = 0
_hostedRuntime.lastError = ''
_hostedSessionConfig.history.push({ role: 'assistant', content: resultText, ts: Date.now() })
persistHostedRuntime()
appendHostedOutput(resultText + ` | step=${_hostedRuntime.stepCount}`)
// 如果 AI 回复中有「执行命令」类内容,通过 Gateway 发送给 Agent
const instruction = resultText.trim()
if (instruction && !detectStopFromText(instruction)) {
_hostedRuntime.status = HOSTED_STATUS.WAITING
_hostedRuntime.pending = false
persistHostedRuntime(); updateHostedBadge()
// 将指令发给 Gateway Agent
try { await wsClient.chatSend(hostedSessionKey, instruction) } catch {}
} else {
_hostedRuntime.status = HOSTED_STATUS.IDLE
_hostedRuntime.pending = false
persistHostedRuntime(); updateHostedBadge()
}
} catch (e) {
_hostedRuntime.errorCount = (_hostedRuntime.errorCount || 0) + 1
_hostedRuntime.lastError = e.message || String(e)
_hostedRuntime.pending = false
if (_hostedRuntime.errorCount >= _hostedSessionConfig.retryLimit) {
_hostedRuntime.status = HOSTED_STATUS.ERROR
persistHostedRuntime(); updateHostedBadge()
appendHostedOutput(t('chat.hostedNeedIntervention', { reason: _hostedRuntime.lastError }))
return
}
persistHostedRuntime(); updateHostedBadge()
setTimeout(() => { _hostedBusy = false; runHostedAgentStep() }, delay)
return
} finally {
_hostedBusy = false
}
}
async function callHostedAI(messages, onChunk) {
let config
try {
const raw = localStorage.getItem('clawpanel-assistant')
const stored = raw ? JSON.parse(raw) : {}
config = { baseUrl: stored.baseUrl || '', apiKey: stored.apiKey || '', model: stored.model || '', temperature: stored.temperature || 0.7, apiType: stored.apiType || 'openai-completions' }
} catch { config = { baseUrl: '', apiKey: '', model: '', temperature: 0.7, apiType: 'openai-completions' } }
if (!config.baseUrl || !config.model) throw new Error(t('chat.hostedModelNotConfigured'))
const apiType = normalizeHostedApiType(config.apiType)
const base = normalizeHostedBaseUrl(config.baseUrl, apiType)
if (_hostedAbort) { _hostedAbort.abort(); _hostedAbort = null }
_hostedAbort = new AbortController()
const signal = _hostedAbort.signal
const timeout = setTimeout(() => { if (_hostedAbort) _hostedAbort.abort() }, 120000)
try {
const headers = { 'Content-Type': 'application/json' }
if (config.apiKey) headers['Authorization'] = `Bearer ${config.apiKey}`
const body = { model: config.model, messages, stream: true, temperature: config.temperature || 0.7 }
const resp = await fetch(base + '/chat/completions', { method: 'POST', headers, body: JSON.stringify(body), signal })
if (!resp.ok) {
const errText = await resp.text().catch(() => '')
let errMsg = `API error ${resp.status}`
try { errMsg = JSON.parse(errText).error?.message || errMsg } catch {}
throw new Error(errMsg)
}
const reader = resp.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed || !trimmed.startsWith('data:')) continue
const data = trimmed.slice(5).trim()
if (data === '[DONE]') return
try { const json = JSON.parse(data); if (json.choices?.[0]?.delta?.content) onChunk(json.choices[0].delta.content) } catch {}
}
}
} finally {
clearTimeout(timeout)
_hostedAbort = null
}
}
function normalizeHostedApiType(raw) {
const type = (raw || '').trim()
if (type === 'anthropic' || type === 'anthropic-messages') return 'anthropic-messages'
if (type === 'google-gemini' || type === 'google-generative-ai') return 'google-generative-ai'
if (type === 'ollama') return 'ollama'
return 'openai-completions'
}
function normalizeHostedBaseUrl(raw, apiType) {
let base = (raw || '').trim()
if (!base) throw new Error(t('chat.hostedModelNotConfigured'))
if (/^\/\//.test(base)) base = `http:${base}`
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(base) && /^(localhost|(?:\d{1,3}\.){3}\d{1,3}|\[[0-9a-f:.]+\]|[^/\s]+:\d+)(?:\/|$)/i.test(base)) {
base = `http://${base}`
}
let url
try {
url = new URL(base)
} catch {
throw new Error(t('chat.hostedModelUrlInvalid'))
}
if (!/^https?:$/.test(url.protocol) || url.hostname === 'tauri.localhost') {
throw new Error(t('chat.hostedModelUrlInvalid'))
}
base = `${url.origin}${url.pathname}`
.replace(/\/+$/, '')
.replace(/\/api\/chat\/?$/, '')
.replace(/\/api\/generate\/?$/, '')
.replace(/\/api\/tags\/?$/, '')
.replace(/\/api\/?$/, '')
.replace(/\/chat\/completions\/?$/, '')
.replace(/\/completions\/?$/, '')
.replace(/\/responses\/?$/, '')
.replace(/\/messages\/?$/, '')
.replace(/\/models\/?$/, '')
const type = normalizeHostedApiType(apiType)
if (type === 'anthropic-messages') {
if (!base.endsWith('/v1')) base += '/v1'
return base
}
if (type === 'google-generative-ai') return base
if (/:(11434)$/i.test(base) && !base.endsWith('/v1')) return `${base}/v1`
return base
}
function appendHostedOutput(text) {
if (!text || !_messagesEl) return
const hostedSessionKey = getHostedBoundSessionKey()
if (hostedSessionKey && _sessionKey && hostedSessionKey !== _sessionKey) return
const wrap = document.createElement('div')
wrap.className = 'msg msg-system msg-hosted'
wrap.textContent = `[${t('chat.hostedAgent')}] ${text}`
_messagesEl.insertBefore(wrap, _typingEl)
scrollToBottom()
}
// ── 页面离开清理 ──
export function cleanup() {
_pageActive = false
if (_unsubEvent) { _unsubEvent(); _unsubEvent = null }
if (_unsubReady) { _unsubReady(); _unsubReady = null }
if (_unsubStatus) { _unsubStatus(); _unsubStatus = null }
clearTimeout(_streamSafetyTimer)
clearInterval(_typingElapsedInterval)
_typingElapsedInterval = null
_cancelResponseWatchdog()
_sendTimestamp = 0
clearTimeout(_postFinalCheck)
_postFinalCheck = null
if (_hostedAbort) { _hostedAbort.abort(); _hostedAbort = null }
_sessionKey = null
_page = null
_messagesEl = null
_textarea = null
_sendBtn = null
_statusDot = null
_typingEl = null
_scrollBtn = null
_sessionListEl = null
_cmdPanelEl = null
_currentAiBubble = null
_currentAiText = ''
_currentAiImages = []
_currentAiVideos = []
_currentAiAudios = []
_currentAiFiles = []
_currentAiTools = []
_currentRunId = null
_isStreaming = false
_isSending = false
_messageQueue = []
_lastHistoryHash = ''
_hostedBtn = null
_hostedPanelEl = null
_hostedBadgeEl = null
_hostedPromptEl = null
_hostedEnableEl = null
_hostedMaxStepsEl = null
_hostedStepDelayEl = null
_hostedRetryLimitEl = null
_hostedSaveBtn = null
_hostedPauseBtn = null
_hostedStopBtn = null
_hostedCloseBtn = null
_hostedGlobalSyncEl = null
_hostedSessionConfig = null
_hostedDefaults = null
_hostedRuntime = { ...HOSTED_RUNTIME_DEFAULT }
_hostedBusy = false
_workspaceBtn = null
_workspacePanelEl = null
_workspaceAgentBadgeEl = null
_workspaceAgentTitleEl = null
_workspacePathEl = null
_workspaceCoreListEl = null
_workspaceTreeEl = null
_workspaceCurrentFileEl = null
_workspaceMetaEl = null
_workspaceEditorEl = null
_workspacePreviewEl = null
_workspaceEmptyEl = null
_workspaceSaveBtn = null
_workspaceReloadBtn = null
_workspacePreviewBtn = null
_workspaceInfo = null
_workspaceCoreFiles = []
_workspaceTreeCache = new Map()
_workspaceExpandedDirs = new Set()
_workspaceCurrentAgentId = 'main'
_workspaceCurrentFile = null
_workspacePreviewMode = false
_workspaceDirty = false
_workspaceLoadedContent = ''
_workspaceLoading = false
_workspaceLoadSeq = 0
_workspaceOpenSeq = 0
}