feat: add workspace file panel to chat

This commit is contained in:
晴天
2026-04-02 00:36:22 +08:00
parent 17dbf2bc81
commit 2013ff98b9
3 changed files with 1006 additions and 6 deletions

View File

@@ -106,6 +106,24 @@ export default {
cmdHelp: _('帮助信息', 'Help', '幫助資訊'),
cmdStatus: _('系统状态', 'System status', '系統狀態'),
cmdContext: _('上下文信息', 'Context info', '上下文資訊'),
workspace: _('工作区', 'Workspace', '工作區'),
openWorkspace: _('打开工作区文件面板', 'Open workspace files panel', '打開工作區檔案面板'),
workspaceFiles: _('工作区文件', 'Workspace Files', '工作區檔案'),
coreFiles: _('核心文件', 'Core Files', '核心檔案'),
workspaceExplorer: _('工作区浏览', 'Workspace Explorer', '工作區瀏覽'),
selectWorkspaceFile: _('选择一个工作区文件开始查看或编辑', 'Select a workspace file to view or edit', '選擇一個工作區檔案開始檢視或編輯'),
reloadWorkspaceFile: _('重新加载', 'Reload file', '重新載入'),
previewWorkspaceFile: _('预览', 'Preview', '預覽'),
editWorkspaceFile: _('编辑', 'Edit', '編輯'),
workspaceEmptyState: _('当前 Agent 工作区已就绪,可从左侧选择文件', 'Workspace ready. Pick a file from the left to start.', '目前 Agent 工作區已就緒,可從左側選擇檔案'),
workspaceLoadFailed: _('加载工作区失败', 'Failed to load workspace', '載入工作區失敗'),
workspaceOpenFailed: _('打开文件失败', 'Failed to open file', '開啟檔案失敗'),
workspaceUnavailable: _('工作区暂不可用', 'Workspace is unavailable', '工作區暫時不可用'),
workspaceNoCoreFiles: _('暂无可展示的核心文件', 'No core files available', '暫無可展示的核心檔案'),
workspaceTreeEmpty: _('当前目录为空', 'This folder is empty', '目前資料夾為空'),
workspaceDraftHint: _('该文件尚不存在,保存后会在当前 Agent 工作区中创建', 'This file does not exist yet. Saving will create it in the current Agent workspace.', '此檔案尚不存在,儲存後會在目前 Agent 工作區中建立'),
confirmDiscardWorkspaceChanges: _('当前文件有未保存修改,确定继续吗?', 'You have unsaved changes in the current file. Continue?', '目前檔案有未儲存修改,確定繼續嗎?'),
workspaceFileCreated: _('工作区文件已创建', 'Workspace file created', '工作區檔案已建立'),
hostedAgent: _('托管 Agent', 'Hosted Agent'),
hostedBadge: _('托管', 'Hosted'),
taskGoal: _('任务目标', 'Task Goal', '任務目標'),

View File

@@ -17,6 +17,7 @@ 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: [
@@ -106,6 +107,14 @@ 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')
@@ -146,6 +155,11 @@ export async function render() {
<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>
@@ -154,6 +168,48 @@ export async function render() {
</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>
@@ -256,10 +312,28 @@ export async function render() {
_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)
@@ -357,6 +431,83 @@ function bindEvents(page) {
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())
@@ -473,6 +624,383 @@ 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')
@@ -675,6 +1203,8 @@ async function connectGateway() {
_sessionKey = saved || sessionKey
updateSessionTitle()
loadHistory()
} else {
syncWorkspaceContext(false)
}
// 始终刷新会话列表(无论是否有 sessionKey
refreshSessionList()
@@ -757,7 +1287,7 @@ function renderSessionList(sessions) {
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) switchSession(item.dataset.key)
if (item) void switchSession(item.dataset.key)
}
_sessionListEl.ondblclick = (e) => {
const labelEl = e.target.closest('.chat-session-label')
@@ -796,8 +1326,15 @@ function parseSessionLabel(key) {
return `${agent} / ${channel}`
}
function switchSession(newKey) {
if (newKey === _sessionKey) return
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 = ''
@@ -806,6 +1343,7 @@ function switchSession(newKey) {
clearMessages()
loadHistory()
refreshSessionList()
return true
}
async function showNewSessionDialog() {
@@ -832,8 +1370,9 @@ async function showNewSessionDialog() {
toast(t('chat.createAgentHint'), 'info')
return
}
switchSession(`agent:${agent}:${name}`)
toast(t('chat.sessionCreated'), 'success')
switchSession(`agent:${agent}:${name}`).then((switched) => {
if (switched) toast(t('chat.sessionCreated'), 'success')
})
}
})
@@ -868,7 +1407,7 @@ async function deleteSession(key) {
try {
await wsClient.sessionsDelete(key)
toast(t('chat.sessionDeleted'), 'success')
if (key === _sessionKey) switchSession(mainKey)
if (key === _sessionKey) void switchSession(mainKey, { forceWorkspace: true })
else refreshSessionList()
} catch (e) {
toast(`${t('common.operationFailed')}: ${e.message}`, 'error')
@@ -894,6 +1433,7 @@ async function resetCurrentSession() {
function updateSessionTitle() {
const el = _page?.querySelector('#chat-title')
if (el) el.textContent = getDisplayLabel(_sessionKey)
syncWorkspaceContext(false)
}
function renameSession(key, labelEl) {
@@ -2530,4 +3070,31 @@ export function cleanup() {
_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
}

View File

@@ -106,6 +106,384 @@
cursor: default;
}
.chat-workspace-trigger {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 34px;
padding: 0 10px;
border: 1px solid transparent;
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease;
}
.chat-workspace-trigger.is-active {
background: color-mix(in srgb, var(--accent) 10%, var(--bg-secondary));
border-color: color-mix(in srgb, var(--accent) 24%, var(--border-primary));
}
.chat-workspace-trigger-label {
white-space: nowrap;
}
.chat-workspace-trigger-agent {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 32px;
padding: 1px 8px;
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 12%, var(--bg-tertiary));
color: var(--accent);
font-size: 11px;
font-weight: 700;
}
.chat-workspace-panel {
position: absolute;
top: 60px;
right: 16px;
bottom: 84px;
width: min(560px, 48vw);
min-width: 360px;
display: flex;
flex-direction: column;
min-height: 0;
background: color-mix(in srgb, var(--bg-card) 92%, var(--bg-primary));
border: 1px solid var(--border-primary);
border-radius: 18px;
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.24);
overflow: hidden;
z-index: 24;
}
.chat-workspace-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--border-primary);
background: color-mix(in srgb, var(--bg-secondary) 88%, var(--bg-card));
}
.chat-workspace-header-copy {
min-width: 0;
flex: 1;
}
.chat-workspace-title-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.chat-workspace-agent-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 32px;
padding: 2px 8px;
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 10%, var(--bg-primary));
color: var(--accent);
font-size: 11px;
font-weight: 700;
}
.chat-workspace-agent-title {
margin-top: 6px;
font-size: 12px;
color: var(--text-primary);
font-weight: 600;
}
.chat-workspace-path {
margin-top: 4px;
font-size: 11px;
color: var(--text-secondary);
line-height: 1.5;
overflow-wrap: anywhere;
}
.chat-workspace-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.chat-workspace-icon-btn {
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border-primary);
border-radius: 10px;
background: var(--bg-primary);
color: var(--text-secondary);
cursor: pointer;
transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease;
}
.chat-workspace-icon-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
border-color: var(--accent);
}
.chat-workspace-body {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(220px, 0.42fr) minmax(0, 0.58fr);
}
.chat-workspace-sidebar-pane {
min-width: 0;
min-height: 0;
overflow-y: auto;
border-right: 1px solid var(--border-primary);
background: color-mix(in srgb, var(--bg-secondary) 76%, var(--bg-card));
}
.chat-workspace-section {
padding: 14px 12px;
}
.chat-workspace-section + .chat-workspace-section {
border-top: 1px solid var(--border-primary);
}
.chat-workspace-section-title {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-secondary);
margin-bottom: 10px;
}
.chat-workspace-core-list,
.chat-workspace-tree {
display: flex;
flex-direction: column;
gap: 6px;
}
.chat-workspace-core-item {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--border-primary);
border-radius: 12px;
background: var(--bg-primary);
color: var(--text-primary);
text-align: left;
cursor: pointer;
transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease;
}
.chat-workspace-core-item:hover,
.chat-workspace-core-item.active {
transform: translateY(-1px);
border-color: color-mix(in srgb, var(--accent) 28%, var(--border-primary));
background: color-mix(in srgb, var(--accent) 8%, var(--bg-primary));
}
.chat-workspace-core-icon {
flex-shrink: 0;
color: var(--accent);
}
.chat-workspace-core-copy {
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.chat-workspace-core-name {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-workspace-core-status {
font-size: 11px;
color: var(--text-secondary);
}
.chat-workspace-core-status.exists {
color: var(--success);
}
.chat-workspace-core-status.missing {
color: var(--warning);
}
.chat-workspace-tree-node {
display: block;
}
.chat-workspace-tree-row {
display: flex;
align-items: center;
gap: 6px;
min-height: 34px;
border-radius: 10px;
transition: background 0.18s ease;
}
.chat-workspace-tree-row:hover,
.chat-workspace-tree-row.active {
background: color-mix(in srgb, var(--accent) 8%, transparent);
}
.chat-workspace-tree-toggle {
width: 18px;
height: 18px;
padding: 0;
border: none;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.chat-workspace-tree-toggle.is-spacer {
display: inline-block;
}
.chat-workspace-tree-link {
flex: 1;
min-width: 0;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0;
border: none;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.chat-workspace-tree-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
}
.chat-workspace-editor-pane {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--bg-primary);
}
.chat-workspace-editor-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px 10px;
border-bottom: 1px solid var(--border-primary);
}
.chat-workspace-current-file {
min-width: 0;
font-size: 13px;
font-weight: 700;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-workspace-editor-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.chat-workspace-editor-meta {
padding: 10px 16px 0;
font-size: 11px;
color: var(--text-secondary);
min-height: 18px;
}
.chat-workspace-editor {
flex: 1;
min-height: 0;
width: 100%;
border: none;
background: transparent;
color: var(--text-primary);
padding: 14px 16px 18px;
resize: none;
outline: none;
font-size: 13px;
line-height: 1.65;
font-family: 'Cascadia Code', 'SF Mono', Consolas, monospace;
}
.chat-workspace-preview {
flex: 1;
min-height: 0;
overflow: auto;
padding: 14px 16px 18px;
font-size: 13px;
line-height: 1.7;
color: var(--text-primary);
}
.chat-workspace-preview pre {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 10px;
padding: 12px 14px;
overflow: auto;
}
.chat-workspace-preview code {
font-family: 'Cascadia Code', 'SF Mono', Consolas, monospace;
}
.chat-workspace-empty,
.chat-workspace-note {
padding: 16px 14px;
border: 1px dashed var(--border-primary);
border-radius: 12px;
color: var(--text-secondary);
font-size: 12px;
line-height: 1.6;
background: color-mix(in srgb, var(--bg-secondary) 70%, transparent);
}
.chat-workspace-empty {
margin: 14px 16px 18px;
}
.chat-workspace-note.is-error {
color: var(--error);
border-color: color-mix(in srgb, var(--error) 35%, var(--border-primary));
}
/* 状态指示点 */
.status-dot {
width: 8px;
@@ -1039,6 +1417,34 @@
padding: var(--space-sm) var(--space-sm);
gap: 6px;
}
.chat-workspace-trigger-label {
display: none;
}
.chat-workspace-panel {
top: 0;
right: 0;
bottom: 0;
left: 0;
width: auto;
min-width: 0;
border-radius: 0;
}
.chat-workspace-body {
grid-template-columns: 1fr;
grid-template-rows: minmax(0, 38%) minmax(0, 62%);
}
.chat-workspace-sidebar-pane {
border-right: none;
border-bottom: 1px solid var(--border-primary);
}
.chat-workspace-editor-toolbar {
flex-direction: column;
align-items: flex-start;
}
.chat-workspace-editor-actions {
width: 100%;
justify-content: flex-start;
}
.chat-toggle-sidebar {
width: 36px;
height: 36px;
@@ -1055,6 +1461,15 @@
}
}
@media (prefers-reduced-motion: reduce) {
.chat-workspace-trigger,
.chat-workspace-core-item,
.chat-workspace-tree-row,
.chat-workspace-icon-btn {
transition: none;
}
}
/* 托管 Agent */
.chat-hosted-btn {
display: flex;