feat: 新增 AI 助手页面 + 图片识别功能 + 更新宣传素材

- 新增 AI 助手页面 (assistant.js/assistant.css/assistant.rs)
- 新增图片识别截图 (13.png) 并加入官网和 README
- 更新宣传视频 (promo-web.mp4) 含 AI 助手+工具调用+图片识别场景
- 更新视频封面 (video-cover.png/video-cover-light.png) 突出 AI 助手
- 更新 AI 助手演示 GIF (ai-assistant-demo.gif) 作为 README 首图
- 更新功能矩阵 GIF (feature-showcase.gif) AI 助手为 star 卡片
- 官网新增图片识别 showcase 区块
- README 新增图片识别特性和截图
- 视频封面改用专业设计版本
- 演示视频时长 badge 更新为 50 秒
This commit is contained in:
晴天
2026-03-06 04:33:43 +08:00
parent 7eb78ea186
commit 860218fa09
26 changed files with 5046 additions and 50 deletions

View File

@@ -10,6 +10,7 @@ const NAV_ITEMS_FULL = [
section: '概览',
items: [
{ route: '/dashboard', label: '仪表盘', icon: 'dashboard' },
{ route: '/assistant', label: 'AI 助手', icon: 'assistant' },
{ route: '/chat', label: '实时聊天', icon: 'chat' },
{ route: '/services', label: '服务管理', icon: 'services' },
{ route: '/logs', label: '日志查看', icon: 'logs' },
@@ -38,8 +39,8 @@ const NAV_ITEMS_FULL = [
{
section: '',
items: [
{ route: '/about', label: '关于', icon: 'about' },
{ route: '/chat-debug', label: '系统诊断', icon: 'debug' },
{ route: '/about', label: '关于', icon: 'about' },
]
}
]
@@ -49,6 +50,7 @@ const NAV_ITEMS_SETUP = [
section: '',
items: [
{ route: '/setup', label: '初始设置', icon: 'setup' },
{ route: '/assistant', label: 'AI 助手', icon: 'assistant' },
]
},
{
@@ -60,8 +62,8 @@ const NAV_ITEMS_SETUP = [
{
section: '',
items: [
{ route: '/about', label: '关于', icon: 'about' },
{ route: '/chat-debug', label: '系统诊断', icon: 'debug' },
{ route: '/about', label: '关于', icon: 'about' },
]
}
]
@@ -78,6 +80,7 @@ const ICONS = {
memory: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 3h6a4 4 0 014 4v14a3 3 0 00-3-3H2z"/><path d="M22 3h-6a4 4 0 00-4 4v14a3 3 0 013-3h7z"/></svg>',
extensions: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>',
about: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>',
assistant: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"/><path d="M18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z"/></svg>',
debug: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/><circle cx="12" cy="12" r="3"/></svg>',
}

View File

@@ -17,18 +17,24 @@ const KEYWORDS = new Set([
function highlightCode(code, lang) {
const escaped = escapeHtml(code)
// Two-phase: mark with control chars first, convert to HTML last
// Prevents keyword regex from matching "class" inside <span class="..."> attributes
const S = '\x02', E = '\x03'
const CLS = ['hl-number','hl-comment','hl-string','hl-type','hl-func','hl-keyword']
return escaped
.replace(/\b(\d+\.?\d*)\b/g, '<span class="hl-number">$1</span>')
.replace(/(\/\/.*$|#.*$)/gm, '<span class="hl-comment">$1</span>')
.replace(/(\/\*[\s\S]*?\*\/)/g, '<span class="hl-comment">$1</span>')
.replace(/\b(\d+\.?\d*)\b/g, `${S}0${E}$1${S}c${E}`)
.replace(/(\/\/.*$|#.*$)/gm, `${S}1${E}$1${S}c${E}`)
.replace(/(\/\*[\s\S]*?\*\/)/g, `${S}1${E}$1${S}c${E}`)
.replace(/(&quot;(?:[^&]|&(?!quot;))*?&quot;|&#x27;(?:[^&]|&(?!#x27;))*?&#x27;|`[^`]*`)/g,
'<span class="hl-string">$1</span>')
`${S}2${E}$1${S}c${E}`)
.replace(/\b([A-Z][a-zA-Z0-9_]*)\b/g, (m, w) =>
KEYWORDS.has(w) ? m : `<span class="hl-type">${w}</span>`)
KEYWORDS.has(w) ? m : `${S}3${E}${w}${S}c${E}`)
.replace(/\b(\w+)(?=\s*\()/g, (m, w) =>
KEYWORDS.has(w) ? m : `<span class="hl-func">${w}</span>`)
KEYWORDS.has(w) ? m : `${S}4${E}${w}${S}c${E}`)
.replace(/\b(\w+)\b/g, (m, w) =>
KEYWORDS.has(w) ? `<span class="hl-keyword">${w}</span>` : m)
KEYWORDS.has(w) ? `${S}5${E}${w}${S}c${E}` : m)
.replace(/\x02([0-5])\x03/g, (_, i) => `<span class="${CLS[+i]}">`)
.replace(/\x02c\x03/g, '</span>')
}
function escapeHtml(str) {

View File

@@ -14,6 +14,7 @@ const NO_MOCK_CMDS = new Set([
'write_memory_file', 'delete_memory_file',
'set_npm_registry', 'reload_gateway', 'restart_gateway',
'auto_pair_device',
'assistant_exec', 'assistant_write_file',
])
// 预加载 Tauri invoke避免每次 API 调用都做动态 import
@@ -236,6 +237,19 @@ function mockInvoke(cmd, args) {
cftunnel_action: () => true,
get_cftunnel_logs: () => '2026-02-26 13:29:01 [INFO] Tunnel started\n2026-02-26 13:30:00 [INFO] Connection healthy',
get_clawapp_status: () => ({ running: true, pid: 7752, port: 3210, url: 'http://localhost:3210' }),
// AI 助手工具
assistant_exec: ({ command }) => `[mock] 执行: ${command}\n这是模拟输出`,
assistant_read_file: ({ path }) => `[mock] 文件内容: ${path}\n# 示例文件\n这是模拟文件内容`,
assistant_write_file: ({ path, content }) => `已写入 ${path} (${content.length} 字节)`,
assistant_list_dir: ({ path }) => '[DIR] src/\n[DIR] docs/\n[FILE] README.md (1024 bytes)\n[FILE] package.json (512 bytes)',
assistant_system_info: () => `OS: ${navigator.platform.includes('Win') ? 'windows' : navigator.platform.includes('Mac') ? 'macos' : 'linux'}\nArch: x86_64\nHome: ${navigator.platform.includes('Win') ? 'C:\\Users\\user' : '/Users/user'}\nHostname: mock-host\nShell: ${navigator.platform.includes('Win') ? 'powershell / cmd' : 'zsh'}\nPath separator: ${navigator.platform.includes('Win') ? '\\\\' : '/'}`,
assistant_list_processes: ({ filter }) => filter ? `Id ProcessName\n-- -----------\n1234 ${filter}\n5678 ${filter}-helper` : 'Id ProcessName\n-- -----------\n1 System\n1234 node\n5678 openclaw',
assistant_check_port: ({ port }) => port === 18789 ? `端口 ${port} 已被占用(正在监听)\n占用进程: node` : `端口 ${port} 未被占用(空闲)`,
// 数据目录 & 图片存储
assistant_ensure_data_dir: () => (navigator.platform.includes('Win') ? 'C:\\Users\\user\\.openclaw\\clawpanel' : '/Users/user/.openclaw/clawpanel'),
assistant_save_image: ({ id }) => `/mock/images/${id}.jpg`,
assistant_load_image: () => 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQI12NgAAIABQABNjN9GQAAAAlwSFlzAAAWJQAAFiUBSVIk8AAAAA0lEQVQI12P4z8BQDwAEgAF/QualzQAAAABJRU5ErkJggg==',
assistant_delete_image: () => null,
}
const fn = mocks[cmd]
return fn ? Promise.resolve(fn(args)) : Promise.reject(`未知命令: ${cmd}`)
@@ -316,4 +330,19 @@ export const api = {
// 设备配对
autoPairDevice: () => invoke('auto_pair_device'),
checkPairingStatus: () => invoke('check_pairing_status'),
// AI 助手工具
assistantExec: (command, cwd) => invoke('assistant_exec', { command, cwd: cwd || null }),
assistantReadFile: (path) => invoke('assistant_read_file', { path }),
assistantWriteFile: (path, content) => invoke('assistant_write_file', { path, content }),
assistantListDir: (path) => invoke('assistant_list_dir', { path }),
assistantSystemInfo: () => invoke('assistant_system_info'),
assistantListProcesses: (filter) => invoke('assistant_list_processes', { filter: filter || null }),
assistantCheckPort: (port) => invoke('assistant_check_port', { port }),
// 数据目录 & 图片存储
ensureDataDir: () => invoke('assistant_ensure_data_dir'),
saveImage: (id, data) => invoke('assistant_save_image', { id, data }),
loadImage: (id) => invoke('assistant_load_image', { id }),
deleteImage: (id) => invoke('assistant_delete_image', { id }),
}

View File

@@ -17,6 +17,7 @@ import './style/pages.css'
import './style/chat.css'
import './style/agents.css'
import './style/debug.css'
import './style/assistant.css'
// 初始化主题
initTheme()
@@ -37,11 +38,19 @@ async function boot() {
registerRoute('/memory', () => import('./pages/memory.js'))
registerRoute('/extensions', () => import('./pages/extensions.js'))
registerRoute('/about', () => import('./pages/about.js'))
registerRoute('/assistant', () => import('./pages/assistant.js'))
registerRoute('/setup', () => import('./pages/setup.js'))
renderSidebar(sidebar)
initRouter(content)
// 隐藏启动加载屏
const splash = document.getElementById('splash')
if (splash) {
splash.classList.add('hide')
setTimeout(() => splash.remove(), 500)
}
// 后台检测状态,检测完再决定是否跳转 setup
detectOpenclawStatus().then(() => {
// 重新渲染侧边栏(检测完成后 isOpenclawReady 状态已更新)

2715
src/pages/assistant.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -703,21 +703,34 @@ function handleChatEvent(payload) {
appendAudiosToEl(_currentAiBubble, _currentAiAudios)
appendFilesToEl(_currentAiBubble, _currentAiFiles)
}
// 添加时间戳 + 耗时
// 添加时间戳 + 耗时 + token 消耗
const wrapper = _currentAiBubble?.parentElement
if (wrapper) {
const time = document.createElement('div')
time.className = 'msg-time'
let timeStr = formatTime(new Date())
const meta = document.createElement('div')
meta.className = 'msg-meta'
let parts = [`<span class="msg-time">${formatTime(new Date())}</span>`]
// 计算响应耗时
let durStr = ''
if (payload.durationMs) {
timeStr += ` · ${(payload.durationMs / 1000).toFixed(1)}s`
durStr = (payload.durationMs / 1000).toFixed(1) + 's'
} else if (_streamStartTime) {
const dur = ((Date.now() - _streamStartTime) / 1000).toFixed(1)
timeStr += ` · ${dur}s`
durStr = ((Date.now() - _streamStartTime) / 1000).toFixed(1) + 's'
}
time.textContent = timeStr
wrapper.appendChild(time)
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>`)
}
}
meta.innerHTML = parts.join('')
wrapper.appendChild(meta)
}
if (_currentAiText || _currentAiImages.length) {
saveMessage({
@@ -1101,12 +1114,12 @@ function appendUserMessage(text, attachments = [], msgTime) {
bubble.appendChild(textNode)
}
const time = document.createElement('div')
time.className = 'msg-time'
time.textContent = formatTime(msgTime || new Date())
const meta = document.createElement('div')
meta.className = 'msg-meta'
meta.innerHTML = `<span class="msg-time">${formatTime(msgTime || new Date())}</span>`
wrap.appendChild(bubble)
wrap.appendChild(time)
wrap.appendChild(meta)
_messagesEl.insertBefore(wrap, _typingEl)
scrollToBottom()
}
@@ -1124,12 +1137,12 @@ function appendAiMessage(text, msgTime, images, videos, audios, files) {
// 图片点击灯箱
bubble.querySelectorAll('img').forEach(img => { if (!img.onclick) img.onclick = () => showLightbox(img.src) })
const time = document.createElement('div')
time.className = 'msg-time'
time.textContent = formatTime(msgTime || new Date())
const meta = document.createElement('div')
meta.className = 'msg-meta'
meta.innerHTML = `<span class="msg-time">${formatTime(msgTime || new Date())}</span>`
wrap.appendChild(bubble)
wrap.appendChild(time)
wrap.appendChild(meta)
_messagesEl.insertBefore(wrap, _typingEl)
scrollToBottom()
}

View File

@@ -149,6 +149,29 @@ function renderSteps(page, { node, cliOk, config }) {
</div>
`
// AI 助手入口
html += `
<div class="config-section" style="text-align:left;margin-top:var(--space-md)">
<div class="config-section-title" style="display:flex;align-items:center;gap:6px">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"/></svg>
晴辰助手
</div>
<p style="color:var(--text-secondary);font-size:var(--font-size-sm);margin-bottom:var(--space-sm);line-height:1.5">
遇到安装问题AI 助手可以帮你诊断和解决。配置好模型后,点击下方按钮${!allOk ? ',当前问题会自动发送给 AI 分析' : ''}
</p>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn btn-secondary btn-sm" id="btn-goto-assistant">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="margin-right:4px"><path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"/></svg>
打开 AI 助手
</button>
${!allOk ? `<button class="btn btn-primary btn-sm" id="btn-ask-ai-help">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="margin-right:4px"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
让 AI 帮我解决
</button>` : ''}
</div>
</div>
`
// 全部就绪 → 进入面板
if (allOk) {
html += `
@@ -159,7 +182,7 @@ function renderSteps(page, { node, cliOk, config }) {
}
stepsEl.innerHTML = html
bindEvents(page, nodeOk)
bindEvents(page, nodeOk, { node, cliOk, config })
}
function renderInstallSection() {
@@ -195,7 +218,37 @@ function renderInstallSection() {
`
}
function bindEvents(page, nodeOk) {
function buildSetupProblemPrompt({ node, cliOk, config }) {
const problems = []
if (!node.installed) problems.push('- Node.js 未安装或未检测到')
else problems.push(`- Node.js 已安装: ${node.version || '版本未知'}`)
if (!cliOk) problems.push('- OpenClaw CLI 未安装')
else problems.push('- OpenClaw CLI 已安装')
if (!config.installed) problems.push('- 配置文件不存在')
else problems.push(`- 配置文件正常: ${config.path || ''}`)
return `我在安装 OpenClaw 时遇到问题,以下是当前检测状态:
${problems.join('\n')}
请帮我分析问题并给出解决步骤。如果需要,请使用工具帮我检查系统环境。`
}
function bindEvents(page, nodeOk, detectState) {
// 打开 AI 助手
page.querySelector('#btn-goto-assistant')?.addEventListener('click', () => {
window.location.hash = '/assistant'
})
// 让 AI 帮我解决(带问题上下文)
page.querySelector('#btn-ask-ai-help')?.addEventListener('click', () => {
if (detectState) {
const prompt = buildSetupProblemPrompt(detectState)
sessionStorage.setItem('assistant-auto-prompt', prompt)
}
window.location.hash = '/assistant'
})
// 进入面板
page.querySelector('#btn-enter')?.addEventListener('click', () => {
window.location.hash = '/dashboard'

1522
src/style/assistant.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -128,6 +128,7 @@
/* 消息通用 */
.msg {
display: flex;
flex-direction: column;
max-width: 85%;
animation: msg-in 0.2s ease-out;
}
@@ -687,15 +688,38 @@
backdrop-filter: blur(4px);
}
/* 消息时间戳 */
.msg-time {
/* 消息时间戳 + 元信息 */
.msg-meta {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--text-tertiary);
margin-top: 4px;
padding: 0 4px;
flex-wrap: wrap;
}
.msg-user .msg-meta { justify-content: flex-end; }
.msg-ai .msg-meta { justify-content: flex-start; }
.msg-meta .msg-time {
font-size: 11px;
}
.msg-meta .msg-tokens {
font-size: 10px;
opacity: 0.8;
}
.msg-meta .msg-duration {
font-size: 10px;
opacity: 0.8;
}
.msg-meta .meta-sep {
color: var(--text-tertiary);
opacity: 0.4;
}
.msg-user .msg-time { text-align: right; }
.msg-ai .msg-time { text-align: left; }
/* 消息内图片 */
.msg-img {

View File

@@ -160,7 +160,7 @@
/* Toast 通知 */
.toast-container {
position: fixed;
top: var(--space-lg);
top: 56px;
right: var(--space-lg);
z-index: 9999;
display: flex;