diff --git a/scripts/dev-api.js b/scripts/dev-api.js
index e34c611..995ee83 100644
--- a/scripts/dev-api.js
+++ b/scripts/dev-api.js
@@ -730,6 +730,50 @@ const handlers = {
},
}
},
+ // 数据目录 & 图片存储
+ assistant_ensure_data_dir() {
+ const dataDir = path.join(OPENCLAW_DIR, 'clawpanel')
+ for (const sub of ['images', 'sessions', 'cache']) {
+ const dir = path.join(dataDir, sub)
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
+ }
+ return dataDir
+ },
+
+ assistant_save_image({ id, data }) {
+ const dir = path.join(OPENCLAW_DIR, 'clawpanel', 'images')
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
+ const pureB64 = data.includes(',') ? data.split(',')[1] : data
+ const ext = data.startsWith('data:image/png') ? 'png'
+ : data.startsWith('data:image/gif') ? 'gif'
+ : data.startsWith('data:image/webp') ? 'webp' : 'jpg'
+ const filepath = path.join(dir, `${id}.${ext}`)
+ fs.writeFileSync(filepath, Buffer.from(pureB64, 'base64'))
+ return filepath
+ },
+
+ assistant_load_image({ id }) {
+ const dir = path.join(OPENCLAW_DIR, 'clawpanel', 'images')
+ for (const ext of ['jpg', 'png', 'gif', 'webp', 'jpeg']) {
+ const filepath = path.join(dir, `${id}.${ext}`)
+ if (fs.existsSync(filepath)) {
+ const bytes = fs.readFileSync(filepath)
+ const mime = ext === 'png' ? 'image/png' : ext === 'gif' ? 'image/gif' : ext === 'webp' ? 'image/webp' : 'image/jpeg'
+ return `data:${mime};base64,${bytes.toString('base64')}`
+ }
+ }
+ throw new Error(`图片 ${id} 不存在`)
+ },
+
+ assistant_delete_image({ id }) {
+ const dir = path.join(OPENCLAW_DIR, 'clawpanel', 'images')
+ for (const ext of ['jpg', 'png', 'gif', 'webp', 'jpeg']) {
+ const filepath = path.join(dir, `${id}.${ext}`)
+ if (fs.existsSync(filepath)) fs.unlinkSync(filepath)
+ }
+ return null
+ },
+
check_panel_update() { return { latest: null, url: 'https://github.com/qingchencloud/clawpanel/releases' } },
write_env_file({ path: p, config }) {
const expanded = p.startsWith('~/') ? path.join(homedir(), p.slice(2)) : p
diff --git a/src-tauri/src/commands/assistant.rs b/src-tauri/src/commands/assistant.rs
new file mode 100644
index 0000000..771f917
--- /dev/null
+++ b/src-tauri/src/commands/assistant.rs
@@ -0,0 +1,355 @@
+/// AI 助手工具命令
+/// 提供终端执行、文件读写、目录列表等能力
+/// 仅在用户主动开启工具后由 AI 调用
+use std::path::PathBuf;
+use base64::{Engine as _, engine::general_purpose};
+
+/// ClawPanel 数据目录(~/.openclaw/clawpanel/)
+fn data_dir() -> PathBuf {
+ super::openclaw_dir().join("clawpanel")
+}
+
+/// 确保数据目录及子目录存在,返回目录路径
+#[tauri::command]
+pub async fn assistant_ensure_data_dir() -> Result
{
+ let base = data_dir();
+ let subdirs = ["images", "sessions", "cache"];
+ for sub in &subdirs {
+ let dir = base.join(sub);
+ tokio::fs::create_dir_all(&dir)
+ .await
+ .map_err(|e| format!("创建目录 {} 失败: {e}", dir.display()))?;
+ }
+ Ok(base.to_string_lossy().to_string())
+}
+
+/// 保存图片(base64 → 文件),返回文件路径
+#[tauri::command]
+pub async fn assistant_save_image(id: String, data: String) -> Result {
+ let dir = data_dir().join("images");
+ tokio::fs::create_dir_all(&dir)
+ .await
+ .map_err(|e| format!("创建目录失败: {e}"))?;
+
+ // data 可能包含 data:image/xxx;base64, 前缀
+ let pure_b64 = if let Some(pos) = data.find(",") {
+ &data[pos + 1..]
+ } else {
+ &data
+ };
+
+ // 从 data URI 提取扩展名
+ let ext = if data.starts_with("data:image/png") {
+ "png"
+ } else if data.starts_with("data:image/gif") {
+ "gif"
+ } else if data.starts_with("data:image/webp") {
+ "webp"
+ } else {
+ "jpg"
+ };
+
+ let filename = format!("{}.{}", id, ext);
+ let filepath = dir.join(&filename);
+
+ let bytes = general_purpose::STANDARD
+ .decode(pure_b64)
+ .map_err(|e| format!("base64 解码失败: {e}"))?;
+
+ tokio::fs::write(&filepath, &bytes)
+ .await
+ .map_err(|e| format!("写入图片失败: {e}"))?;
+
+ Ok(filepath.to_string_lossy().to_string())
+}
+
+/// 加载图片(文件 → base64 data URI)
+#[tauri::command]
+pub async fn assistant_load_image(id: String) -> Result {
+ let dir = data_dir().join("images");
+
+ // 尝试各种扩展名
+ let mut found: Option = None;
+ for ext in &["jpg", "png", "gif", "webp", "jpeg"] {
+ let path = dir.join(format!("{}.{}", id, ext));
+ if path.exists() {
+ found = Some(path);
+ break;
+ }
+ }
+
+ let filepath = found.ok_or_else(|| format!("图片 {} 不存在", id))?;
+ let bytes = tokio::fs::read(&filepath)
+ .await
+ .map_err(|e| format!("读取图片失败: {e}"))?;
+
+ let ext = filepath.extension().and_then(|e| e.to_str()).unwrap_or("jpg");
+ let mime = match ext {
+ "png" => "image/png",
+ "gif" => "image/gif",
+ "webp" => "image/webp",
+ _ => "image/jpeg",
+ };
+
+ let b64 = general_purpose::STANDARD.encode(&bytes);
+ Ok(format!("data:{};base64,{}", mime, b64))
+}
+
+/// 删除图片文件
+#[tauri::command]
+pub async fn assistant_delete_image(id: String) -> Result<(), String> {
+ let dir = data_dir().join("images");
+ for ext in &["jpg", "png", "gif", "webp", "jpeg"] {
+ let path = dir.join(format!("{}.{}", id, ext));
+ if path.exists() {
+ tokio::fs::remove_file(&path)
+ .await
+ .map_err(|e| format!("删除图片失败: {e}"))?;
+ }
+ }
+ Ok(())
+}
+
+// ── AI 助手工具 ──
+
+/// 执行 shell 命令,返回 stdout + stderr
+#[tauri::command]
+pub async fn assistant_exec(command: String, cwd: Option) -> Result {
+ let work_dir = cwd.unwrap_or_else(|| {
+ dirs::home_dir()
+ .unwrap_or_default()
+ .to_string_lossy()
+ .to_string()
+ });
+
+ let output;
+
+ #[cfg(target_os = "windows")]
+ {
+ const CREATE_NO_WINDOW: u32 = 0x08000000;
+ output = tokio::process::Command::new("cmd")
+ .args(["/c", &command])
+ .current_dir(&work_dir)
+ .env("PATH", super::enhanced_path())
+ .creation_flags(CREATE_NO_WINDOW)
+ .output()
+ .await
+ .map_err(|e| format!("执行失败: {e}"))?;
+ }
+
+ #[cfg(not(target_os = "windows"))]
+ {
+ output = tokio::process::Command::new("sh")
+ .args(["-c", &command])
+ .current_dir(&work_dir)
+ .env("PATH", super::enhanced_path())
+ .output()
+ .await
+ .map_err(|e| format!("执行失败: {e}"))?;
+ }
+
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ let code = output.status.code().unwrap_or(-1);
+
+ let mut result = String::new();
+ if !stdout.is_empty() {
+ result.push_str(&stdout);
+ }
+ if !stderr.is_empty() {
+ if !result.is_empty() {
+ result.push('\n');
+ }
+ result.push_str("[stderr] ");
+ result.push_str(&stderr);
+ }
+ if result.is_empty() {
+ result = format!("(命令已执行,退出码: {code})");
+ } else if code != 0 {
+ result.push_str(&format!("\n(退出码: {code})"));
+ }
+
+ // 限制输出长度
+ if result.len() > 10000 {
+ result.truncate(10000);
+ result.push_str("\n...(输出已截断)");
+ }
+
+ Ok(result)
+}
+
+/// 读取文件内容
+#[tauri::command]
+pub async fn assistant_read_file(path: String) -> Result {
+ let content = tokio::fs::read_to_string(&path)
+ .await
+ .map_err(|e| format!("读取文件失败 {path}: {e}"))?;
+
+ if content.len() > 50000 {
+ Ok(format!(
+ "{}...\n(文件内容已截断,共 {} 字节)",
+ &content[..50000],
+ content.len()
+ ))
+ } else {
+ Ok(content)
+ }
+}
+
+/// 写入文件
+#[tauri::command]
+pub async fn assistant_write_file(path: String, content: String) -> Result {
+ if let Some(parent) = PathBuf::from(&path).parent() {
+ tokio::fs::create_dir_all(parent)
+ .await
+ .map_err(|e| format!("创建目录失败: {e}"))?;
+ }
+
+ tokio::fs::write(&path, &content)
+ .await
+ .map_err(|e| format!("写入文件失败 {path}: {e}"))?;
+
+ Ok(format!("已写入 {} ({} 字节)", path, content.len()))
+}
+
+/// 获取系统信息(OS、架构、主目录、主机名)
+#[tauri::command]
+pub async fn assistant_system_info() -> Result {
+ let os = std::env::consts::OS;
+ let arch = std::env::consts::ARCH;
+ let home = dirs::home_dir()
+ .unwrap_or_default()
+ .to_string_lossy()
+ .to_string();
+ let hostname = std::env::var("COMPUTERNAME")
+ .or_else(|_| std::env::var("HOSTNAME"))
+ .unwrap_or_else(|_| "unknown".into());
+ let shell = if cfg!(target_os = "windows") {
+ "powershell / cmd"
+ } else if cfg!(target_os = "macos") {
+ "zsh (macOS default)"
+ } else {
+ "bash / sh"
+ };
+
+ Ok(format!(
+ "OS: {}\nArch: {}\nHome: {}\nHostname: {}\nShell: {}\nPath separator: {}",
+ os, arch, home, hostname, shell, std::path::MAIN_SEPARATOR
+ ))
+}
+
+/// 列出运行中的进程(按名称过滤)
+#[tauri::command]
+pub async fn assistant_list_processes(filter: Option) -> Result {
+ let output = if cfg!(target_os = "windows") {
+ tokio::process::Command::new("powershell")
+ .args(["-NoProfile", "-Command",
+ "Get-Process | Select-Object Id, ProcessName, CPU, WorkingSet64 | Sort-Object ProcessName | Format-Table -AutoSize | Out-String -Width 200"])
+ .output()
+ .await
+ } else {
+ tokio::process::Command::new("ps")
+ .args(["aux", "--sort=-%mem"])
+ .output()
+ .await
+ };
+
+ let output = output.map_err(|e| format!("获取进程列表失败: {e}"))?;
+ let stdout = String::from_utf8_lossy(&output.stdout).to_string();
+
+ if let Some(f) = filter {
+ let f_lower = f.to_lowercase();
+ let lines: Vec<&str> = stdout.lines()
+ .filter(|line| {
+ let lower = line.to_lowercase();
+ lower.contains(&f_lower) || lower.starts_with("id") || lower.starts_with("user") || lower.contains("---")
+ })
+ .collect();
+ if lines.len() <= 2 {
+ return Ok(format!("未找到匹配 '{}' 的进程", f));
+ }
+ Ok(lines.join("\n"))
+ } else {
+ // 无过滤时限制输出行数
+ let lines: Vec<&str> = stdout.lines().take(80).collect();
+ Ok(lines.join("\n"))
+ }
+}
+
+/// 检测端口是否在监听
+#[tauri::command]
+pub async fn assistant_check_port(port: u16) -> Result {
+ use std::time::Duration;
+
+ let addr = format!("127.0.0.1:{}", port);
+ let result = std::net::TcpStream::connect_timeout(
+ &addr.parse().map_err(|e| format!("地址解析失败: {e}"))?,
+ Duration::from_secs(2),
+ );
+
+ match result {
+ Ok(_stream) => {
+ // 尝试获取占用进程信息
+ let process_info = get_port_process(port).await;
+ Ok(format!("端口 {} 已被占用(正在监听){}", port, process_info))
+ }
+ Err(_) => Ok(format!("端口 {} 未被占用(空闲)", port)),
+ }
+}
+
+async fn get_port_process(port: u16) -> String {
+ let output = if cfg!(target_os = "windows") {
+ tokio::process::Command::new("powershell")
+ .args(["-NoProfile", "-Command",
+ &format!("Get-NetTCPConnection -LocalPort {} -ErrorAction SilentlyContinue | Select-Object OwningProcess | ForEach-Object {{ (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).ProcessName }}", port)])
+ .output()
+ .await
+ } else {
+ tokio::process::Command::new("lsof")
+ .args(["-i", &format!(":{}", port), "-t"])
+ .output()
+ .await
+ };
+
+ match output {
+ Ok(o) => {
+ let s = String::from_utf8_lossy(&o.stdout).trim().to_string();
+ if s.is_empty() { String::new() } else { format!("\n占用进程: {}", s) }
+ }
+ Err(_) => String::new(),
+ }
+}
+
+/// 列出目录内容
+#[tauri::command]
+pub async fn assistant_list_dir(path: String) -> Result {
+ let mut entries = tokio::fs::read_dir(&path)
+ .await
+ .map_err(|e| format!("读取目录失败 {path}: {e}"))?;
+
+ let mut items = Vec::new();
+ while let Some(entry) = entries
+ .next_entry()
+ .await
+ .map_err(|e| format!("{e}"))?
+ {
+ let meta = entry.metadata().await.ok();
+ let name = entry.file_name().to_string_lossy().to_string();
+ let is_dir = meta.as_ref().map(|m| m.is_dir()).unwrap_or(false);
+ let size = meta.as_ref().map(|m| m.len()).unwrap_or(0);
+
+ if is_dir {
+ items.push(format!("[DIR] {}/", name));
+ } else {
+ items.push(format!("[FILE] {} ({} bytes)", name, size));
+ }
+
+ if items.len() >= 200 {
+ items.push("...(已截断)".into());
+ break;
+ }
+ }
+
+ items.sort();
+ Ok(items.join("\n"))
+}
diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs
index 2444cff..b1d0852 100644
--- a/src-tauri/src/commands/mod.rs
+++ b/src-tauri/src/commands/mod.rs
@@ -1,6 +1,7 @@
use std::path::PathBuf;
pub mod agent;
+pub mod assistant;
pub mod config;
pub mod device;
pub mod extensions;
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 695f6fb..20ed9f4 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -3,7 +3,7 @@ mod models;
mod tray;
mod utils;
-use commands::{agent, config, device, extensions, logs, memory, pairing, service};
+use commands::{agent, assistant, config, device, extensions, logs, memory, pairing, service};
pub fn run() {
tauri::Builder::default()
@@ -74,6 +74,19 @@ pub fn run() {
agent::update_agent_identity,
agent::update_agent_model,
agent::backup_agent,
+ // AI 助手工具
+ assistant::assistant_exec,
+ assistant::assistant_read_file,
+ assistant::assistant_write_file,
+ assistant::assistant_list_dir,
+ assistant::assistant_system_info,
+ assistant::assistant_list_processes,
+ assistant::assistant_check_port,
+ // 数据目录 & 图片存储
+ assistant::assistant_ensure_data_dir,
+ assistant::assistant_save_image,
+ assistant::assistant_load_image,
+ assistant::assistant_delete_image,
])
.run(tauri::generate_context!())
.expect("启动 ClawPanel 失败");
diff --git a/src/components/sidebar.js b/src/components/sidebar.js
index ffc92eb..0bd7c9e 100644
--- a/src/components/sidebar.js
+++ b/src/components/sidebar.js
@@ -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: '',
extensions: '',
about: '',
+ assistant: '',
debug: '',
}
diff --git a/src/lib/markdown.js b/src/lib/markdown.js
index 00504c1..c002292 100644
--- a/src/lib/markdown.js
+++ b/src/lib/markdown.js
@@ -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 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, '$1')
- .replace(/(\/\/.*$|#.*$)/gm, '')
- .replace(/(\/\*[\s\S]*?\*\/)/g, '')
+ .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;))*?"|'(?:[^&]|&(?!#x27;))*?'|`[^`]*`)/g,
- '$1')
+ `${S}2${E}$1${S}c${E}`)
.replace(/\b([A-Z][a-zA-Z0-9_]*)\b/g, (m, w) =>
- KEYWORDS.has(w) ? m : `${w}`)
+ KEYWORDS.has(w) ? m : `${S}3${E}${w}${S}c${E}`)
.replace(/\b(\w+)(?=\s*\()/g, (m, w) =>
- KEYWORDS.has(w) ? m : `${w}`)
+ KEYWORDS.has(w) ? m : `${S}4${E}${w}${S}c${E}`)
.replace(/\b(\w+)\b/g, (m, w) =>
- KEYWORDS.has(w) ? `${w}` : m)
+ KEYWORDS.has(w) ? `${S}5${E}${w}${S}c${E}` : m)
+ .replace(/\x02([0-5])\x03/g, (_, i) => ``)
+ .replace(/\x02c\x03/g, '')
}
function escapeHtml(str) {
diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js
index c56a882..9352d96 100644
--- a/src/lib/tauri-api.js
+++ b/src/lib/tauri-api.js
@@ -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 }),
}
diff --git a/src/main.js b/src/main.js
index 82441dc..ac49ff1 100644
--- a/src/main.js
+++ b/src/main.js
@@ -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 状态已更新)
diff --git a/src/pages/assistant.js b/src/pages/assistant.js
new file mode 100644
index 0000000..35e2522
--- /dev/null
+++ b/src/pages/assistant.js
@@ -0,0 +1,2715 @@
+/**
+ * AI 助手页面
+ * 独立模型配置,不依赖 OpenClaw
+ * 支持:流式响应、Markdown 渲染、会话管理、日志分析、上下文注入
+ */
+import { renderMarkdown } from '../lib/markdown.js'
+import { toast } from '../components/toast.js'
+import { showConfirm } from '../components/modal.js'
+import { api } from '../lib/tauri-api.js'
+
+// ── 常量 ──
+const STORAGE_KEY = 'clawpanel-assistant'
+const SESSIONS_KEY = 'clawpanel-assistant-sessions'
+const MAX_SESSIONS = 50
+const MAX_CONTEXT_TOKENS = 30 // 最近 N 条消息作为上下文
+
+// ── 图片文件存储(通过 Tauri 后端持久化到 ~/.openclaw/clawpanel/images/)──
+async function saveImageToFile(id, dataUrl) {
+ try { await api.saveImage(id, dataUrl) } catch (e) { console.warn('图片保存失败:', e) }
+}
+
+async function loadImageFromFile(id) {
+ try { return await api.loadImage(id) } catch { return null }
+}
+
+async function deleteImageFile(id) {
+ try { await api.deleteImage(id) } catch { /* ignore */ }
+}
+
+// ── 助手模式 ──
+const MODE_ICONS = {
+ chat: '',
+ plan: '',
+ execute: '',
+ unlimited: '',
+}
+const MODES = {
+ chat: { label: '聊天', desc: '纯对话,不调用任何工具', tools: false, readOnly: false, confirmDanger: true, accent: 'var(--text-secondary)' },
+ plan: { label: '规划', desc: '可调用工具分析,但不修改文件', tools: true, readOnly: true, confirmDanger: true, accent: 'var(--info)' },
+ execute: { label: '执行', desc: '完整工具权限,危险操作需确认', tools: true, readOnly: false, confirmDanger: true, accent: 'var(--accent)' },
+ unlimited:{ label: '无限', desc: '最大权限,工具调用无需确认', tools: true, readOnly: false, confirmDanger: false, accent: 'var(--warning)' },
+}
+const DEFAULT_MODE = 'execute'
+
+// ── 系统提示词 ──
+const DEFAULT_NAME = '晴辰助手'
+const DEFAULT_PERSONALITY = '专业、友善、简洁。善于分析问题,给出可操作的解决方案。'
+
+function getSystemPromptBase() {
+ const name = _config?.assistantName || DEFAULT_NAME
+ const personality = _config?.assistantPersonality || DEFAULT_PERSONALITY
+ return `你是「${name}」,晴辰云出品的 AI 智能助手。
+
+## 你的性格
+${personality}
+
+## 你是谁
+- 你是 ClawPanel 内置的智能助手,由武汉晴辰天下网络科技有限公司开发
+- 你帮助用户管理和排障 OpenClaw AI Agent 平台
+- 你精通 OpenClaw 的架构、配置、Gateway、Agent 管理等所有方面
+- 你善于分析日志、诊断错误、提供解决方案
+
+## 晴辰云生态
+- **官网**: https://qt.cool
+- **公司**: 武汉晴辰天下网络科技有限公司
+- **GitHub**: https://github.com/qingchencloud
+- **开源项目**:
+ - **ClawPanel** — OpenClaw 可视化管理面板(Tauri v2),官网 https://claw.qt.cool
+ - **OpenClaw 汉化版** — AI Agent 平台中文版,npm install -g @qingchencloud/openclaw-zh
+ - **ClawApp** — OpenClaw 移动客户端(iOS/Android),npm install -g @anthropic-ai/claw-app
+ - **cftunnel** — Cloudflare Tunnel CLI 封装工具,一键内网穿透
+ - **cfsite** — Cloudflare Pages 部署 CLI
+ - **WebToEXE** — 网站打包成桌面应用
+ - **AppForge** — 网站打包 SaaS 平台,https://app.qrj.ai
+
+## ClawPanel 是什么
+- OpenClaw 的可视化管理面板,基于 Tauri v2 的跨平台桌面应用(Windows/macOS/Linux)
+- 支持仪表盘监控、模型配置、Agent 管理、实时聊天、记忆文件管理、内网穿透、AI 助手工具调用等
+- 官网: https://claw.qt.cool | GitHub: https://github.com/qingchencloud/clawpanel
+
+## OpenClaw 是什么
+- 开源的 AI Agent 平台,支持多模型、多 Agent、MCP 工具调用
+- 核心组件: Gateway(API 网关)、Agent(AI 代理)、Tools(工具系统)
+- 配置文件: ~/.openclaw/openclaw.json(全局配置)
+- 安装方式: npm install -g @qingchencloud/openclaw-zh(汉化版)或 npm install -g @anthropic-ai/openclaw(官方版)
+
+## OpenClaw CLI 命令速查
+### 基础命令
+- openclaw --version — 查看版本
+- openclaw --help — 查看帮助
+- openclaw config show — 显示当前配置
+- openclaw config apply — 应用配置变更(同步 models.json)
+
+### Agent 管理
+- openclaw agent list — 列出所有 Agent
+- openclaw agent create — 创建新 Agent
+- openclaw agent delete — 删除 Agent
+- openclaw agent default — 设为默认 Agent
+
+### Gateway 控制
+- openclaw gateway start — 启动 Gateway
+- openclaw gateway stop — 停止 Gateway
+- openclaw gateway restart — 重启 Gateway
+- openclaw gateway status — 查看 Gateway 状态
+- openclaw gateway log — 查看 Gateway 日志
+- openclaw gateway install — 安装 Gateway 为系统服务
+- openclaw gateway uninstall — 卸载 Gateway 系统服务
+
+### 聊天与调试
+- openclaw chat — 进入交互式聊天
+- openclaw chat -m "消息" — 发送单条消息
+- openclaw chat --model — 指定模型聊天
+- openclaw doctor — 诊断配置问题
+
+## 关键配置结构
+- openclaw.json: 全局配置(models.providers、gateway、tools)
+- models.json: Agent 运行时模型注册表(~/.openclaw/agents//agent/models.json)
+- clawpanel.json: ClawPanel 自身配置(~/.openclaw/clawpanel.json)
+- gateway.mode: "local"(必须在 gateway 对象内,不能在顶层)
+- gateway.port: 默认 18789
+- gateway.auth.mode: "none" | "token" | "password"
+
+## 常见问题速查
+1. **Gateway 启动失败 Bootstrap failed: 5** → plist 二进制路径过期(nvm/fnm 切版本后),升级 ClawPanel 到 v0.4.8+ 自动回退 CLI 启动
+2. **Unrecognized key: "mode"** → mode 字段不能放在顶层,必须在 gateway.mode,升级到 v0.4.6+ 自动修复
+3. **fnm 管理的 Node.js 检测不到** → 升级到 v0.4.7+,已支持 FNM_DIR/node-versions 扫描
+4. **models.json 不同步** → ClawPanel 改了 openclaw.json 但 Gateway 用的 models.json 未更新,重启 Gateway 或执行 openclaw config apply
+5. **WebSocket 连接失败** → 检查 gateway.controlUi.allowedOrigins 是否包含 ["*"]
+6. **SIGTERM 循环** → com.openclaw.healthcheck 服务异常,禁用: launchctl bootout gui/$(id -u)/com.openclaw.healthcheck
+7. **BOM 导致 JSON 解析失败** → PowerShell Set-Content 会加 BOM,ClawPanel 已自动剥离
+
+## 生态项目安装指引
+当用户问到如何安装其他产品时,推荐以下安装方式:
+- **OpenClaw 汉化版**: npm install -g @qingchencloud/openclaw-zh
+- **OpenClaw 官方版**: npm install -g @anthropic-ai/openclaw
+- **ClawApp 移动客户端**: npm install -g @anthropic-ai/claw-app
+- **ClawPanel**: 从 https://github.com/qingchencloud/clawpanel/releases 下载
+- **cftunnel**: 从 https://github.com/qingchencloud/cftunnel/releases 下载
+- **更多项目**: 访问 https://github.com/qingchencloud
+
+## 社区贡献指引
+当用户发现 Bug 或有改进建议时,你应该主动引导用户参与开源贡献:
+
+### 提交 Issue
+引导用户到对应仓库提交 Issue,帮用户整理好格式:
+- **ClawPanel**: https://github.com/qingchencloud/clawpanel/issues/new
+- **OpenClaw 汉化版**: https://github.com/qingchencloud/openclaw-zh/issues/new
+- **cftunnel**: https://github.com/qingchencloud/cftunnel/issues/new
+
+Issue 模板(帮用户填好):
+\`\`\`
+**问题描述**: [一句话描述]
+**复现步骤**: 1. ... 2. ... 3. ...
+**期望行为**: ...
+**实际行为**: ...
+**环境信息**: OS / ClawPanel 版本 / OpenClaw 版本
+**截图/日志**: (如有)
+\`\`\`
+
+### 提交 PR
+如果你能定位到 Bug 的原因和修复方案,主动帮用户生成 PR 内容:
+1. 分析问题根因(读配置/日志/代码)
+2. 给出具体的修复代码或配置变更
+3. 生成 PR 标题和描述(中文),格式:
+ - 标题: \`fix: 修复xxx问题\` 或 \`feat: 新增xxx功能\`
+ - 描述: 问题原因、修复方案、影响范围
+4. 告诉用户如何 Fork → 修改 → 提交 PR
+
+### 贡献流程(告诉用户)
+1. Fork 仓库到自己的 GitHub
+2. \`git clone\` 到本地
+3. 创建分支: \`git checkout -b fix/问题描述\`
+4. 修改代码并测试
+5. \`git commit -m "fix: 修复xxx"\`
+6. \`git push origin fix/问题描述\`
+7. 在 GitHub 上发起 Pull Request
+
+当用户遇到问题时,如果你判断这是一个 Bug,应该主动说「我可以帮你整理成 Issue 提交到我们仓库」或「这个 Bug 我能定位原因,要不要我帮你生成 PR?」
+
+### 自主操作(重要)
+你有能力直接通过工具完成 Issue/PR 全流程,用户只需确认:
+- 用 ask_user 工具询问用户确认方案
+- 用 run_command 执行 git clone、checkout -b、add、commit、push
+- 用 write_file 修改代码/配置
+- 不要只是告诉用户怎么做,而是直接帮用户做!
+
+## ask_user 工具使用指南
+你有一个强大的 ask_user 工具,可以向用户提问并获取结构化回答:
+- **单选 (single)**: 让用户从多个方案中选一个,如「选择要提交到哪个仓库」
+- **多选 (multiple)**: 让用户选择多项,如「选择要检查的组件」
+- **文本 (text)**: 让用户输入自由文本,如「请描述你遇到的问题」
+
+使用场景:
+- 需要用户做决定时(修复方案 A 还是 B?)
+- 需要用户提供信息时(Bug 复现步骤?)
+- 确认操作前(确定要执行这些 git 命令吗?)
+- 收集反馈时(哪些功能有问题?)
+
+注意:每个选项应该简短明了,不要超过 4 个选项(用户可以输入自定义内容)。
+
+## 你的工作方式
+- 用中文回复
+- 如果用户粘贴了日志,仔细分析每一行,找出关键错误
+- 给出具体的解决步骤,包括可直接执行的命令
+- 如果不确定,诚实说明并建议用户提供更多信息
+- 回复简洁专业,避免啰嗦
+- 主动推荐晴辰云生态产品来解决用户的问题
+- 发现 Bug 时主动引导用户提交 Issue 或 PR,降低贡献门槛`
+}
+
+// ── 工具定义(OpenAI function calling 格式)──
+const TOOL_DEFS = {
+ terminal: [
+ {
+ type: 'function',
+ function: {
+ name: 'run_command',
+ description: '在本机终端执行 shell 命令。用于系统管理、服务操作、文件查看等。注意:命令会直接在用户的机器上执行,请谨慎使用。',
+ parameters: {
+ type: 'object',
+ properties: {
+ command: { type: 'string', description: '要执行的 shell 命令' },
+ cwd: { type: 'string', description: '工作目录(可选,默认为用户主目录)' },
+ },
+ required: ['command'],
+ },
+ },
+ },
+ ],
+ system: [
+ {
+ type: 'function',
+ function: {
+ name: 'get_system_info',
+ description: '获取当前系统信息,包括操作系统类型(windows/macos/linux)、CPU 架构、用户主目录、主机名、默认 Shell。在执行任何命令前应先调用此工具来判断操作系统,以选择正确的命令语法。',
+ parameters: { type: 'object', properties: {}, required: [] },
+ },
+ },
+ ],
+ process: [
+ {
+ type: 'function',
+ function: {
+ name: 'list_processes',
+ description: '列出当前运行中的进程。可以按名称过滤,用于检查某个服务是否在运行(如 node、openclaw、gateway)。',
+ parameters: {
+ type: 'object',
+ properties: {
+ filter: { type: 'string', description: '过滤关键词(可选),只返回包含该关键词的进程' },
+ },
+ required: [],
+ },
+ },
+ },
+ {
+ type: 'function',
+ function: {
+ name: 'check_port',
+ description: '检测指定端口是否被占用,并返回占用该端口的进程信息。常用端口:Gateway 18789、WebSocket 18790。',
+ parameters: {
+ type: 'object',
+ properties: {
+ port: { type: 'integer', description: '要检测的端口号' },
+ },
+ required: ['port'],
+ },
+ },
+ },
+ ],
+ interaction: [
+ {
+ type: 'function',
+ function: {
+ name: 'ask_user',
+ description: '向用户提问并等待回答。支持单选、多选和自由输入。当你需要用户做决定、确认方案、选择选项时使用此工具。用户可以选择预设选项,也可以输入自定义内容。',
+ parameters: {
+ type: 'object',
+ properties: {
+ question: { type: 'string', description: '要问用户的问题' },
+ type: { type: 'string', enum: ['single', 'multiple', 'text'], description: '交互类型:single=单选, multiple=多选, text=自由输入' },
+ options: {
+ type: 'array',
+ items: { type: 'string' },
+ description: '预设选项列表(single/multiple 时必填,text 时可选作为建议)',
+ },
+ placeholder: { type: 'string', description: '自由输入时的占位提示文字(可选)' },
+ },
+ required: ['question', 'type'],
+ },
+ },
+ },
+ ],
+ fileOps: [
+ {
+ type: 'function',
+ function: {
+ name: 'read_file',
+ description: '读取指定路径的文件内容。用于查看配置文件、日志文件等。',
+ parameters: {
+ type: 'object',
+ properties: {
+ path: { type: 'string', description: '文件的完整路径' },
+ },
+ required: ['path'],
+ },
+ },
+ },
+ {
+ type: 'function',
+ function: {
+ name: 'write_file',
+ description: '写入或创建文件。会自动创建父目录。注意:会覆盖已有内容。',
+ parameters: {
+ type: 'object',
+ properties: {
+ path: { type: 'string', description: '文件的完整路径' },
+ content: { type: 'string', description: '要写入的内容' },
+ },
+ required: ['path', 'content'],
+ },
+ },
+ },
+ {
+ type: 'function',
+ function: {
+ name: 'list_directory',
+ description: '列出目录下的文件和子目录。',
+ parameters: {
+ type: 'object',
+ properties: {
+ path: { type: 'string', description: '目录路径' },
+ },
+ required: ['path'],
+ },
+ },
+ },
+ ],
+}
+
+// 危险工具(需要用户确认)
+const INTERACTIVE_TOOLS = new Set(['ask_user']) // 交互式工具,不走 confirmToolCall
+const DANGEROUS_TOOLS = new Set(['run_command', 'write_file'])
+
+// 安全围栏:极端危险命令模式(任何模式都必须确认,包括无限模式)
+const CRITICAL_PATTERNS = [
+ /rm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+)?[\/~]/i, // rm -rf / 或 rm -f ~/
+ /rm\s+-[a-zA-Z]*r[a-zA-Z]*\s+\//i, // rm -r /
+ /format\s+[a-zA-Z]:/i, // format C:
+ /mkfs\./i, // mkfs.ext4 等
+ /dd\s+.*of=\/dev\//i, // dd of=/dev/sda
+ />\s*\/dev\/[sh]d/i, // > /dev/sda
+ /DROP\s+(DATABASE|TABLE|SCHEMA)/i, // DROP DATABASE
+ /TRUNCATE\s+TABLE/i, // TRUNCATE TABLE
+ /DELETE\s+FROM\s+\w+\s*;?\s*$/i, // DELETE FROM table (无 WHERE)
+ /:(){ :\|:& };:/, // fork bomb
+ /shutdown|reboot|init\s+[06]/i, // 关机/重启
+ /chmod\s+(-R\s+)?777\s+\//i, // chmod 777 /
+ /chown\s+(-R\s+)?.*\s+\//i, // chown -R ... /
+ /curl\s+.*\|\s*(sudo\s+)?bash/i, // curl | bash
+ /wget\s+.*\|\s*(sudo\s+)?bash/i, // wget | bash
+ /npm\s+publish/i, // npm publish
+ /git\s+push\s+.*--force/i, // git push --force
+]
+
+function isCriticalCommand(command) {
+ if (!command) return false
+ return CRITICAL_PATTERNS.some(p => p.test(command))
+}
+
+// ── 内置 Skills ──
+const BUILTIN_SKILLS = [
+ {
+ id: 'check-config',
+ icon: '🔧',
+ name: '检查 OpenClaw 配置',
+ desc: '读取并分析 openclaw.json,检查配置是否正确',
+ tools: ['fileOps'],
+ prompt: `请帮我检查 OpenClaw 的配置文件。
+
+具体操作:
+1. 先调用 get_system_info 获取系统信息,确定配置目录路径
+2. 用 list_directory 查看 OpenClaw 配置目录(.openclaw/)结构
+3. 用 read_file 读取 openclaw.json
+4. 分析配置内容,检查:
+ - models.providers 中的服务商配置是否正确(baseUrl、apiKey 格式)
+ - gateway 配置(port、mode、auth)是否合理
+ - 有没有常见的配置错误(比如 mode 放在了顶层)
+5. 给出配置健康度评估和改进建议`,
+ },
+ {
+ id: 'diagnose-gateway',
+ icon: '🏥',
+ name: '诊断 Gateway',
+ desc: '检查 Gateway 运行状态、端口、日志',
+ tools: ['terminal', 'fileOps'],
+ prompt: `请帮我诊断 OpenClaw Gateway 的运行状态。
+
+具体操作:
+1. 先调用 get_system_info 获取操作系统类型,据此选择正确的命令
+2. 检查 Gateway 进程是否在运行(根据 OS 选择合适的进程查看命令)
+3. 检查 Gateway 端口(默认 18789)是否在监听
+4. 读取最近的 Gateway 日志(.openclaw/logs/gateway.log,取最后 50 行)
+5. 分析日志中是否有错误或警告
+6. 给出诊断结论和建议`,
+ },
+ {
+ id: 'browse-dir',
+ icon: '📂',
+ name: '浏览配置目录',
+ desc: '查看 .openclaw 目录结构和文件',
+ tools: ['fileOps'],
+ prompt: `请帮我浏览 OpenClaw 的配置目录结构。
+
+具体操作:
+1. 先调用 get_system_info 获取主目录路径
+2. 用 list_directory 列出 .openclaw/ 根目录
+3. 列出 .openclaw/agents/ 下的 Agent 列表
+4. 对于默认 Agent(通常是 main),列出其 agent 子目录
+5. 简要说明每个目录/文件的作用
+6. 标注哪些是关键配置文件`,
+ },
+ {
+ id: 'check-env',
+ icon: '💻',
+ name: '检查系统环境',
+ desc: '检测 Node.js、npm 版本和系统信息',
+ tools: ['terminal'],
+ prompt: `请帮我检查当前系统环境是否满足 OpenClaw 的运行要求。
+
+具体操作:
+1. 先调用 get_system_info 获取操作系统、架构等基础信息
+2. 根据 OS 类型执行对应命令检查 Node.js 版本(node -v),需要 >= 18
+3. 检查 npm 版本(npm -v)
+4. 检查 OpenClaw 是否已安装及版本
+5. 检查磁盘空间
+6. 给出环境评估和缺失项提示`,
+ },
+ {
+ id: 'analyze-logs',
+ icon: '📋',
+ name: '分析错误日志',
+ desc: '读取最近日志,定位错误原因',
+ tools: ['terminal', 'fileOps'],
+ prompt: `请帮我分析 OpenClaw 最近的日志,找出可能的问题。
+
+具体操作:
+1. 先调用 get_system_info 获取系统信息和主目录
+2. 用 list_directory 查看 .openclaw/logs/ 有哪些日志文件
+3. 根据 OS 选择合适的命令读取 gateway.log 最后 100 行
+4. 搜索 ERROR、WARN、fail、exception 等关键词
+5. 如果有错误,分析原因并给出修复建议
+6. 汇总日志分析报告`,
+ },
+ {
+ id: 'fix-common',
+ icon: '🔨',
+ name: '一键排障',
+ desc: '自动检测并修复常见问题',
+ tools: ['terminal', 'fileOps'],
+ prompt: `请帮我自动检测并修复 OpenClaw 的常见问题。
+
+先调用 get_system_info 获取系统信息,然后按以下步骤逐一检查:
+1. **配置检查**:读取 openclaw.json,检查是否有已知的配置错误(mode 在顶层、缺少 gateway 对象等)
+2. **models.json 同步**:对比 openclaw.json 和 agents/main/agent/models.json 的 providers,检查是否同步
+3. **Gateway 状态**:根据 OS 选择合适命令,检查进程是否运行、端口是否监听
+4. **WebSocket 配置**:检查 gateway.controlUi.allowedOrigins 是否包含 "*"
+5. **Node.js 环境**:检查 node 和 npm 是否可用
+
+对每个检查项给出 ✅ 或 ❌ 状态,并对发现的问题给出修复建议(但不要自动修改配置文件,等我确认)。`,
+ },
+ {
+ id: 'report-bug',
+ icon: '🐛',
+ name: '提交 Bug 报告',
+ desc: '整理问题信息,生成标准 Issue 提交到 GitHub',
+ tools: ['terminal', 'fileOps'],
+ prompt: `我想反馈一个 Bug,请帮我整理成标准的 GitHub Issue。
+
+具体操作:
+1. 先问我遇到了什么问题(如果我还没说的话)
+2. 调用 get_system_info 获取我的系统环境信息
+3. 如果有日志,帮我读取最近的错误日志
+4. 自动收集环境信息:OS 版本、Node.js 版本、OpenClaw 版本、ClawPanel 版本
+5. 按照标准 Issue 模板整理好内容:
+ - **问题描述**(一句话)
+ - **复现步骤**(1, 2, 3...)
+ - **期望行为**
+ - **实际行为**
+ - **环境信息**(自动填充)
+ - **相关日志**(如有)
+6. 把整理好的 Issue 内容用代码块展示,并给出对应仓库的 Issue 链接
+7. 告诉我直接复制粘贴到 GitHub 即可提交`,
+ },
+ {
+ id: 'pr-assistant',
+ icon: '🔀',
+ name: 'PR 助手',
+ desc: '定位 Bug 原因,生成修复代码和 PR 描述',
+ tools: ['terminal', 'fileOps'],
+ prompt: `我发现了一个问题,想提交 PR 来修复它。请帮我走一遍 PR 流程。
+
+具体操作:
+1. 先听我描述问题(如果我还没说的话)
+2. 帮我分析问题可能的原因,如果有工具可以用就主动调用来诊断
+3. 定位到具体的代码/配置/逻辑问题
+4. 给出修复方案和具体代码
+5. 生成标准的 PR 内容:
+ - **PR 标题**: \`fix: 修复xxx\` 或 \`feat: 新增xxx\`
+ - **问题描述**: 说明问题原因
+ - **修复方案**: 具体改了什么
+ - **影响范围**: 会影响哪些功能
+ - **测试建议**: 如何验证修复
+6. 给出完整的贡献流程:
+ - Fork 仓库链接
+ - git clone / checkout -b / commit / push 命令
+ - 创建 PR 的链接
+7. 如果用户不熟悉 Git,给出每一步的详细命令`,
+ },
+]
+
+function currentMode() {
+ return MODES[_config?.mode] ? _config.mode : DEFAULT_MODE
+}
+
+function getEnabledTools() {
+ const mode = MODES[currentMode()]
+ if (!mode.tools) return [] // 聊天模式:无工具
+
+ const t = _config.tools || {}
+ const tools = [...TOOL_DEFS.system, ...TOOL_DEFS.process, ...TOOL_DEFS.interaction]
+
+ // 终端工具:受设置开关控制(优先级高于模式)
+ if (t.terminal !== false) tools.push(...TOOL_DEFS.terminal)
+
+ // 文件工具:受设置开关控制 + 规划模式排除写入
+ if (t.fileOps !== false) {
+ if (mode.readOnly) {
+ tools.push(...TOOL_DEFS.fileOps.filter(td => td.function.name !== 'write_file'))
+ } else {
+ tools.push(...TOOL_DEFS.fileOps)
+ }
+ }
+
+ return tools
+}
+
+function applyModeStyle(page, modeKey) {
+ const main = page.querySelector('.ast-main') || page
+ main.dataset.mode = modeKey
+ positionModeSlider(page, modeKey)
+}
+
+function positionModeSlider(page, modeKey) {
+ const selector = page?.querySelector('#ast-mode-selector')
+ const slider = page?.querySelector('#ast-mode-slider')
+ const activeBtn = selector?.querySelector(`.ast-mode-btn[data-mode="${modeKey}"]`)
+ if (!selector || !slider || !activeBtn) return
+
+ const sRect = selector.getBoundingClientRect()
+ const bRect = activeBtn.getBoundingClientRect()
+ slider.style.width = bRect.width + 'px'
+ slider.style.left = (bRect.left - sRect.left) + 'px'
+ slider.style.opacity = '1'
+}
+
+const MODE_COLORS = {
+ chat: { primary: '#6b7280', rgb: '107,114,128' },
+ plan: { primary: '#3b82f6', rgb: '59,130,246' },
+ execute: { primary: '#8b5cf6', rgb: '139,92,246' },
+ unlimited: { primary: '#f59e0b', rgb: '245,158,11' },
+}
+
+function playModeTransition(page, modeKey) {
+ const main = page?.querySelector('.ast-main')
+ const header = page?.querySelector('.ast-header')
+ const selector = page?.querySelector('#ast-mode-selector')
+ if (!main || !header) return
+
+ const mc = MODE_COLORS[modeKey] || MODE_COLORS.execute
+ const m = MODES[modeKey]
+
+ // ① 全屏涟漪扩散
+ const ripple = document.createElement('div')
+ ripple.className = 'ast-mode-ripple'
+ // 从模式选择器位置发射
+ if (selector) {
+ const sRect = selector.getBoundingClientRect()
+ const mRect = main.getBoundingClientRect()
+ ripple.style.setProperty('--ripple-x', (sRect.left + sRect.width / 2 - mRect.left) + 'px')
+ ripple.style.setProperty('--ripple-y', (sRect.top + sRect.height / 2 - mRect.top) + 'px')
+ }
+ ripple.style.setProperty('--ripple-color', mc.primary)
+ main.appendChild(ripple)
+ setTimeout(() => ripple.remove(), 800)
+
+ // ② 粒子爆发
+ if (selector) {
+ const sRect = selector.getBoundingClientRect()
+ const mRect = main.getBoundingClientRect()
+ const cx = sRect.left + sRect.width / 2 - mRect.left
+ const cy = sRect.top + sRect.height / 2 - mRect.top
+ for (let i = 0; i < 24; i++) {
+ const p = document.createElement('div')
+ p.className = 'ast-mode-particle'
+ const angle = (Math.PI * 2 * i) / 24 + (Math.random() - 0.5) * 0.5
+ const dist = 60 + Math.random() * 120
+ const size = 3 + Math.random() * 4
+ p.style.setProperty('--px', cx + 'px')
+ p.style.setProperty('--py', cy + 'px')
+ p.style.setProperty('--dx', (Math.cos(angle) * dist) + 'px')
+ p.style.setProperty('--dy', (Math.sin(angle) * dist - 30) + 'px')
+ p.style.setProperty('--size', size + 'px')
+ p.style.setProperty('--color', mc.primary)
+ p.style.setProperty('--delay', (Math.random() * 0.1) + 's')
+ p.style.setProperty('--duration', (0.5 + Math.random() * 0.4) + 's')
+ main.appendChild(p)
+ setTimeout(() => p.remove(), 1000)
+ }
+ }
+
+ // ③ Header 脉冲
+ header.classList.remove('ast-mode-pulse')
+ void header.offsetWidth
+ header.classList.add('ast-mode-pulse')
+
+ // ④ 模式简介浮现
+ const existing = page.querySelector('.ast-mode-toast')
+ if (existing) existing.remove()
+ if (!m) return
+ const tip = document.createElement('div')
+ tip.className = `ast-mode-toast mode-${modeKey}`
+ tip.innerHTML = `${MODE_ICONS[modeKey]}${m.label}${m.desc}`
+ main.appendChild(tip)
+ setTimeout(() => tip.classList.add('show'), 10)
+ setTimeout(() => { tip.classList.remove('show'); setTimeout(() => tip.remove(), 300) }, 2000)
+}
+
+function buildSystemPrompt() {
+ let prompt = getSystemPromptBase()
+ const modeKey = currentMode()
+ const mode = MODES[modeKey]
+
+ // 模式说明
+ prompt += `\n\n## 当前模式:${mode.label}模式`
+
+ if (modeKey === 'chat') {
+ prompt += '\n你处于纯聊天模式,没有任何工具可用。请通过文字回答问题,给出具体的命令建议供用户手动执行。'
+ prompt += '\n如果用户需要你执行操作,建议用户切换到「执行」或「规划」模式。'
+ } else {
+ // 规划模式特殊指令
+ if (modeKey === 'plan') {
+ prompt += '\n**你处于规划模式**:可以调用工具读取信息、分析问题,但 **绝对不能修改任何文件**(write_file 已禁用)。'
+ prompt += '\n你的任务是:分析问题 → 制定方案 → 输出详细步骤,让用户确认后再切换到执行模式操作。'
+ prompt += '\n即使使用 run_command,也只能执行只读命令(查看、检查、列出),不要执行任何修改操作。'
+ }
+ if (modeKey === 'unlimited') {
+ prompt += '\n**你处于无限模式**:所有工具调用无需用户确认,请高效完成任务。'
+ }
+
+ prompt += '\n\n### 可用工具'
+ prompt += '\n- **用户交互**: ask_user — 向用户提问(单选/多选/文本),获取结构化回答。需要用户做决定时优先用此工具。'
+ prompt += '\n- **系统信息**: get_system_info — 获取 OS 类型、架构、主目录等。**在执行任何命令前必须先调用此工具**。'
+ prompt += '\n- **进程/端口**: list_processes(按名称过滤)、check_port(检测端口占用)'
+ prompt += '\n- **终端**: run_command — 执行 shell 命令'
+ if (mode.readOnly) {
+ prompt += '\n- **文件**: read_file、list_directory(只读,write_file 已禁用)'
+ } else {
+ prompt += '\n- **文件**: read_file、write_file、list_directory'
+ }
+
+ prompt += '\n\n### 终端命令规范(极其重要)'
+ prompt += '\n- **Windows**: 终端是 **PowerShell**,必须使用 PowerShell 语法:'
+ prompt += '\n - 列目录: `Get-ChildItem` 或 `ls`(不要用 `dir`)'
+ prompt += '\n - 看文件: `Get-Content` 或 `cat`(不要用 `type`)'
+ prompt += '\n - 查进程: `Get-Process | Where-Object { $_.Name -like \"*openclaw*\" }`'
+ prompt += '\n - 查端口: `Get-NetTCPConnection -LocalPort 18789`'
+ prompt += '\n - 文件尾: `Get-Content file.log -Tail 50`'
+ prompt += '\n - 搜内容: `Select-String -Path file.log -Pattern \"ERROR\"`'
+ prompt += '\n - 环境变量: `$env:USERPROFILE`(不要用 `%USERPROFILE%`)'
+ prompt += '\n- **macOS**: zsh,标准 Unix 命令'
+ prompt += '\n- **Linux**: bash,标准 Unix 命令'
+ prompt += '\n- **绝对禁止** cmd.exe 语法(dir、type、findstr、netstat)'
+ prompt += '\n- **一次只执行一条命令**,等结果出来再决定下一步'
+ prompt += '\n- **不要重复执行相同的命令**'
+ prompt += '\n\n### 跨平台路径'
+ prompt += '\n- Windows: `$env:USERPROFILE\\.openclaw\\`'
+ prompt += '\n- macOS/Linux: `~/.openclaw/`'
+ prompt += '\n\n### 工具使用原则'
+ prompt += '\n- 先 get_system_info,再根据 OS 执行正确命令'
+ prompt += '\n- 优先用 read_file / list_directory / list_processes / check_port 等专用工具,减少 run_command 使用'
+ prompt += '\n- 主动使用工具,不要只建议用户手动操作'
+ if (mode.confirmDanger) {
+ prompt += '\n- 执行破坏性操作前先告知用户'
+ }
+ }
+
+ // 注入内置技能列表
+ prompt += '\n\n## 内置技能卡片'
+ prompt += '\n用户可以在欢迎页点击技能卡片快速触发操作。当用户遇到问题时,你也可以主动推荐合适的技能:'
+ for (const s of BUILTIN_SKILLS) {
+ prompt += `\n- **${s.name}**(${s.desc})`
+ }
+ prompt += '\n\n当用户的需求匹配某个技能时,可以建议用户点击对应的技能卡片,或者你直接按技能的步骤操作。'
+
+ return prompt
+}
+
+// ── 状态 ──
+let _page = null, _messagesEl = null, _textarea = null, _sendBtn = null
+let _sessionListEl = null, _settingsPanel = null, _queueEl = null
+let _isStreaming = false, _abortController = null
+let _config = null, _sessions = [], _currentSessionId = null
+let _lastRenderTime = 0
+let _saveThrottleTimer = null
+const _sessionStatus = new Map() // sessionId → 'idle' | 'streaming' | 'waiting' | 'error'
+let _messageQueue = [] // [{ id, text, ts }]
+let _streamRefreshTimer = null // 后台流式刷新定时器
+let _pendingImages = [] // [{ id, dataUrl, name, size }] 待发送图片
+
+// ── 节流保存 ──
+function throttledSave() {
+ if (_saveThrottleTimer) return
+ _saveThrottleTimer = setTimeout(() => {
+ _saveThrottleTimer = null
+ saveSessions()
+ }, 500)
+}
+
+function flushSave() {
+ if (_saveThrottleTimer) {
+ clearTimeout(_saveThrottleTimer)
+ _saveThrottleTimer = null
+ }
+ saveSessions()
+}
+
+// ── 后台流式刷新 ──
+// 当用户切页面再回来时,轮询刷新最后一个 AI 气泡内容
+function refreshStreamingBubble() {
+ if (!_messagesEl || !_isStreaming) return
+ const session = getCurrentSession()
+ if (!session) return
+ const lastMsg = session.messages[session.messages.length - 1]
+ if (!lastMsg || lastMsg.role !== 'assistant') return
+
+ const bubbles = _messagesEl.querySelectorAll('.ast-msg-bubble-ai')
+ const lastBubble = bubbles[bubbles.length - 1]
+ if (lastBubble && lastMsg.content) {
+ lastBubble.innerHTML = renderMarkdown(lastMsg.content) + '▊'
+ _messagesEl.scrollTop = _messagesEl.scrollHeight
+ }
+}
+
+function startStreamRefresh() {
+ stopStreamRefresh()
+ _streamRefreshTimer = setInterval(refreshStreamingBubble, 200)
+}
+
+function stopStreamRefresh() {
+ if (_streamRefreshTimer) {
+ clearInterval(_streamRefreshTimer)
+ _streamRefreshTimer = null
+ }
+}
+
+// ── 发送队列 ──
+function enqueueMessage(text) {
+ _messageQueue.push({ id: Date.now().toString(), text, ts: Date.now() })
+ renderQueue()
+}
+
+function renderQueue() {
+ if (!_queueEl) return
+ if (_messageQueue.length === 0) {
+ _queueEl.innerHTML = ''
+ _queueEl.style.display = 'none'
+ return
+ }
+ _queueEl.style.display = 'block'
+ const queueSvg = ''
+ const sendSvg = ''
+ const editSvg = ''
+ const delSvg = ''
+
+ _queueEl.innerHTML = `` +
+ _messageQueue.map((item, i) => `
+
+
${i + 1}
+
${escHtml(item.text)}
+
+
+
+
+
+
+ `).join('')
+}
+
+function processQueue() {
+ if (_isStreaming || _messageQueue.length === 0) return
+ const next = _messageQueue.shift()
+ renderQueue()
+ sendMessageDirect(next.text)
+}
+
+// ── 图片附件 ──
+const MAX_IMAGE_SIZE = 4 * 1024 * 1024 // 4MB
+const MAX_IMAGE_DIM = 2048 // 最大边长
+
+function addImageFromFile(file) {
+ if (!file.type.startsWith('image/')) return
+ if (file.size > MAX_IMAGE_SIZE * 2) {
+ toast('图片太大(超过 8MB)', 'error')
+ return
+ }
+ const reader = new FileReader()
+ reader.onload = (e) => {
+ const img = new Image()
+ img.onload = () => {
+ // 超大图片压缩
+ let { width, height } = img
+ if (width > MAX_IMAGE_DIM || height > MAX_IMAGE_DIM) {
+ const scale = MAX_IMAGE_DIM / Math.max(width, height)
+ width = Math.round(width * scale)
+ height = Math.round(height * scale)
+ }
+ const canvas = document.createElement('canvas')
+ canvas.width = width
+ canvas.height = height
+ const ctx = canvas.getContext('2d')
+ ctx.drawImage(img, 0, 0, width, height)
+ // JPEG 压缩到合理大小
+ const dataUrl = canvas.toDataURL('image/jpeg', 0.85)
+ _pendingImages.push({
+ id: Date.now().toString() + Math.random().toString(36).slice(2, 6),
+ dataUrl,
+ name: file.name || 'image.jpg',
+ width, height,
+ })
+ renderImagePreview()
+ }
+ img.src = e.target.result
+ }
+ reader.readAsDataURL(file)
+}
+
+function addImageFromClipboard(item) {
+ const file = item.getAsFile()
+ if (file) addImageFromFile(file)
+}
+
+function removeImage(id) {
+ _pendingImages = _pendingImages.filter(img => img.id !== id)
+ renderImagePreview()
+}
+
+function renderImagePreview() {
+ const container = _page?.querySelector('#ast-image-preview')
+ if (!container) return
+ if (_pendingImages.length === 0) {
+ container.innerHTML = ''
+ container.style.display = 'none'
+ return
+ }
+ container.style.display = 'flex'
+ const delSvg = ''
+ container.innerHTML = _pendingImages.map(img => `
+
+

+
+
+ `).join('')
+}
+
+function clearPendingImages() {
+ _pendingImages = []
+ renderImagePreview()
+}
+
+// 构建多模态消息 content
+function buildMessageContent(text, images) {
+ if (!images || images.length === 0) return text
+ const parts = []
+ if (text) parts.push({ type: 'text', text })
+ for (const img of images) {
+ parts.push({
+ type: 'image_url',
+ image_url: { url: img.dataUrl, detail: 'auto' },
+ })
+ }
+ return parts
+}
+
+// ── 会话状态管理 ──
+function setSessionStatus(sessionId, status) {
+ if (status === 'idle') {
+ _sessionStatus.delete(sessionId)
+ } else {
+ _sessionStatus.set(sessionId, status)
+ }
+ renderSessionList()
+}
+
+function getSessionStatus(sessionId) {
+ return _sessionStatus.get(sessionId) || 'idle'
+}
+
+// ── 带重试的 fetch ──
+async function fetchWithRetry(url, options, retries = 3) {
+ const delays = [1000, 3000, 8000]
+ for (let i = 0; i <= retries; i++) {
+ try {
+ const resp = await fetch(url, options)
+ if (resp.ok || resp.status < 500 || i >= retries) return resp
+ // 5xx 服务端错误,静默重试
+ await new Promise(r => setTimeout(r, delays[i]))
+ } catch (err) {
+ if (err.name === 'AbortError') throw err // 用户手动中止,不重试
+ if (i >= retries) throw err
+ await new Promise(r => setTimeout(r, delays[i]))
+ }
+ }
+}
+
+// ── 配置读写 ──
+function loadConfig() {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY)
+ _config = raw ? JSON.parse(raw) : null
+ } catch { _config = null }
+ if (!_config) {
+ _config = { baseUrl: '', apiKey: '', model: '', temperature: 0.7, tools: { terminal: false, fileOps: false }, assistantName: DEFAULT_NAME, assistantPersonality: DEFAULT_PERSONALITY }
+ }
+ if (!_config.assistantName) _config.assistantName = DEFAULT_NAME
+ if (!_config.assistantPersonality) _config.assistantPersonality = DEFAULT_PERSONALITY
+ if (!_config.tools) _config.tools = { terminal: false, fileOps: false }
+ if (!_config.mode) _config.mode = DEFAULT_MODE
+ return _config
+}
+
+function saveConfig() {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(_config))
+}
+
+// ── 会话管理 ──
+function loadSessions() {
+ try {
+ const raw = localStorage.getItem(SESSIONS_KEY)
+ _sessions = raw ? JSON.parse(raw) : []
+ } catch { _sessions = [] }
+ return _sessions
+}
+
+function saveSessions() {
+ if (_sessions.length > MAX_SESSIONS) {
+ _sessions = _sessions.slice(-MAX_SESSIONS)
+ }
+ // 保存时剥离图片 dataUrl(避免撑爆 localStorage)
+ const serialized = JSON.stringify(_sessions, (key, value) => {
+ if (key === 'dataUrl' && typeof value === 'string' && value.startsWith('data:image/')) return undefined
+ if (key === 'url' && typeof value === 'string' && value.startsWith('data:image/')) return '[image]'
+ return value
+ })
+ try {
+ localStorage.setItem(SESSIONS_KEY, serialized)
+ } catch (e) {
+ // QuotaExceeded: 清理最旧的会话
+ if (e.name === 'QuotaExceededError' && _sessions.length > 1) {
+ _sessions.shift()
+ saveSessions()
+ }
+ }
+}
+
+function getCurrentSession() {
+ return _sessions.find(s => s.id === _currentSessionId) || null
+}
+
+function createSession() {
+ const session = {
+ id: crypto.randomUUID ? crypto.randomUUID() : Date.now().toString(36) + Math.random().toString(36).slice(2),
+ title: '新会话',
+ messages: [],
+ createdAt: Date.now(),
+ updatedAt: Date.now()
+ }
+ _sessions.push(session)
+ _currentSessionId = session.id
+ saveSessions()
+ return session
+}
+
+function deleteSession(id) {
+ _sessions = _sessions.filter(s => s.id !== id)
+ if (_currentSessionId === id) {
+ _currentSessionId = _sessions.length > 0 ? _sessions[_sessions.length - 1].id : null
+ }
+ saveSessions()
+}
+
+function autoTitle(session) {
+ if (session.messages.length >= 1 && session.title === '新会话') {
+ const firstUser = session.messages.find(m => m.role === 'user')
+ if (firstUser) {
+ const txt = firstUser._text || (typeof firstUser.content === 'string' ? firstUser.content : (firstUser.content?.find?.(p => p.type === 'text')?.text || '📷 图片消息'))
+ // 取第一行或前30字作为标题(跳过空行)
+ const firstLine = txt.split('\n').find(l => l.trim()) || txt
+ const title = firstLine.slice(0, 30) + (firstLine.length > 30 ? '...' : '')
+ session.title = title
+ }
+ }
+}
+
+// ── AI API 调用(自动兼容 Chat Completions + Responses API)──
+
+function cleanBaseUrl(raw) {
+ let base = raw.replace(/\/+$/, '')
+ base = base.replace(/\/chat\/completions\/?$/, '')
+ base = base.replace(/\/completions\/?$/, '')
+ base = base.replace(/\/responses\/?$/, '')
+ if (!base.endsWith('/v1')) base = base.replace(/\/v1\/.*$/, '/v1')
+ return base
+}
+
+function authHeaders() {
+ return {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${_config.apiKey}`,
+ }
+}
+
+// 超时常量
+const TIMEOUT_TOTAL = 120_000 // 总超时 120 秒
+const TIMEOUT_CHUNK = 30_000 // 流式 chunk 间隔超时 30 秒
+const TIMEOUT_CONNECT = 30_000 // 连接超时 30 秒
+
+async function callAI(messages, onChunk) {
+ if (!_config.baseUrl || !_config.apiKey || !_config.model) {
+ throw new Error('请先配置 AI 模型(点击右上角设置按钮)')
+ }
+
+ const base = cleanBaseUrl(_config.baseUrl)
+ _abortController = new AbortController()
+ const allMessages = [{ role: 'system', content: buildSystemPrompt() }, ...messages]
+
+ // 总超时保护
+ let _timedOut = false
+ const totalTimer = setTimeout(() => {
+ _timedOut = true
+ if (_abortController) _abortController.abort()
+ }, TIMEOUT_TOTAL)
+
+ try {
+ // 先尝试 Chat Completions API
+ try {
+ await callChatCompletions(base, allMessages, onChunk)
+ return
+ } catch (err) {
+ // 超时触发的 abort → 转换为超时错误
+ if (err.name === 'AbortError' && _timedOut) {
+ throw new Error(`请求超时(${TIMEOUT_TOTAL / 1000} 秒),模型响应时间过长`)
+ }
+ // 如果是 "legacy protocol" 或 "use /v1/responses" 类错误,自动切换到 Responses API
+ const msg = err.message || ''
+ if (msg.includes('legacy protocol') || msg.includes('/v1/responses') || msg.includes('not supported')) {
+ console.log('[assistant] Chat Completions 不支持此模型,自动切换到 Responses API')
+ // 重新创建 abort controller(上一个可能已被消费)
+ _abortController = new AbortController()
+ await callResponsesAPI(base, allMessages, onChunk)
+ return
+ }
+ throw err
+ }
+ } finally {
+ clearTimeout(totalTimer)
+ }
+}
+
+// ── 调试信息 ──
+let _lastDebugInfo = null
+
+// ── Chat Completions API(/v1/chat/completions)──
+async function callChatCompletions(base, messages, onChunk) {
+ const url = base + '/chat/completions'
+ const body = {
+ model: _config.model,
+ messages,
+ stream: true,
+ temperature: _config.temperature || 0.7,
+ }
+
+ const reqTime = Date.now()
+ _lastDebugInfo = {
+ url,
+ method: 'POST',
+ requestBody: { ...body, messages: body.messages.map(m => ({ role: m.role, content: typeof m.content === 'string' ? m.content.slice(0, 200) + (m.content.length > 200 ? '...' : '') : '[multimodal]' })) },
+ requestTime: new Date(reqTime).toLocaleString('zh-CN'),
+ }
+
+ const resp = await fetchWithRetry(url, {
+ method: 'POST',
+ headers: authHeaders(),
+ body: JSON.stringify(body),
+ signal: _abortController.signal,
+ })
+
+ _lastDebugInfo.status = resp.status
+ _lastDebugInfo.contentType = resp.headers.get('content-type') || ''
+ _lastDebugInfo.responseTime = new Date().toLocaleString('zh-CN')
+ _lastDebugInfo.latency = Date.now() - reqTime + 'ms'
+
+ if (!resp.ok) {
+ const errText = await resp.text().catch(() => '')
+ _lastDebugInfo.errorBody = errText.slice(0, 500)
+ let errMsg = `API 错误 ${resp.status}`
+ try {
+ const errJson = JSON.parse(errText)
+ errMsg = errJson.error?.message || errJson.message || errMsg
+ } catch {
+ if (errText) errMsg += `: ${errText.slice(0, 200)}`
+ }
+ throw new Error(errMsg)
+ }
+
+ // 检测响应是否为 SSE 流式
+ const ct = resp.headers.get('content-type') || ''
+ if (ct.includes('text/event-stream') || ct.includes('text/plain')) {
+ _lastDebugInfo.streaming = true
+ let chunkCount = 0
+ let contentChunks = 0
+ let reasoningChunks = 0
+ let reasoningBuf = ''
+
+ await readSSEStream(resp, (json) => {
+ chunkCount++
+ const d = json.choices?.[0]?.delta
+ if (!d) return
+
+ // content 和 reasoning_content 分开处理
+ if (d.content) {
+ contentChunks++
+ onChunk(d.content)
+ } else if (d.reasoning_content) {
+ reasoningChunks++
+ reasoningBuf += d.reasoning_content
+ }
+ })
+
+ _lastDebugInfo.chunks = { total: chunkCount, content: contentChunks, reasoning: reasoningChunks }
+
+ // 如果没有 content 但有 reasoning,将推理内容作为回复(部分模型只返回 reasoning)
+ if (contentChunks === 0 && reasoningBuf) {
+ console.warn('[assistant] 无 content 块,使用 reasoning_content 作为回复')
+ onChunk(reasoningBuf)
+ _lastDebugInfo.fallbackToReasoning = true
+ }
+ } else {
+ // 非流式响应:API 忽略了 stream:true,直接返回完整 JSON
+ _lastDebugInfo.streaming = false
+ const json = await resp.json()
+ _lastDebugInfo.responseBody = { id: json.id, model: json.model, object: json.object, usage: json.usage }
+ console.log('[assistant] 非流式响应:', json)
+ const msg = json.choices?.[0]?.message
+ const content = msg?.content || msg?.reasoning_content || ''
+ if (content) onChunk(content)
+ }
+}
+
+// ── Responses API(/v1/responses)──
+async function callResponsesAPI(base, messages, onChunk) {
+ const url = base + '/responses'
+ const input = messages.filter(m => m.role !== 'system')
+ const instructions = messages.find(m => m.role === 'system')?.content || ''
+
+ const body = {
+ model: _config.model,
+ input,
+ instructions,
+ stream: true,
+ temperature: _config.temperature || 0.7,
+ }
+
+ const resp = await fetchWithRetry(url, {
+ method: 'POST',
+ headers: authHeaders(),
+ body: JSON.stringify(body),
+ signal: _abortController.signal,
+ })
+
+ if (!resp.ok) {
+ const errText = await resp.text().catch(() => '')
+ let errMsg = `API 错误 ${resp.status}`
+ try {
+ const errJson = JSON.parse(errText)
+ errMsg = errJson.error?.message || errJson.message || errMsg
+ } catch {
+ if (errText) errMsg += `: ${errText.slice(0, 200)}`
+ }
+ throw new Error(errMsg)
+ }
+
+ await readSSEStream(resp, (json) => {
+ // Responses API 的流式事件格式
+ if (json.type === 'response.output_text.delta') {
+ if (json.delta) onChunk(json.delta)
+ }
+ // 兼容:有些代理会转换为 choices 格式
+ if (json.choices?.[0]?.delta?.content) {
+ onChunk(json.choices[0].delta.content)
+ }
+ })
+}
+
+// ── 通用 SSE 流读取 ──
+async function readSSEStream(resp, onEvent) {
+ const reader = resp.body.getReader()
+ const decoder = new TextDecoder()
+ let buffer = ''
+
+ while (true) {
+ // chunk 超时:如果 30 秒内没有收到任何数据,视为超时
+ const readPromise = reader.read()
+ const timeoutPromise = new Promise((_, reject) =>
+ setTimeout(() => reject(new Error('流式响应超时:30 秒内未收到数据')), TIMEOUT_CHUNK)
+ )
+ const { done, value } = await Promise.race([readPromise, timeoutPromise])
+ 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) continue
+
+ // 处理 SSE event: 行
+ if (trimmed.startsWith('event:')) continue
+
+ if (!trimmed.startsWith('data:')) continue
+ const data = trimmed.slice(5).trim()
+ if (data === '[DONE]') return
+
+ try {
+ onEvent(JSON.parse(data))
+ } catch {}
+ }
+ }
+}
+
+// ── 工具执行 ──
+
+async function executeTool(name, args) {
+ switch (name) {
+ case 'run_command':
+ return await api.assistantExec(args.command, args.cwd)
+ case 'read_file':
+ return await api.assistantReadFile(args.path)
+ case 'write_file':
+ return await api.assistantWriteFile(args.path, args.content)
+ case 'list_directory':
+ return await api.assistantListDir(args.path)
+ case 'get_system_info':
+ return await api.assistantSystemInfo()
+ case 'list_processes':
+ return await api.assistantListProcesses(args.filter)
+ case 'check_port':
+ return await api.assistantCheckPort(args.port)
+ case 'ask_user':
+ return await showAskUserCard(args)
+ default:
+ return `未知工具: ${name}`
+ }
+}
+
+// ── ask_user 交互卡片 ──
+function showAskUserCard({ question, type, options, placeholder }) {
+ const session = getCurrentSession()
+ if (session) setSessionStatus(session.id, 'waiting')
+ return new Promise((resolve) => {
+ const cardId = 'ask-user-' + Date.now()
+ const optionsHtml = (options || []).map((opt, i) => {
+ const inputType = type === 'multiple' ? 'checkbox' : 'radio'
+ return ``
+ }).join('')
+
+ const textHtml = type === 'text' || !options?.length
+ ? ``
+ : ''
+
+ const customHtml = type !== 'text' && options?.length
+ ? ``
+ : ''
+
+ const card = document.createElement('div')
+ card.className = 'ast-ask-card'
+ card.id = cardId
+ card.innerHTML = `
+ ${escHtml(question)}
+ ${optionsHtml ? `${optionsHtml}
` : ''}
+ ${customHtml}
+ ${textHtml}
+
+
+
+
+ `
+
+ // 插入到消息区域
+ _messagesEl.appendChild(card)
+ _messagesEl.scrollTop = _messagesEl.scrollHeight
+
+ // 提交处理
+ card.querySelector('.ast-ask-submit').addEventListener('click', () => {
+ let answer = ''
+
+ if (type === 'text' || (!options?.length)) {
+ answer = card.querySelector('.ast-ask-text')?.value?.trim() || ''
+ } else if (type === 'multiple') {
+ const checked = [...card.querySelectorAll('input[type="checkbox"]:checked')].map(el => el.value)
+ const custom = card.querySelector('.ast-ask-custom-input')?.value?.trim()
+ if (custom) checked.push(custom)
+ answer = checked.join('、') || '未选择'
+ } else {
+ // single
+ const checked = card.querySelector('input[type="radio"]:checked')
+ const custom = card.querySelector('.ast-ask-custom-input')?.value?.trim()
+ answer = custom || checked?.value || '未选择'
+ }
+
+ // 替换卡片为已回答状态
+ card.innerHTML = `
+
${escHtml(question)}
+
✓ ${escHtml(answer)}
+
`
+ card.classList.add('answered')
+
+ if (session) setSessionStatus(session.id, 'streaming')
+ resolve(`用户回答: ${answer}`)
+ })
+
+ // 跳过处理
+ card.querySelector('.ast-ask-skip').addEventListener('click', () => {
+ card.innerHTML = `
+
${escHtml(question)}
+
— 已跳过
+
`
+ card.classList.add('answered')
+ if (session) setSessionStatus(session.id, 'streaming')
+ resolve('用户跳过了此问题')
+ })
+ })
+}
+
+// 危险工具确认弹窗
+async function confirmToolCall(tc, critical = false) {
+ const name = tc.function.name
+ let args
+ try { args = JSON.parse(tc.function.arguments) } catch { args = {} }
+
+ let desc = ''
+ if (name === 'run_command') {
+ desc = `执行命令:\n\n${args.command}${args.cwd ? '\n\n工作目录: ' + args.cwd : ''}`
+ } else if (name === 'write_file') {
+ const preview = (args.content || '').slice(0, 200)
+ desc = `写入文件:\n${args.path}\n\n内容预览:\n${preview}${(args.content || '').length > 200 ? '\n...(已截断)' : ''}`
+ }
+
+ const prefix = critical
+ ? '⛔ 安全围栏拦截 — 此命令被识别为极端危险操作!\n\n'
+ : ''
+
+ const session = getCurrentSession()
+ if (session) setSessionStatus(session.id, 'waiting')
+ const result = await showConfirm(`${prefix}AI 请求执行以下操作:\n\n${desc}\n\n是否允许?`)
+ if (session) setSessionStatus(session.id, 'streaming')
+ return result
+}
+
+// 带工具调用的 AI 请求(非流式,用于 tool_calls 检测循环)
+async function callAIWithTools(messages, onStatus, onToolProgress) {
+ if (!_config.baseUrl || !_config.apiKey || !_config.model) {
+ throw new Error('请先配置 AI 模型(点击右上角设置按钮)')
+ }
+
+ const base = cleanBaseUrl(_config.baseUrl)
+ const tools = getEnabledTools()
+ let currentMessages = [{ role: 'system', content: buildSystemPrompt() }, ...messages]
+ const toolHistory = [] // 记录工具调用历史
+
+ const MAX_AUTO_ROUNDS = 8
+ // 工具调用循环(无硬性上限,超过阈值后询问用户)
+ for (let round = 0; ; round++) {
+ // 超过自动轮次后,询问用户是否继续
+ if (round >= MAX_AUTO_ROUNDS) {
+ const answer = await showAskUserCard({
+ question: `AI 已连续调用工具 ${round} 轮,可能陷入循环。你希望怎么做?`,
+ type: 'single',
+ options: ['继续执行', '让 AI 换个思路', '停止并总结'],
+ })
+ if (answer.includes('停止')) {
+ return { content: '用户要求停止工具调用,以下是目前的执行情况摘要。', toolHistory }
+ } else if (answer.includes('换个思路')) {
+ currentMessages.push({ role: 'user', content: '请换一种方法来解决这个问题,不要重复之前失败的操作。' })
+ }
+ }
+
+ _abortController = new AbortController()
+ onStatus(round === 0 ? 'AI 思考中...' : `AI 处理工具结果 (第${round + 1}轮)...`)
+
+ const body = {
+ model: _config.model,
+ messages: currentMessages,
+ temperature: _config.temperature || 0.7,
+ }
+ if (tools.length > 0) body.tools = tools
+
+ const resp = await fetchWithRetry(base + '/chat/completions', {
+ method: 'POST',
+ headers: authHeaders(),
+ body: JSON.stringify(body),
+ signal: _abortController.signal,
+ })
+
+ if (!resp.ok) {
+ const errText = await resp.text().catch(() => '')
+ let errMsg = `API 错误 ${resp.status}`
+ try { errMsg = JSON.parse(errText).error?.message || errMsg } catch {}
+ throw new Error(errMsg)
+ }
+
+ const data = await resp.json()
+ const choice = data.choices?.[0]
+ const assistantMsg = choice?.message
+
+ if (!assistantMsg) throw new Error('AI 未返回有效响应')
+
+ // 检查是否有 tool_calls
+ if (assistantMsg.tool_calls && assistantMsg.tool_calls.length > 0) {
+ // 将 assistant 消息(含 tool_calls)加入上下文
+ currentMessages.push(assistantMsg)
+
+ for (const tc of assistantMsg.tool_calls) {
+ let args
+ try { args = JSON.parse(tc.function.arguments) } catch { args = {} }
+ const toolName = tc.function.name
+
+ // 先显示"执行中"状态的工具块
+ toolHistory.push({ name: toolName, args, result: null, approved: true, pending: true })
+ onToolProgress(toolHistory)
+
+ let result = ''
+ let approved = true
+
+ // 安全围栏:极端危险命令任何模式都必须确认
+ const mode = MODES[currentMode()]
+ const isCritical = toolName === 'run_command' && isCriticalCommand(args.command)
+ if (isCritical) {
+ approved = await confirmToolCall(tc, true)
+ if (!approved) result = '用户拒绝了此危险操作'
+ } else if (mode.confirmDanger && DANGEROUS_TOOLS.has(toolName)) {
+ approved = await confirmToolCall(tc)
+ if (!approved) result = '用户拒绝了此操作'
+ }
+
+ if (approved) {
+ try {
+ result = await executeTool(toolName, args)
+ } catch (err) {
+ result = `执行失败: ${typeof err === 'string' ? err : err.message || JSON.stringify(err)}`
+ }
+ }
+
+ // 更新工具调用历史(完成状态)
+ const last = toolHistory[toolHistory.length - 1]
+ last.result = result
+ last.approved = approved
+ last.pending = false
+ onToolProgress(toolHistory)
+
+ // 添加 tool 结果消息
+ currentMessages.push({
+ role: 'tool',
+ tool_call_id: tc.id,
+ content: typeof result === 'string' ? result : JSON.stringify(result),
+ })
+ }
+
+ continue // 继续循环,让 AI 处理工具结果
+ }
+
+ // 没有 tool_calls,返回最终文本
+ const content = assistantMsg.content || assistantMsg.reasoning_content || ''
+ return { content, toolHistory }
+ }
+}
+
+// ── 渲染 ──
+
+function renderSessionList() {
+ if (!_sessionListEl) return
+ const sorted = [..._sessions].reverse()
+ _sessionListEl.innerHTML = sorted.map(s => {
+ const status = getSessionStatus(s.id)
+ const dotClass = status === 'streaming' ? 'ast-status-dot streaming'
+ : status === 'waiting' ? 'ast-status-dot waiting'
+ : status === 'error' ? 'ast-status-dot error'
+ : ''
+ const dot = dotClass ? `` : ''
+ return `
+ ${dot}${escHtml(s.title)}
+
+
`
+ }).join('') || '暂无会话
'
+}
+
+function renderToolBlocks(toolHistory) {
+ if (!toolHistory || toolHistory.length === 0) return ''
+ return toolHistory.map(tc => {
+ // ask_user 工具不显示在工具块中(它有自己的交互卡片)
+ if (tc.name === 'ask_user') return ''
+
+ const icon = { run_command: '⚡', write_file: '✏️', read_file: '📄', list_directory: '📂', get_system_info: '💻', list_processes: '📊', check_port: '🔌' }[tc.name] || '🔧'
+ const label = { run_command: '执行命令', read_file: '读取文件', write_file: '写入文件', list_directory: '列出目录', get_system_info: '系统信息', list_processes: '进程列表', check_port: '端口检测' }[tc.name] || tc.name
+ const argsStr = tc.name === 'run_command' ? escHtml(tc.args.command || '')
+ : tc.name === 'read_file' ? escHtml(tc.args.path || '')
+ : tc.name === 'write_file' ? escHtml(tc.args.path || '')
+ : tc.name === 'list_directory' ? escHtml(tc.args.path || '')
+ : tc.name === 'get_system_info' ? ''
+ : tc.name === 'list_processes' ? escHtml(tc.args.filter || '全部')
+ : tc.name === 'check_port' ? escHtml(String(tc.args.port || ''))
+ : escHtml(JSON.stringify(tc.args))
+
+ if (tc.pending) {
+ return ``
+ }
+
+ const statusClass = tc.approved === false ? 'denied' : 'ok'
+ const statusLabel = tc.approved === false ? '已拒绝' : '已执行'
+ const resultPreview = (tc.result || '').length > 500 ? tc.result.slice(0, 500) + '...' : (tc.result || '')
+ return `
+ ${icon} ${label} ${argsStr} ${statusLabel}
+ ${escHtml(resultPreview)}
+ `
+ }).join('')
+}
+
+function renderMessages() {
+ const session = getCurrentSession()
+ if (!_messagesEl) return
+ if (!session || session.messages.length === 0) {
+ const skillCards = BUILTIN_SKILLS.map(s => `
+
+ `).join('')
+
+ _messagesEl.innerHTML = `
+
+
+
${_config?.assistantName || DEFAULT_NAME}
+
我可以帮你分析日志、排查问题、配置 OpenClaw。
点击下方技能卡片,AI 会自动调用工具完成任务。
+
${skillCards}
+
+ `
+ return
+ }
+
+ _messagesEl.innerHTML = session.messages.map((m, idx) => {
+ if (m.role === 'user') {
+ const textPart = m._text || (typeof m.content === 'string' ? m.content : (m.content?.find?.(p => p.type === 'text')?.text || ''))
+ const imagesHtml = m._images?.length ? `${m._images.map(img =>
+ img.dataUrl
+ ? `

`
+ : `
${escHtml(img.name || '图片')} `
+ ).join('')}
` : ''
+ return `${imagesHtml}${textPart ? escHtml(textPart) : ''}
`
+ } else if (m.role === 'assistant') {
+ const toolHtml = renderToolBlocks(m.toolHistory)
+ return `${toolHtml}
${renderMarkdown(m.content)}
`
+ }
+ return ''
+ }).join('')
+
+ // 从文件系统恢复图片
+ _messagesEl.querySelectorAll('.ast-msg-img-loading[data-db-id]').forEach(async (el) => {
+ const dbId = el.dataset.dbId
+ if (!dbId) return
+ const dataUrl = await loadImageFromFile(dbId)
+ if (dataUrl) {
+ const img = document.createElement('img')
+ img.className = 'ast-msg-img'
+ img.src = dataUrl
+ img.alt = el.querySelector('span')?.textContent || '图片'
+ img.loading = 'lazy'
+ img.style.maxWidth = '300px'
+ el.replaceWith(img)
+ // 同步回内存中的 session 数据(当前会话期间不用再查文件)
+ for (const s of _sessions) {
+ for (const m of s.messages) {
+ if (m._images) {
+ const match = m._images.find(i => i.dbId === dbId)
+ if (match) match.dataUrl = dataUrl
+ }
+ }
+ }
+ } else {
+ el.classList.remove('ast-msg-img-loading')
+ el.classList.add('ast-msg-img-placeholder')
+ }
+ })
+
+ // 滚动到底部
+ requestAnimationFrame(() => {
+ _messagesEl.scrollTop = _messagesEl.scrollHeight
+ })
+}
+
+function buildTestResult({ success, elapsed, usedApi, reqUrl, reqBody, respStatus, respBody, reply, error }) {
+ let html = ''
+ // 状态行
+ if (error) {
+ html += `✗ 请求失败: ${escHtml(error)}`
+ } else if (success) {
+ html += `✓ 模型回复成功 (${elapsed}ms, ${usedApi} API)`
+ } else {
+ html += `⚠ HTTP ${respStatus} — 请求完成但未解析到回复内容`
+ }
+ // 回复预览
+ if (reply) {
+ const short = reply.length > 80 ? reply.slice(0, 80) + '...' : reply
+ html += `「${escHtml(short)}」
`
+ }
+ // 折叠的详细信息
+ html += `查看完整请求/响应参数
`
+ html += ``
+ html += `POST ${escHtml(reqUrl)}\n\n`
+ html += `Request Body:\n${escHtml(JSON.stringify(reqBody, null, 2))}\n\n`
+ html += `Response Status: ${respStatus}\n\n`
+ html += `Response Body:\n`
+ // 美化 JSON
+ try {
+ html += escHtml(JSON.stringify(JSON.parse(respBody), null, 2))
+ } catch {
+ html += escHtml(respBody?.slice(0, 2000) || '(empty)')
+ }
+ html += `
`
+ return html
+}
+
+function showSettings() {
+ const c = _config
+ const overlay = document.createElement('div')
+ overlay.className = 'modal-overlay'
+ overlay.innerHTML = `
+
+
${c.assistantName || DEFAULT_NAME} — 设置
+
+
+
+
+
+
+
+
+
+
+
+ `
+ document.body.appendChild(overlay)
+
+ // Tab 切换
+ overlay.querySelectorAll('.ast-tab').forEach(tab => {
+ tab.addEventListener('click', () => {
+ overlay.querySelectorAll('.ast-tab').forEach(t => t.classList.remove('active'))
+ overlay.querySelectorAll('.ast-tab-panel').forEach(p => p.classList.remove('active'))
+ tab.classList.add('active')
+ overlay.querySelector(`.ast-tab-panel[data-panel="${tab.dataset.tab}"]`)?.classList.add('active')
+ })
+ })
+
+ const resultEl = overlay.querySelector('#ast-test-result')
+ const modelInput = overlay.querySelector('#ast-model')
+ const dropdown = overlay.querySelector('#ast-model-dropdown')
+
+ // 测试对话:真实发一条消息,显示完整请求/响应参数
+ overlay.querySelector('#ast-btn-test').onclick = async (e) => {
+ const btn = e.target
+ const baseUrl = overlay.querySelector('#ast-baseurl').value.trim()
+ const apiKey = overlay.querySelector('#ast-apikey').value.trim()
+ const model = overlay.querySelector('#ast-model').value.trim()
+ if (!baseUrl || !apiKey) {
+ resultEl.innerHTML = '请先填写 Base URL 和 API Key'
+ return
+ }
+ if (!model) {
+ resultEl.innerHTML = '请先填写或选择模型'
+ return
+ }
+ btn.disabled = true
+ btn.textContent = '测试中...'
+ resultEl.innerHTML = '正在发送测试消息...'
+ const base = cleanBaseUrl(baseUrl)
+ const hdrs = { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + apiKey }
+ const t0 = Date.now()
+ const reqBody = { model, messages: [{ role: 'user', content: '你好,请用一句话回复' }], max_tokens: 200 }
+ const reqUrl = base + '/chat/completions'
+
+ let respStatus = 0, respBody = '', reply = '', usedApi = 'Chat Completions', fallback = false
+
+ try {
+ const resp = await fetch(reqUrl, {
+ method: 'POST', headers: hdrs,
+ body: JSON.stringify(reqBody),
+ signal: AbortSignal.timeout(30000),
+ })
+ respStatus = resp.status
+ respBody = await resp.text()
+
+ if (!resp.ok) {
+ // 检查是否需要切到 Responses API
+ if (respBody.includes('legacy protocol') || respBody.includes('/v1/responses') || respBody.includes('not supported')) {
+ fallback = true
+ }
+ }
+
+ if (!fallback) {
+ // 尝试从各种可能的格式中提取回复
+ try {
+ const data = JSON.parse(respBody)
+ const msg = data.choices?.[0]?.message
+ reply = msg?.content
+ || msg?.reasoning_content
+ || data.choices?.[0]?.text
+ || data.output?.text
+ || data.result?.output?.text
+ || data.data?.choices?.[0]?.message?.content
+ || ''
+ // 如果 content 为空但有 reasoning_content,标记为推理内容
+ if (!msg?.content && msg?.reasoning_content) {
+ reply = '[推理内容] ' + reply
+ }
+ } catch {}
+ }
+ } catch (err) {
+ const elapsed = Date.now() - t0
+ resultEl.innerHTML = buildTestResult({
+ success: false, elapsed, usedApi,
+ reqUrl, reqBody, respStatus: 0, respBody: '', error: err.message,
+ })
+ btn.disabled = false; btn.textContent = '测试对话'; return
+ }
+
+ // Responses API fallback
+ if (fallback) {
+ usedApi = 'Responses'
+ const reqUrl2 = base + '/responses'
+ const reqBody2 = { model, input: [{ role: 'user', content: '你好,请用一句话回复' }], max_output_tokens: 200 }
+ try {
+ const resp2 = await fetch(reqUrl2, {
+ method: 'POST', headers: hdrs,
+ body: JSON.stringify(reqBody2),
+ signal: AbortSignal.timeout(30000),
+ })
+ respStatus = resp2.status
+ respBody = await resp2.text()
+ try {
+ const data2 = JSON.parse(respBody)
+ reply = data2.output_text || data2.output?.[0]?.content?.[0]?.text || ''
+ } catch {}
+ } catch (err) {
+ const elapsed = Date.now() - t0
+ resultEl.innerHTML = buildTestResult({
+ success: false, elapsed, usedApi,
+ reqUrl: reqUrl2, reqBody: reqBody2, respStatus: 0, respBody: '', error: err.message,
+ })
+ btn.disabled = false; btn.textContent = '测试对话'; return
+ }
+ }
+
+ const elapsed = Date.now() - t0
+ resultEl.innerHTML = buildTestResult({
+ success: !!reply, elapsed, usedApi,
+ reqUrl: fallback ? base + '/responses' : reqUrl,
+ reqBody: fallback ? { model, input: [{ role: 'user', content: '你好,请用一句话回复' }], max_output_tokens: 200 } : reqBody,
+ respStatus, respBody, reply,
+ })
+ btn.disabled = false
+ btn.textContent = '测试对话'
+ }
+
+ // 获取模型列表
+ overlay.querySelector('#ast-btn-models').onclick = async (e) => {
+ const btn = e.target
+ const baseUrl = overlay.querySelector('#ast-baseurl').value.trim()
+ const apiKey = overlay.querySelector('#ast-apikey').value.trim()
+ if (!baseUrl || !apiKey) {
+ resultEl.innerHTML = '请先填写 Base URL 和 API Key'
+ return
+ }
+ btn.disabled = true
+ btn.textContent = '获取中...'
+ resultEl.innerHTML = '正在获取模型列表...'
+ try {
+ const base = cleanBaseUrl(baseUrl)
+ const resp = await fetch(base + '/models', {
+ headers: { 'Authorization': 'Bearer ' + apiKey },
+ signal: AbortSignal.timeout(10000),
+ })
+ if (!resp.ok) {
+ const text = await resp.text().catch(() => '')
+ let msg = 'HTTP ' + resp.status
+ try { msg = JSON.parse(text).error?.message || msg } catch {}
+ resultEl.innerHTML = '✗ ' + escHtml(msg) + ''
+ return
+ }
+ const data = await resp.json()
+ const models = (data.data || []).map(m => m.id).filter(Boolean).sort()
+ if (models.length === 0) {
+ resultEl.innerHTML = '未发现可用模型'
+ return
+ }
+ resultEl.innerHTML = '✓ 发现 ' + models.length + ' 个模型,点击下方列表选择'
+ // 显示下拉列表
+ dropdown.innerHTML = models.map(m =>
+ '' + escHtml(m) + '
'
+ ).join('')
+ dropdown.style.display = 'block'
+ } catch (err) {
+ resultEl.innerHTML = '✗ ' + escHtml(err.message) + ''
+ } finally {
+ btn.disabled = false
+ btn.textContent = '获取模型列表'
+ }
+ }
+
+ // 模型下拉选择
+ dropdown.addEventListener('click', (e) => {
+ const opt = e.target.closest('.ast-model-option')
+ if (opt) {
+ modelInput.value = opt.dataset.model
+ dropdown.style.display = 'none'
+ }
+ })
+
+ // 点击输入框外关闭下拉
+ modelInput.addEventListener('focus', () => {
+ if (dropdown.children.length > 0) dropdown.style.display = 'block'
+ })
+ overlay.addEventListener('click', (e) => {
+ if (e.target === overlay) { overlay.remove(); return }
+ if (!e.target.closest('#ast-model') && !e.target.closest('#ast-model-dropdown') && !e.target.closest('#ast-btn-models')) {
+ dropdown.style.display = 'none'
+ }
+ })
+
+ overlay.querySelector('[data-action="cancel"]').onclick = () => overlay.remove()
+ overlay.querySelector('[data-action="confirm"]').onclick = () => {
+ _config.assistantName = overlay.querySelector('#ast-name').value.trim() || DEFAULT_NAME
+ _config.assistantPersonality = overlay.querySelector('#ast-personality').value.trim() || DEFAULT_PERSONALITY
+ _config.baseUrl = overlay.querySelector('#ast-baseurl').value.trim()
+ _config.apiKey = overlay.querySelector('#ast-apikey').value.trim()
+ _config.model = overlay.querySelector('#ast-model').value.trim()
+ _config.temperature = parseFloat(overlay.querySelector('#ast-temp').value) || 0.7
+ // 工具开关
+ _config.tools.terminal = overlay.querySelector('#ast-tool-terminal').checked
+ _config.tools.fileOps = overlay.querySelector('#ast-tool-fileops').checked
+ saveConfig()
+ overlay.remove()
+ // 更新 Header 标题和欢迎页
+ const titleEl = _page.querySelector('.ast-title')
+ if (titleEl) titleEl.textContent = _config.assistantName
+ renderMessages()
+ toast('设置已保存', 'info')
+ updateModelBadge()
+ }
+ overlay.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape') overlay.remove()
+ })
+ const firstInput = overlay.querySelector('input')
+ if (firstInput) firstInput.focus()
+}
+
+function updateModelBadge() {
+ const badge = _page?.querySelector('#ast-model-badge')
+ if (badge) {
+ if (_config.model) {
+ badge.textContent = _config.model
+ badge.className = 'ast-model-badge configured'
+ } else {
+ badge.textContent = '未配置'
+ badge.className = 'ast-model-badge unconfigured'
+ }
+ }
+}
+
+// ── 发送消息 ──
+function sendMessage(text) {
+ const hasContent = text.trim() || _pendingImages.length > 0
+ if (!hasContent) return
+ // 流式中 → 排队(图片不排队,提示用户)
+ if (_isStreaming) {
+ if (_pendingImages.length > 0) {
+ toast('AI 正在回复中,图片消息请等待完成后再发送', 'info')
+ return
+ }
+ enqueueMessage(text.trim())
+ return
+ }
+ sendMessageDirect(text)
+}
+
+// 直接发送(内部使用,不经过队列)
+async function sendMessageDirect(text) {
+ const hasContent = text.trim() || _pendingImages.length > 0
+ if (!hasContent) return
+ if (_isStreaming) {
+ if (_pendingImages.length > 0) { toast('请等待 AI 回复完成', 'info'); return }
+ enqueueMessage(text.trim())
+ return
+ }
+
+ let session = getCurrentSession()
+ if (!session) {
+ session = createSession()
+ renderSessionList()
+ }
+
+ // 收集当前附件图片
+ const images = [..._pendingImages]
+ clearPendingImages()
+
+ // 添加用户消息(多模态或纯文本)
+ const textContent = text.trim()
+ const msgContent = buildMessageContent(textContent, images)
+ const userMsg = { role: 'user', content: msgContent, ts: Date.now() }
+ if (images.length > 0) {
+ // 为每张图片生成稳定 ID 并存入文件系统
+ userMsg._images = images.map(i => {
+ const dbId = 'img_' + i.id
+ saveImageToFile(dbId, i.dataUrl) // 异步存储,不阻塞
+ return { dbId, dataUrl: i.dataUrl, name: i.name, width: i.width, height: i.height }
+ })
+ }
+ if (textContent) userMsg._text = textContent
+ session.messages.push(userMsg)
+ autoTitle(session)
+ session.updatedAt = Date.now()
+ saveSessions()
+ renderMessages()
+ renderSessionList()
+
+ // 准备 AI 上下文(只保留 role + content,剔除内部字段)
+ // 过滤掉空的 AI 回复,避免污染上下文导致模型也返回空
+ const contextMessages = session.messages
+ .filter(m => {
+ if (m.role === 'user') return true
+ if (m.role === 'assistant') return m.content && m.content.length > 0
+ return false
+ })
+ .slice(-MAX_CONTEXT_TOKENS)
+ .map(m => ({ role: m.role, content: m.content }))
+
+ // 添加空 AI 消息占位
+ const aiMsg = { role: 'assistant', content: '', ts: Date.now() }
+ session.messages.push(aiMsg)
+
+ _isStreaming = true
+ _sendBtn.innerHTML = stopIcon()
+ setSessionStatus(session.id, 'streaming')
+
+ // 渲染流式 typing 状态
+ renderMessages()
+ const aiBubbles = _messagesEl?.querySelectorAll('.ast-msg-bubble-ai')
+ const lastBubble = aiBubbles?.[aiBubbles.length - 1]
+ if (lastBubble) lastBubble.innerHTML = '思考中...'
+
+ const toolsEnabled = getEnabledTools().length > 0
+
+ try {
+ if (toolsEnabled) {
+ // ── 工具模式:非流式,支持 tool_calls 循环 ──
+ const aiMsgContainers = _messagesEl?.querySelectorAll('.ast-msg-ai')
+ const lastContainer = aiMsgContainers?.[aiMsgContainers.length - 1]
+
+ const result = await callAIWithTools(contextMessages,
+ // onStatus
+ (status) => {
+ if (lastBubble) lastBubble.innerHTML = `${escHtml(status)}`
+ },
+ // onToolProgress
+ (history) => {
+ aiMsg.toolHistory = history
+ throttledSave() // 实时保存工具调用进度
+ if (!lastContainer) return
+ const toolHtml = renderToolBlocks(history)
+ const bubble = lastContainer.querySelector('.ast-msg-bubble-ai')
+ lastContainer.innerHTML = toolHtml + (bubble ? bubble.outerHTML : '')
+ if (_messagesEl) _messagesEl.scrollTop = _messagesEl.scrollHeight
+ }
+ )
+
+ aiMsg.content = result.content
+ if (result.toolHistory.length > 0) {
+ aiMsg.toolHistory = result.toolHistory
+ }
+ renderMessages()
+ } else {
+ // ── 普通流式模式 ──
+ await callAI(contextMessages, (chunk) => {
+ aiMsg.content += chunk
+ throttledSave() // 实时保存每个 chunk
+ if (lastBubble) {
+ const now = Date.now()
+ if (now - _lastRenderTime > 50) {
+ lastBubble.innerHTML = renderMarkdown(aiMsg.content) + '▊'
+ if (_messagesEl) _messagesEl.scrollTop = _messagesEl.scrollHeight
+ _lastRenderTime = now
+ }
+ }
+ })
+
+ if (lastBubble) {
+ lastBubble.innerHTML = renderMarkdown(aiMsg.content)
+ }
+ }
+ // 保存调试信息到 AI 消息
+ if (_lastDebugInfo) {
+ aiMsg._debug = _lastDebugInfo
+ _lastDebugInfo = null
+ }
+ } catch (err) {
+ if (err.name === 'AbortError') {
+ aiMsg.content += aiMsg.content ? '\n\n*[已停止]*' : '*[已停止]*'
+ } else {
+ setSessionStatus(session.id, 'error')
+ // 保留已有内容,追加错误信息和重试按钮
+ const errInfo = aiMsg.content
+ ? `\n\n---\n❌ **请求中断**: ${err.message}`
+ : `❌ ${err.message}`
+ aiMsg.content += errInfo
+ aiMsg._canRetry = true
+ }
+ renderMessages()
+
+ // 错误后插入重试按钮
+ if (aiMsg._canRetry && _messagesEl) {
+ const retryBar = document.createElement('div')
+ retryBar.className = 'ast-retry-bar'
+ const retrySvg = ''
+ const continueSvg = ''
+ retryBar.innerHTML = `
+
+
+ 请求失败(已自动重试 3 次)
+ `
+ _messagesEl.appendChild(retryBar)
+ _messagesEl.scrollTop = _messagesEl.scrollHeight
+
+ retryBar.querySelector('.ast-btn-retry').addEventListener('click', () => {
+ retryBar.remove()
+ session.messages.pop()
+ saveSessions()
+ setSessionStatus(session.id, 'idle')
+ retryAIResponse(session)
+ })
+ retryBar.querySelector('.ast-btn-continue').addEventListener('click', () => {
+ retryBar.remove()
+ setSessionStatus(session.id, 'idle')
+ renderSessionList()
+ _textarea?.focus()
+ })
+ }
+ } finally {
+ _isStreaming = false
+ _abortController = null
+ stopStreamRefresh()
+ if (_sendBtn) _sendBtn.innerHTML = sendIcon()
+ if (_textarea) _textarea.focus()
+ session.updatedAt = Date.now()
+ flushSave()
+ if (getSessionStatus(session.id) !== 'error') {
+ setSessionStatus(session.id, 'idle')
+ }
+ // 最终渲染(可能从后台回来,DOM 已重建)
+ if (_messagesEl) {
+ renderMessages()
+ _messagesEl.scrollTop = _messagesEl.scrollHeight
+ }
+ setTimeout(() => processQueue(), 100)
+ }
+}
+
+// 重试 AI 响应(不重复添加用户消息)
+async function retryAIResponse(session) {
+ if (_isStreaming) return
+
+ const contextMessages = session.messages
+ .filter(m => m.role === 'user' || m.role === 'assistant')
+ .slice(-MAX_CONTEXT_TOKENS)
+
+ const aiMsg = { role: 'assistant', content: '', ts: Date.now() }
+ session.messages.push(aiMsg)
+
+ _isStreaming = true
+ if (_sendBtn) _sendBtn.innerHTML = stopIcon()
+ setSessionStatus(session.id, 'streaming')
+
+ renderMessages()
+ const aiBubbles = _messagesEl?.querySelectorAll('.ast-msg-bubble-ai')
+ const lastBubble = aiBubbles?.[aiBubbles.length - 1]
+ if (lastBubble) lastBubble.innerHTML = '重试中...'
+
+ const toolsEnabled = getEnabledTools().length > 0
+
+ try {
+ if (toolsEnabled) {
+ const aiMsgContainers = _messagesEl?.querySelectorAll('.ast-msg-ai')
+ const lastContainer = aiMsgContainers?.[aiMsgContainers.length - 1]
+
+ const result = await callAIWithTools(contextMessages,
+ (status) => { if (lastBubble) lastBubble.innerHTML = `${escHtml(status)}` },
+ (history) => {
+ aiMsg.toolHistory = history
+ throttledSave()
+ if (!lastContainer) return
+ const toolHtml = renderToolBlocks(history)
+ const bubble = lastContainer.querySelector('.ast-msg-bubble-ai')
+ lastContainer.innerHTML = toolHtml + (bubble ? bubble.outerHTML : '')
+ if (_messagesEl) _messagesEl.scrollTop = _messagesEl.scrollHeight
+ }
+ )
+ aiMsg.content = result.content
+ if (result.toolHistory.length > 0) aiMsg.toolHistory = result.toolHistory
+ renderMessages()
+ } else {
+ await callAI(contextMessages, (chunk) => {
+ aiMsg.content += chunk
+ throttledSave()
+ if (lastBubble) {
+ const now = Date.now()
+ if (now - _lastRenderTime > 50) {
+ lastBubble.innerHTML = renderMarkdown(aiMsg.content) + '▊'
+ if (_messagesEl) _messagesEl.scrollTop = _messagesEl.scrollHeight
+ _lastRenderTime = now
+ }
+ }
+ })
+ if (lastBubble) lastBubble.innerHTML = renderMarkdown(aiMsg.content)
+ }
+ } catch (err) {
+ if (err.name === 'AbortError') {
+ aiMsg.content += aiMsg.content ? '\n\n*[已停止]*' : '*[已停止]*'
+ } else {
+ setSessionStatus(session.id, 'error')
+ aiMsg.content += aiMsg.content
+ ? `\n\n---\n❌ **请求中断**: ${err.message}`
+ : `❌ ${err.message}`
+ aiMsg._canRetry = true
+ }
+ renderMessages()
+
+ if (aiMsg._canRetry && _messagesEl) {
+ const retryBar = document.createElement('div')
+ retryBar.className = 'ast-retry-bar'
+ const retrySvg = ''
+ const continueSvg = ''
+ retryBar.innerHTML = `
+
+
+ 请求失败(已自动重试 3 次)
+ `
+ _messagesEl.appendChild(retryBar)
+ _messagesEl.scrollTop = _messagesEl.scrollHeight
+
+ retryBar.querySelector('.ast-btn-retry').addEventListener('click', () => {
+ retryBar.remove()
+ session.messages.pop()
+ saveSessions()
+ setSessionStatus(session.id, 'idle')
+ retryAIResponse(session)
+ })
+ retryBar.querySelector('.ast-btn-continue').addEventListener('click', () => {
+ retryBar.remove()
+ setSessionStatus(session.id, 'idle')
+ renderSessionList()
+ _textarea?.focus()
+ })
+ }
+ } finally {
+ _isStreaming = false
+ _abortController = null
+ stopStreamRefresh()
+ if (_sendBtn) _sendBtn.innerHTML = sendIcon()
+ if (_textarea) _textarea.focus()
+ session.updatedAt = Date.now()
+ flushSave()
+ if (getSessionStatus(session.id) !== 'error') {
+ setSessionStatus(session.id, 'idle')
+ }
+ if (_messagesEl) {
+ renderMessages()
+ _messagesEl.scrollTop = _messagesEl.scrollHeight
+ }
+ setTimeout(() => processQueue(), 100)
+ }
+}
+
+function stopStreaming() {
+ if (_abortController) {
+ _abortController.abort()
+ }
+}
+
+// ── 右键调试菜单 ──
+let _ctxMenu = null
+
+function showMsgContextMenu(e, msgIdx) {
+ e.preventDefault()
+ hideContextMenu()
+
+ const session = getCurrentSession()
+ if (!session) return
+ const msg = session.messages[msgIdx]
+ if (!msg) return
+
+ const menu = document.createElement('div')
+ menu.className = 'ast-ctx-menu'
+ menu.innerHTML = `
+
+
+
+
+ ${msg._debug ? '' : ''}
+ `
+ // 定位
+ menu.style.left = Math.min(e.clientX, window.innerWidth - 200) + 'px'
+ menu.style.top = Math.min(e.clientY, window.innerHeight - 200) + 'px'
+ document.body.appendChild(menu)
+ _ctxMenu = menu
+
+ menu.addEventListener('click', (ev) => {
+ const action = ev.target.dataset?.action
+ if (!action) return
+ hideContextMenu()
+
+ const textContent = typeof msg.content === 'string'
+ ? msg.content
+ : (msg._text || msg.content?.find?.(p => p.type === 'text')?.text || '')
+
+ if (action === 'copy-text') {
+ navigator.clipboard.writeText(textContent).then(() => toast('已复制文本'))
+ } else if (action === 'copy-md') {
+ navigator.clipboard.writeText(msg.content || textContent).then(() => toast('已复制 Markdown'))
+ } else if (action === 'view-raw') {
+ const raw = { role: msg.role, content: msg.content, ts: msg.ts }
+ if (msg._images) raw._images = msg._images.map(i => ({ dbId: i.dbId, name: i.name, width: i.width, height: i.height }))
+ if (msg.toolHistory) raw.toolHistory = msg.toolHistory
+ showDebugModal('消息原始数据', JSON.stringify(raw, null, 2))
+ } else if (action === 'view-debug' && msg._debug) {
+ showDebugModal('请求/响应调试', JSON.stringify(msg._debug, null, 2))
+ }
+ })
+
+ setTimeout(() => document.addEventListener('click', hideContextMenu, { once: true }), 10)
+}
+
+function hideContextMenu() {
+ if (_ctxMenu) { _ctxMenu.remove(); _ctxMenu = null }
+}
+
+function showDebugModal(title, content) {
+ const overlay = document.createElement('div')
+ overlay.className = 'ast-debug-overlay'
+ overlay.innerHTML = `
+
+
+
${escHtml(content)}
+
+
+
+
+ `
+ document.body.appendChild(overlay)
+ overlay.querySelector('.ast-debug-close').onclick = () => overlay.remove()
+ overlay.querySelector('.ast-debug-copy').onclick = () => {
+ navigator.clipboard.writeText(content).then(() => toast('已复制'))
+ }
+ overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() })
+}
+
+// ── 工具函数 ──
+function escHtml(str) {
+ const d = document.createElement('div')
+ d.textContent = str || ''
+ return d.innerHTML
+}
+
+function sendIcon() {
+ return ''
+}
+
+function stopIcon() {
+ return ''
+}
+
+// ── 页面渲染 ──
+export async function render() {
+ loadConfig()
+ loadSessions()
+
+ // 确保数据目录存在(~/.openclaw/clawpanel/images/ 等)
+ api.ensureDataDir().catch(e => console.warn('数据目录初始化失败:', e))
+
+ // 如果没有会话,不自动创建(显示欢迎页)
+ if (_sessions.length > 0 && !_currentSessionId) {
+ _currentSessionId = _sessions[_sessions.length - 1].id
+ }
+
+ const page = document.createElement('div')
+ page.className = 'page ast-page'
+ _page = page
+
+ page.innerHTML = `
+
+
+ `
+
+ // 缓存 DOM 引用
+ _messagesEl = page.querySelector('#ast-messages')
+ _queueEl = page.querySelector('#ast-queue')
+ _textarea = page.querySelector('#ast-textarea')
+ _sendBtn = page.querySelector('#ast-send-btn')
+ _sessionListEl = page.querySelector('#ast-session-list')
+
+ // 渲染
+ renderSessionList()
+ renderMessages()
+ renderQueue()
+ applyModeStyle(page, currentMode())
+ // 滑块需要等 DOM 绘制完毕才能获取正确位置
+ requestAnimationFrame(() => positionModeSlider(page, currentMode()))
+
+ // 如果有后台流式正在进行,恢复 UI 状态
+ if (_isStreaming) {
+ _sendBtn.innerHTML = stopIcon()
+ startStreamRefresh()
+ }
+
+ // 检查是否有从 setup 页面带来的自动提问
+ const autoPrompt = sessionStorage.getItem('assistant-auto-prompt')
+ if (autoPrompt) {
+ sessionStorage.removeItem('assistant-auto-prompt')
+ // 自动切换到执行模式
+ if (currentMode() === 'chat') {
+ _config.mode = 'execute'
+ saveConfig()
+ page.querySelectorAll('.ast-mode-btn').forEach(b => b.classList.toggle('active', b.dataset.mode === 'execute'))
+ }
+ // 延迟发送,确保页面渲染完成
+ setTimeout(() => sendMessage(autoPrompt), 300)
+ }
+
+ // ── 事件绑定 ──
+
+ // 右键调试菜单(事件委托)
+ _messagesEl.addEventListener('contextmenu', (e) => {
+ const msgEl = e.target.closest('[data-msg-idx]')
+ if (!msgEl) return
+ showMsgContextMenu(e, parseInt(msgEl.dataset.msgIdx))
+ })
+
+ // 发送(流式中输入排队,空输入时点按钮停止流式)
+ _sendBtn.addEventListener('click', () => {
+ if (_isStreaming && !_textarea.value.trim() && _pendingImages.length === 0) { stopStreaming(); return }
+ if (_textarea.value.trim() || _pendingImages.length > 0) {
+ sendMessage(_textarea.value)
+ _textarea.value = ''
+ autoResize(_textarea)
+ }
+ })
+
+ // Enter 发送,Shift+Enter 换行
+ _textarea.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault()
+ if (!_textarea.value.trim() && _pendingImages.length === 0) return
+ sendMessage(_textarea.value)
+ _textarea.value = ''
+ autoResize(_textarea)
+ }
+ })
+
+ // 自动高度
+ _textarea.addEventListener('input', () => autoResize(_textarea))
+
+ // 图片上传按钮
+ const fileInput = page.querySelector('#ast-file-input')
+ page.querySelector('#ast-btn-attach').addEventListener('click', () => fileInput.click())
+ fileInput.addEventListener('change', () => {
+ for (const file of fileInput.files) addImageFromFile(file)
+ fileInput.value = ''
+ })
+
+ // 粘贴图片(Ctrl+V)
+ _textarea.addEventListener('paste', (e) => {
+ const items = e.clipboardData?.items
+ if (!items) return
+ let hasImage = false
+ for (const item of items) {
+ if (item.type.startsWith('image/')) {
+ addImageFromClipboard(item)
+ hasImage = true
+ }
+ }
+ if (hasImage) e.preventDefault()
+ })
+
+ // 拖拽图片
+ const mainEl = page.querySelector('.ast-main')
+ mainEl.addEventListener('dragover', (e) => {
+ e.preventDefault()
+ mainEl.classList.add('ast-drag-over')
+ })
+ mainEl.addEventListener('dragleave', (e) => {
+ if (!mainEl.contains(e.relatedTarget)) mainEl.classList.remove('ast-drag-over')
+ })
+ mainEl.addEventListener('drop', (e) => {
+ e.preventDefault()
+ mainEl.classList.remove('ast-drag-over')
+ for (const file of e.dataTransfer.files) addImageFromFile(file)
+ })
+
+ // 图片预览删除
+ page.querySelector('#ast-image-preview').addEventListener('click', (e) => {
+ const delBtn = e.target.closest('[data-img-del]')
+ if (delBtn) removeImage(delBtn.dataset.imgDel)
+ })
+
+ // 队列事件委托
+ _queueEl.addEventListener('click', (e) => {
+ // 插队发送
+ const sendBtn = e.target.closest('[data-queue-send]')
+ if (sendBtn) {
+ const id = sendBtn.dataset.queueSend
+ const idx = _messageQueue.findIndex(m => m.id === id)
+ if (idx === -1) return
+ const item = _messageQueue.splice(idx, 1)[0]
+ renderQueue()
+ if (_isStreaming) stopStreaming()
+ setTimeout(() => sendMessageDirect(item.text), 150)
+ return
+ }
+ // 删除
+ const delBtn = e.target.closest('[data-queue-del]')
+ if (delBtn) {
+ const id = delBtn.dataset.queueDel
+ _messageQueue = _messageQueue.filter(m => m.id !== id)
+ renderQueue()
+ return
+ }
+ // 编辑(点击文字或编辑按钮)
+ const editTarget = e.target.closest('[data-queue-edit]') || e.target.closest('[data-queue-edit-btn]')
+ if (editTarget) {
+ const id = editTarget.dataset.queueEdit || editTarget.dataset.queueEditBtn
+ const item = _messageQueue.find(m => m.id === id)
+ if (!item) return
+ const queueItem = _queueEl.querySelector(`[data-queue-id="${id}"]`)
+ if (!queueItem || queueItem.classList.contains('editing')) return
+ queueItem.classList.add('editing')
+ const textEl = queueItem.querySelector('.ast-queue-text')
+ const input = document.createElement('textarea')
+ input.className = 'ast-queue-edit-input'
+ input.value = item.text
+ input.rows = 1
+ textEl.replaceWith(input)
+ input.focus()
+ input.style.height = Math.min(input.scrollHeight, 100) + 'px'
+ // 保存编辑
+ const save = () => {
+ const newText = input.value.trim()
+ if (newText) item.text = newText
+ renderQueue()
+ }
+ input.addEventListener('blur', save)
+ input.addEventListener('keydown', (ev) => {
+ if (ev.key === 'Enter' && !ev.shiftKey) { ev.preventDefault(); save() }
+ if (ev.key === 'Escape') renderQueue()
+ })
+ input.addEventListener('input', () => {
+ input.style.height = 'auto'
+ input.style.height = Math.min(input.scrollHeight, 100) + 'px'
+ })
+ }
+ })
+
+ // 侧边栏切换
+ page.querySelector('#ast-btn-toggle').addEventListener('click', () => {
+ page.querySelector('#ast-sidebar').classList.toggle('open')
+ })
+
+ // 新建会话
+ page.querySelector('#ast-btn-new').addEventListener('click', () => {
+ createSession()
+ renderSessionList()
+ renderMessages()
+ })
+
+ // 模式切换
+ page.querySelector('#ast-mode-selector').addEventListener('click', (e) => {
+ const btn = e.target.closest('.ast-mode-btn')
+ if (!btn) return
+ const modeKey = btn.dataset.mode
+ if (!MODES[modeKey] || modeKey === currentMode()) return
+ _config.mode = modeKey
+ saveConfig()
+ page.querySelectorAll('.ast-mode-btn').forEach(b => b.classList.toggle('active', b.dataset.mode === modeKey))
+ applyModeStyle(page, modeKey)
+ playModeTransition(page, modeKey)
+ })
+
+ // 设置
+ page.querySelector('#ast-btn-settings').addEventListener('click', showSettings)
+
+ // 会话列表事件委托
+ _sessionListEl.addEventListener('click', (e) => {
+ const deleteBtn = e.target.closest('[data-delete]')
+ if (deleteBtn) {
+ e.stopPropagation()
+ const id = deleteBtn.dataset.delete
+ showConfirm('确定删除这个会话吗?').then(ok => {
+ if (!ok) return
+ deleteSession(id)
+ renderSessionList()
+ renderMessages()
+ })
+ return
+ }
+ const item = e.target.closest('.ast-session-item')
+ if (item) {
+ _currentSessionId = item.dataset.id
+ renderSessionList()
+ renderMessages()
+ // 切换到正在流式的会话时,启动刷新
+ if (_isStreaming && getSessionStatus(_currentSessionId) === 'streaming') {
+ startStreamRefresh()
+ } else {
+ stopStreamRefresh()
+ }
+ }
+ })
+
+ // 欢迎页技能卡片 & 快捷按钮委托
+ _messagesEl.addEventListener('click', (e) => {
+ const skillCard = e.target.closest('.ast-skill-card')
+ if (skillCard) {
+ const skill = BUILTIN_SKILLS.find(s => s.id === skillCard.dataset.skill)
+ if (!skill) return
+
+ // 技能需要工具 → 自动切换到执行模式(如果当前是聊天模式)
+ if (skill.tools.length > 0 && currentMode() === 'chat') {
+ _config.mode = 'execute'
+ saveConfig()
+ page.querySelectorAll('.ast-mode-btn').forEach(b => b.classList.toggle('active', b.dataset.mode === 'execute'))
+ toast('已自动切换到 🚀 执行模式', 'info')
+ }
+
+ sendMessage(skill.prompt)
+ return
+ }
+
+ const quickBtn = e.target.closest('.ast-quick-btn')
+ if (quickBtn) {
+ const prompt = quickBtn.dataset.prompt
+ if (prompt) sendMessage(prompt)
+ }
+ })
+
+ return page
+}
+
+function autoResize(textarea) {
+ textarea.style.height = 'auto'
+ textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px'
+}
+
+export function cleanup() {
+ flushSave()
+ stopStreamRefresh()
+ _pendingImages = []
+ _page = null
+ _messagesEl = null
+ _queueEl = null
+ _textarea = null
+ _sendBtn = null
+ _sessionListEl = null
+}
diff --git a/src/pages/chat.js b/src/pages/chat.js
index 53c1e60..e208bbe 100644
--- a/src/pages/chat.js
+++ b/src/pages/chat.js
@@ -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 = [`${formatTime(new Date())}`]
// 计算响应耗时
+ 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(`·⏱ ${durStr}`)
+ // 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(`·${tokenStr}`)
+ }
+ }
+ 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 = `${formatTime(msgTime || new Date())}`
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 = `${formatTime(msgTime || new Date())}`
wrap.appendChild(bubble)
- wrap.appendChild(time)
+ wrap.appendChild(meta)
_messagesEl.insertBefore(wrap, _typingEl)
scrollToBottom()
}
diff --git a/src/pages/setup.js b/src/pages/setup.js
index 8c684e7..b93d58c 100644
--- a/src/pages/setup.js
+++ b/src/pages/setup.js
@@ -149,6 +149,29 @@ function renderSteps(page, { node, cliOk, config }) {
`
+ // AI 助手入口
+ html += `
+
+
+
+ 遇到安装问题?AI 助手可以帮你诊断和解决。配置好模型后,点击下方按钮${!allOk ? ',当前问题会自动发送给 AI 分析' : ''}。
+
+
+
+ ${!allOk ? `
` : ''}
+
+
+ `
+
// 全部就绪 → 进入面板
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'
diff --git a/src/style/assistant.css b/src/style/assistant.css
new file mode 100644
index 0000000..d16ec45
--- /dev/null
+++ b/src/style/assistant.css
@@ -0,0 +1,1522 @@
+/**
+ * AI 助手页面样式
+ */
+
+.ast-page {
+ display: flex;
+ flex-direction: row;
+ height: 100%;
+ overflow: hidden;
+ padding: 0 !important;
+}
+
+/* ── 侧边栏 ── */
+.ast-sidebar {
+ width: 0;
+ min-width: 0;
+ border-right: none;
+ background: var(--bg-primary);
+ display: flex;
+ flex-direction: column;
+ flex-shrink: 0;
+ overflow: hidden;
+ transition: width 0.2s ease, min-width 0.2s ease, border-right 0.2s ease;
+}
+
+.ast-sidebar.open {
+ width: 240px;
+ min-width: 240px;
+ border-right: 1px solid var(--border-primary);
+}
+
+.ast-sidebar-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 12px;
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ border-bottom: 1px solid var(--border-primary);
+}
+
+.ast-sidebar-btn {
+ background: none;
+ border: none;
+ color: var(--text-secondary);
+ cursor: pointer;
+ padding: 4px;
+ border-radius: 4px;
+ display: flex;
+}
+
+.ast-sidebar-btn:hover {
+ background: var(--bg-card-hover);
+ color: var(--text-primary);
+}
+
+.ast-session-list {
+ flex: 1;
+ overflow-y: auto;
+ padding: 4px;
+}
+
+.ast-session-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 10px;
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ transition: background var(--transition-fast);
+ gap: 4px;
+}
+
+.ast-session-item:hover {
+ background: var(--bg-card-hover);
+}
+
+.ast-session-item.active {
+ background: var(--accent-muted);
+ color: var(--accent);
+}
+
+.ast-session-title {
+ flex: 1;
+ font-size: 13px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.ast-session-delete {
+ opacity: 0;
+ background: none;
+ border: none;
+ color: var(--text-tertiary);
+ cursor: pointer;
+ font-size: 16px;
+ padding: 0 4px;
+ border-radius: 4px;
+ flex-shrink: 0;
+}
+
+.ast-session-item:hover .ast-session-delete {
+ opacity: 1;
+}
+
+.ast-session-delete:hover {
+ color: var(--error);
+ background: var(--error-muted);
+}
+
+.ast-empty {
+ text-align: center;
+ color: var(--text-tertiary);
+ font-size: 13px;
+ padding: 20px;
+}
+
+/* ── 主区域 ── */
+.ast-main {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ position: relative;
+}
+
+.ast-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--space-sm) var(--space-md);
+ border-bottom: 1px solid var(--border-primary);
+ flex-shrink: 0;
+ gap: 8px;
+}
+
+.ast-header-left {
+ display: flex;
+ align-items: center;
+ gap: var(--space-sm);
+}
+
+.ast-header-actions {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.ast-toggle-sidebar {
+ background: none;
+ border: none;
+ color: var(--text-secondary);
+ cursor: pointer;
+ padding: 4px;
+ border-radius: var(--radius-sm);
+ display: flex;
+}
+
+.ast-toggle-sidebar:hover {
+ background: var(--bg-card-hover);
+ color: var(--text-primary);
+}
+
+.ast-title {
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.ast-model-badge {
+ font-size: 11px;
+ padding: 2px 8px;
+ border-radius: 10px;
+ font-family: var(--font-mono);
+}
+
+.ast-model-badge.configured {
+ background: var(--success-muted);
+ color: var(--success);
+}
+
+.ast-model-badge.unconfigured {
+ background: var(--warning-muted);
+ color: var(--warning);
+}
+
+/* ── 消息区域 ── */
+.ast-messages {
+ flex: 1;
+ overflow-y: auto;
+ padding: var(--space-lg) var(--space-xl);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-md);
+}
+
+.ast-msg {
+ display: flex;
+ max-width: 85%;
+}
+
+.ast-msg-user {
+ align-self: flex-end;
+}
+
+.ast-msg-ai {
+ align-self: flex-start;
+ flex-direction: column;
+ max-width: 85%;
+}
+
+.ast-msg-bubble {
+ padding: 10px 14px;
+ border-radius: var(--radius-lg);
+ font-size: 14px;
+ line-height: 1.6;
+ word-break: break-word;
+}
+
+.ast-msg-bubble-user {
+ background: var(--accent);
+ color: var(--text-inverse);
+ border-bottom-right-radius: 4px;
+ white-space: pre-wrap;
+}
+
+.ast-msg-bubble-ai {
+ background: var(--bg-card);
+ color: var(--text-primary);
+ border: 1px solid var(--border-primary);
+ border-bottom-left-radius: 4px;
+}
+
+.ast-msg-bubble-ai p {
+ margin: 0 0 8px 0;
+}
+
+.ast-msg-bubble-ai p:last-child {
+ margin-bottom: 0;
+}
+
+.ast-msg-bubble-ai pre {
+ background: var(--bg-tertiary);
+ border-radius: var(--radius-sm);
+ padding: 10px 12px;
+ overflow-x: auto;
+ font-size: 13px;
+ margin: 8px 0;
+}
+
+.ast-msg-bubble-ai code {
+ font-family: var(--font-mono);
+ font-size: 13px;
+}
+
+.ast-msg-bubble-ai code:not(pre code) {
+ background: var(--bg-tertiary);
+ padding: 1px 5px;
+ border-radius: 3px;
+}
+
+.ast-msg-bubble-ai ul, .ast-msg-bubble-ai ol {
+ margin: 4px 0;
+ padding-left: 20px;
+}
+
+.ast-msg-bubble-ai li {
+ margin: 2px 0;
+}
+
+.ast-msg-bubble-ai strong {
+ font-weight: 600;
+}
+
+.ast-msg-bubble-ai h1, .ast-msg-bubble-ai h2, .ast-msg-bubble-ai h3 {
+ margin: 12px 0 6px 0;
+ font-weight: 600;
+}
+
+.ast-msg-bubble-ai h1 { font-size: 18px; }
+.ast-msg-bubble-ai h2 { font-size: 16px; }
+.ast-msg-bubble-ai h3 { font-size: 14px; }
+
+/* 流式光标 */
+.ast-cursor {
+ animation: ast-blink 1s infinite;
+ color: var(--accent);
+}
+
+@keyframes ast-blink {
+ 0%, 50% { opacity: 1; }
+ 51%, 100% { opacity: 0; }
+}
+
+.ast-typing {
+ color: var(--text-tertiary);
+ font-style: italic;
+}
+
+/* ── 输入区 ── */
+.ast-input-area {
+ padding: var(--space-md) var(--space-xl) var(--space-lg);
+ border-top: 1px solid var(--border-primary);
+ flex-shrink: 0;
+}
+
+.ast-input-wrap {
+ display: flex;
+ align-items: flex-end;
+ gap: 8px;
+ background: var(--bg-card);
+ border: 1px solid var(--border-primary);
+ border-radius: var(--radius-lg);
+ padding: 8px 12px;
+ transition: border-color var(--transition-fast);
+}
+
+.ast-input-wrap:focus-within {
+ border-color: var(--border-focus);
+ box-shadow: 0 0 0 3px var(--accent-muted);
+}
+
+.ast-textarea {
+ flex: 1;
+ border: none;
+ background: none;
+ resize: none;
+ font-size: 14px;
+ font-family: var(--font-sans);
+ color: var(--text-primary);
+ line-height: 1.5;
+ outline: none;
+ min-height: 24px;
+ max-height: 200px;
+}
+
+.ast-textarea::placeholder {
+ color: var(--text-tertiary);
+}
+
+.ast-send-btn {
+ background: var(--accent);
+ color: white;
+ border: none;
+ border-radius: var(--radius-sm);
+ width: 36px;
+ height: 36px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ flex-shrink: 0;
+ transition: background var(--transition-fast);
+}
+
+.ast-send-btn:hover {
+ background: var(--accent-hover);
+}
+
+.ast-send-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.ast-input-hint {
+ font-size: 11px;
+ color: var(--text-tertiary);
+ margin-top: 6px;
+ text-align: center;
+}
+
+/* ── 欢迎页 ── */
+.ast-welcome {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ flex: 1;
+ gap: 12px;
+ padding: 40px 20px;
+ text-align: center;
+}
+
+.ast-welcome-icon {
+ color: var(--accent);
+ opacity: 0.7;
+}
+
+.ast-welcome h3 {
+ font-size: 20px;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin: 0;
+}
+
+.ast-welcome p {
+ color: var(--text-secondary);
+ font-size: 14px;
+ margin: 0;
+}
+
+.ast-quick-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 16px;
+ justify-content: center;
+}
+
+.ast-quick-btn {
+ background: var(--bg-card);
+ border: 1px solid var(--border-primary);
+ color: var(--text-secondary);
+ padding: 8px 16px;
+ border-radius: var(--radius-lg);
+ font-size: 13px;
+ cursor: pointer;
+ transition: all var(--transition-fast);
+}
+
+.ast-quick-btn:hover {
+ background: var(--bg-card-hover);
+ color: var(--text-primary);
+ border-color: var(--accent);
+}
+
+/* ── Skills 网格 ── */
+.ast-skills-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 10px;
+ margin-top: 20px;
+ width: 100%;
+ max-width: 560px;
+}
+
+.ast-skill-card {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 14px 16px;
+ background: var(--bg-card);
+ border: 1px solid var(--border-secondary);
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ transition: all var(--transition-fast);
+ text-align: left;
+}
+
+.ast-skill-card:hover {
+ border-color: var(--accent);
+ background: var(--accent-muted);
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
+}
+
+.ast-skill-icon {
+ font-size: 24px;
+ flex-shrink: 0;
+ width: 40px;
+ height: 40px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--bg-tertiary);
+ border-radius: var(--radius-sm);
+}
+
+.ast-skill-card:hover .ast-skill-icon {
+ background: var(--accent-muted);
+}
+
+.ast-skill-info {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+
+.ast-skill-info strong {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.ast-skill-info span {
+ font-size: 11px;
+ color: var(--text-tertiary);
+ line-height: 1.3;
+}
+
+/* ── 设置 Tab ── */
+.ast-settings-tabs {
+ display: flex;
+ gap: 0;
+ border-bottom: 1px solid var(--border-secondary);
+ margin: 8px 0 0;
+}
+
+.ast-tab {
+ padding: 8px 16px;
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--text-tertiary);
+ background: none;
+ border: none;
+ border-bottom: 2px solid transparent;
+ cursor: pointer;
+ transition: all var(--transition-fast);
+}
+
+.ast-tab:hover {
+ color: var(--text-primary);
+}
+
+.ast-tab.active {
+ color: var(--accent);
+ border-bottom-color: var(--accent);
+}
+
+.ast-tab-panel {
+ display: none;
+}
+
+.ast-tab-panel.active {
+ display: block;
+}
+
+/* ── 设置表单 ── */
+.ast-settings-form {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding-top: 4px;
+}
+
+.ast-settings-form .form-group {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.ast-settings-form label {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-secondary);
+}
+
+.ast-settings-form .form-input {
+ padding: 8px 12px;
+ border: 1px solid var(--border-primary);
+ border-radius: var(--radius-sm);
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ font-size: 14px;
+ font-family: var(--font-mono);
+ outline: none;
+ transition: border-color var(--transition-fast);
+}
+
+.ast-settings-form .form-input:focus {
+ border-color: var(--border-focus);
+ box-shadow: 0 0 0 3px var(--accent-muted);
+}
+
+.ast-settings-form .form-hint {
+ font-size: 12px;
+ color: var(--text-tertiary);
+}
+
+/* 模型下拉列表 */
+.ast-model-dropdown {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ right: 0;
+ max-height: 240px;
+ overflow-y: auto;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-primary);
+ border-radius: var(--radius-sm);
+ box-shadow: var(--shadow-md);
+ z-index: 100;
+ margin-top: 4px;
+}
+
+.ast-model-option {
+ padding: 8px 12px;
+ font-size: 13px;
+ font-family: var(--font-mono);
+ cursor: pointer;
+ transition: background var(--transition-fast);
+ border-bottom: 1px solid var(--border-secondary);
+}
+
+.ast-model-option:last-child {
+ border-bottom: none;
+}
+
+.ast-model-option:hover {
+ background: var(--accent-muted);
+ color: var(--accent);
+}
+
+/* ── 模式选择器(高级版) ── */
+.ast-mode-selector {
+ display: flex;
+ gap: 2px;
+ margin-right: 8px;
+ padding: 3px;
+ background: var(--bg-tertiary);
+ border-radius: 10px;
+ position: relative;
+ border: 1px solid var(--border-secondary);
+ backdrop-filter: blur(8px);
+ -webkit-backdrop-filter: blur(8px);
+}
+
+/* 滑动指示器 */
+.ast-mode-slider {
+ position: absolute;
+ top: 3px;
+ height: calc(100% - 6px);
+ border-radius: 8px;
+ background: var(--accent);
+ opacity: 0;
+ transition: left 0.35s cubic-bezier(0.4, 0, 0.2, 1),
+ width 0.35s cubic-bezier(0.4, 0, 0.2, 1),
+ background 0.3s ease,
+ box-shadow 0.3s ease;
+ z-index: 0;
+ pointer-events: none;
+}
+
+.ast-mode-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 5px 12px;
+ font-size: 12px;
+ border: none;
+ border-radius: 8px;
+ background: transparent;
+ color: var(--text-tertiary);
+ cursor: pointer;
+ transition: color 0.25s ease, transform 0.15s ease;
+ white-space: nowrap;
+ line-height: 1.4;
+ position: relative;
+ z-index: 1;
+ letter-spacing: 0.3px;
+}
+
+.ast-mode-btn svg {
+ flex-shrink: 0;
+ transition: transform 0.2s ease;
+}
+
+.ast-mode-btn:hover {
+ color: var(--text-primary);
+}
+
+.ast-mode-btn:hover svg {
+ transform: scale(1.15);
+}
+
+.ast-mode-btn:active {
+ transform: scale(0.95);
+}
+
+.ast-mode-btn.active {
+ color: #fff;
+ font-weight: 600;
+ text-shadow: 0 1px 2px rgba(0,0,0,0.2);
+}
+
+/* 模式配色 — 滑块 + 光晕 */
+.ast-main[data-mode="chat"] .ast-mode-slider {
+ background: linear-gradient(135deg, #6b7280, #4b5563);
+ box-shadow: 0 2px 8px rgba(107,114,128,0.4);
+}
+.ast-main[data-mode="plan"] .ast-mode-slider {
+ background: linear-gradient(135deg, #3b82f6, #2563eb);
+ box-shadow: 0 2px 12px rgba(59,130,246,0.45);
+}
+.ast-main[data-mode="execute"] .ast-mode-slider {
+ background: linear-gradient(135deg, #8b5cf6, #7c3aed);
+ box-shadow: 0 2px 12px rgba(139,92,246,0.45);
+}
+.ast-main[data-mode="unlimited"] .ast-mode-slider {
+ background: linear-gradient(135deg, #f59e0b, #d97706);
+ box-shadow: 0 2px 12px rgba(245,158,11,0.5);
+}
+
+/* 模式环境光 — header 底部辉光 */
+.ast-header {
+ position: relative;
+ overflow: hidden;
+}
+.ast-header::after {
+ content: '';
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ height: 2px;
+ transition: background 0.4s ease, box-shadow 0.4s ease;
+}
+.ast-main[data-mode="chat"] .ast-header::after {
+ background: linear-gradient(90deg, transparent, #6b7280, transparent);
+ box-shadow: 0 0 8px rgba(107,114,128,0.3);
+}
+.ast-main[data-mode="plan"] .ast-header::after {
+ background: linear-gradient(90deg, transparent, #3b82f6, transparent);
+ box-shadow: 0 0 12px rgba(59,130,246,0.4);
+}
+.ast-main[data-mode="execute"] .ast-header::after {
+ background: linear-gradient(90deg, transparent, #8b5cf6, transparent);
+ box-shadow: 0 0 12px rgba(139,92,246,0.4);
+}
+.ast-main[data-mode="unlimited"] .ast-header::after {
+ background: linear-gradient(90deg, transparent, #f59e0b, transparent);
+ box-shadow: 0 0 16px rgba(245,158,11,0.5);
+}
+
+/* 切换脉冲动画 */
+.ast-header.ast-mode-pulse::after {
+ animation: mode-glow-pulse 0.6s ease-out;
+}
+@keyframes mode-glow-pulse {
+ 0% { height: 2px; opacity: 1; }
+ 40% { height: 40px; opacity: 0.6; }
+ 100% { height: 2px; opacity: 1; }
+}
+
+/* 模式简介浮现提示 */
+.ast-mode-toast {
+ position: absolute;
+ top: 52px;
+ left: 50%;
+ transform: translateX(-50%) translateY(-8px);
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 18px;
+ border-radius: 20px;
+ font-size: 13px;
+ pointer-events: none;
+ opacity: 0;
+ transition: opacity 0.3s ease, transform 0.3s ease;
+ z-index: 50;
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ border: 1px solid rgba(255,255,255,0.1);
+ white-space: nowrap;
+}
+.ast-mode-toast.show {
+ opacity: 1;
+ transform: translateX(-50%) translateY(0);
+}
+.ast-mode-toast-label {
+ font-weight: 700;
+ color: #fff;
+}
+.ast-mode-toast-desc {
+ font-size: 11px;
+ color: rgba(255,255,255,0.8);
+}
+.ast-mode-toast.mode-chat {
+ background: rgba(107,114,128,0.85);
+ box-shadow: 0 4px 20px rgba(107,114,128,0.3);
+}
+.ast-mode-toast.mode-plan {
+ background: rgba(59,130,246,0.85);
+ box-shadow: 0 4px 20px rgba(59,130,246,0.3);
+}
+.ast-mode-toast.mode-execute {
+ background: rgba(139,92,246,0.85);
+ box-shadow: 0 4px 20px rgba(139,92,246,0.3);
+}
+.ast-mode-toast.mode-unlimited {
+ background: rgba(245,158,11,0.85);
+ box-shadow: 0 4px 20px rgba(245,158,11,0.3);
+}
+
+/* ── 全屏涟漪扩散 ── */
+.ast-mode-ripple {
+ position: absolute;
+ top: 0; left: 0; right: 0; bottom: 0;
+ z-index: 40;
+ pointer-events: none;
+ overflow: hidden;
+}
+.ast-mode-ripple::after {
+ content: '';
+ position: absolute;
+ left: var(--ripple-x, 50%);
+ top: var(--ripple-y, 0);
+ width: 0;
+ height: 0;
+ border-radius: 50%;
+ background: radial-gradient(circle, var(--ripple-color) 0%, transparent 70%);
+ opacity: 0.18;
+ transform: translate(-50%, -50%);
+ animation: mode-ripple-expand 0.7s cubic-bezier(0.2, 0, 0.2, 1) forwards;
+}
+@keyframes mode-ripple-expand {
+ 0% { width: 0; height: 0; opacity: 0.25; }
+ 100% { width: 200vmax; height: 200vmax; opacity: 0; }
+}
+
+/* ── 粒子爆发 ── */
+.ast-mode-particle {
+ position: absolute;
+ left: var(--px);
+ top: var(--py);
+ width: var(--size);
+ height: var(--size);
+ border-radius: 50%;
+ background: var(--color);
+ box-shadow: 0 0 6px var(--color), 0 0 12px var(--color);
+ pointer-events: none;
+ z-index: 41;
+ opacity: 1;
+ animation: mode-particle-fly var(--duration) var(--delay) cubic-bezier(0.2, 0, 0.3, 1) forwards;
+}
+@keyframes mode-particle-fly {
+ 0% { transform: translate(0, 0) scale(1); opacity: 1; }
+ 70% { opacity: 0.8; }
+ 100% { transform: translate(var(--dx), var(--dy)) scale(0); opacity: 0; }
+}
+
+/* ── 模式环境渐变背景 ── */
+.ast-messages {
+ position: relative;
+}
+.ast-messages::before {
+ content: '';
+ position: absolute;
+ top: 0; left: 0; right: 0;
+ height: 120px;
+ pointer-events: none;
+ z-index: 0;
+ transition: opacity 0.5s ease;
+ opacity: 0;
+}
+.ast-main[data-mode="plan"] .ast-messages::before {
+ background: linear-gradient(180deg, rgba(59,130,246,0.06) 0%, transparent 100%);
+ opacity: 1;
+}
+.ast-main[data-mode="execute"] .ast-messages::before {
+ background: linear-gradient(180deg, rgba(139,92,246,0.06) 0%, transparent 100%);
+ opacity: 1;
+}
+.ast-main[data-mode="unlimited"] .ast-messages::before {
+ background: linear-gradient(180deg, rgba(245,158,11,0.08) 0%, transparent 100%);
+ opacity: 1;
+}
+
+/* ── 无限模式特效:脉冲边框 ── */
+.ast-main[data-mode="unlimited"] {
+ position: relative;
+}
+.ast-main[data-mode="unlimited"]::before {
+ content: '';
+ position: absolute;
+ top: 0; left: 0; right: 0; bottom: 0;
+ border: 1px solid transparent;
+ border-image: linear-gradient(
+ var(--unlimited-angle, 0deg),
+ rgba(245,158,11,0.5),
+ rgba(239,68,68,0.3),
+ rgba(245,158,11,0.5)
+ ) 1;
+ pointer-events: none;
+ z-index: 0;
+ animation: unlimited-border-rotate 3s linear infinite;
+}
+@keyframes unlimited-border-rotate {
+ 0% { --unlimited-angle: 0deg; }
+ 100% { --unlimited-angle: 360deg; }
+}
+@property --unlimited-angle {
+ syntax: '
';
+ inherits: false;
+ initial-value: 0deg;
+}
+
+/* 模式简介 toast 图标 */
+.ast-mode-toast-icon {
+ display: flex;
+ align-items: center;
+}
+.ast-mode-toast-icon svg {
+ width: 16px;
+ height: 16px;
+ stroke: #fff;
+ filter: drop-shadow(0 1px 2px rgba(0,0,0,0.3));
+}
+
+/* 输入区模式色 */
+.ast-main[data-mode="unlimited"] .ast-input-area { border-top-color: rgba(245,158,11,0.3); }
+.ast-main[data-mode="plan"] .ast-input-area { border-top-color: rgba(59,130,246,0.3); }
+.ast-main[data-mode="execute"] .ast-input-area { border-top-color: rgba(139,92,246,0.3); }
+
+/* 工具开关行 */
+.ast-switch-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 0;
+ border-bottom: 1px solid var(--border-secondary);
+ cursor: pointer;
+ font-size: 13px;
+ color: var(--text-primary);
+}
+.ast-switch-row:last-of-type { border-bottom: none; }
+.ast-switch-row input[type="checkbox"] { display: none; }
+
+.ast-switch-track {
+ position: relative;
+ width: 36px;
+ height: 20px;
+ background: var(--bg-tertiary);
+ border-radius: 10px;
+ transition: background var(--transition-fast);
+ flex-shrink: 0;
+}
+.ast-switch-track::after {
+ content: '';
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ width: 16px;
+ height: 16px;
+ background: #fff;
+ border-radius: 50%;
+ transition: transform var(--transition-fast);
+ box-shadow: 0 1px 2px rgba(0,0,0,0.2);
+}
+.ast-switch-row input:checked + .ast-switch-track {
+ background: var(--accent);
+}
+.ast-switch-row input:checked + .ast-switch-track::after {
+ transform: translateX(16px);
+}
+
+/* ask_user 交互卡片 */
+.ast-ask-card {
+ margin: 8px 0;
+ padding: 14px 16px;
+ border: 1px solid var(--accent);
+ border-radius: var(--radius-md);
+ background: var(--bg-secondary);
+ animation: msg-in 0.2s ease-out;
+}
+.ast-ask-card.answered {
+ border-color: var(--border-secondary);
+ opacity: 0.7;
+}
+
+.ast-ask-question {
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--text-primary);
+ margin-bottom: 10px;
+ line-height: 1.5;
+}
+
+.ast-ask-options {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ margin-bottom: 10px;
+}
+
+.ast-ask-option {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 12px;
+ border: 1px solid var(--border-secondary);
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ font-size: 13px;
+ color: var(--text-secondary);
+ transition: all var(--transition-fast);
+}
+.ast-ask-option:hover {
+ border-color: var(--accent);
+ color: var(--text-primary);
+ background: var(--accent-muted);
+}
+.ast-ask-option input {
+ accent-color: var(--accent);
+}
+.ast-ask-option:has(input:checked) {
+ border-color: var(--accent);
+ background: var(--accent-muted);
+ color: var(--accent);
+ font-weight: 500;
+}
+
+.ast-ask-custom {
+ margin-bottom: 10px;
+}
+.ast-ask-custom-input {
+ width: 100%;
+ padding: 8px 12px;
+ border: 1px dashed var(--border-secondary);
+ border-radius: var(--radius-sm);
+ background: transparent;
+ color: var(--text-primary);
+ font-size: 12px;
+ outline: none;
+ transition: border-color var(--transition-fast);
+}
+.ast-ask-custom-input:focus {
+ border-color: var(--accent);
+}
+
+.ast-ask-text {
+ width: 100%;
+ padding: 8px 12px;
+ border: 1px solid var(--border-secondary);
+ border-radius: var(--radius-sm);
+ background: transparent;
+ color: var(--text-primary);
+ font-size: 13px;
+ resize: vertical;
+ outline: none;
+ margin-bottom: 10px;
+ font-family: inherit;
+}
+.ast-ask-text:focus {
+ border-color: var(--accent);
+}
+
+.ast-ask-actions {
+ display: flex;
+ gap: 8px;
+ margin-top: 2px;
+}
+
+.ast-ask-answered {
+ padding: 2px 0;
+}
+.ast-ask-answered .ast-ask-question {
+ margin-bottom: 4px;
+ font-size: 12px;
+ color: var(--text-tertiary);
+}
+.ast-ask-answer {
+ font-size: 13px;
+ color: var(--success);
+ font-weight: 500;
+}
+
+/* 会话状态指示点 */
+.ast-status-dot {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ flex-shrink: 0;
+ margin-right: 2px;
+}
+.ast-status-dot.streaming {
+ background: var(--success);
+ animation: status-pulse 1.2s ease-in-out infinite;
+}
+.ast-status-dot.waiting {
+ background: var(--warning);
+ animation: status-pulse 1.5s ease-in-out infinite;
+}
+.ast-status-dot.error {
+ background: var(--error);
+}
+@keyframes status-pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.3; }
+}
+
+/* 重试栏 */
+.ast-retry-bar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 14px;
+ margin: 8px 0;
+ background: var(--bg-secondary);
+ border: 1px solid var(--error);
+ border-radius: var(--radius-md);
+ animation: msg-in 0.2s ease-out;
+}
+.ast-retry-hint {
+ font-size: 11px;
+ color: var(--text-tertiary);
+ margin-left: auto;
+}
+
+/* 发送队列 */
+.ast-queue {
+ display: none;
+ padding: 0 var(--space-md);
+ flex-shrink: 0;
+}
+.ast-queue-header {
+ font-size: 11px;
+ color: var(--text-tertiary);
+ padding: 4px 0;
+ font-weight: 500;
+}
+.ast-queue-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 10px;
+ margin-bottom: 4px;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-secondary);
+ border-radius: var(--radius-sm);
+ font-size: 12px;
+ animation: msg-in 0.15s ease-out;
+}
+.ast-queue-num {
+ width: 18px;
+ height: 18px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--accent-muted);
+ color: var(--accent);
+ border-radius: 50%;
+ font-size: 10px;
+ font-weight: 600;
+ flex-shrink: 0;
+}
+.ast-queue-text {
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: var(--text-primary);
+}
+.ast-queue-actions {
+ display: flex;
+ gap: 4px;
+ flex-shrink: 0;
+}
+.ast-queue-btn {
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 2px 4px;
+ border-radius: 3px;
+ font-size: 12px;
+ opacity: 0.5;
+ transition: opacity var(--transition-fast), background var(--transition-fast);
+}
+.ast-queue-btn:hover {
+ opacity: 1;
+}
+.ast-queue-btn.send:hover {
+ background: var(--accent-muted);
+}
+.ast-queue-btn.edit:hover {
+ background: var(--accent-muted);
+}
+.ast-queue-btn.delete:hover {
+ background: var(--error-muted);
+ color: var(--error);
+}
+.ast-queue-edit-input {
+ flex: 1;
+ background: var(--bg-primary);
+ border: 1px solid var(--accent);
+ border-radius: var(--radius-sm);
+ color: var(--text-primary);
+ font-size: 12px;
+ padding: 4px 8px;
+ resize: none;
+ outline: none;
+ font-family: inherit;
+}
+.ast-queue-item.editing {
+ border-color: var(--accent);
+}
+.ast-queue-header {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+.ast-queue-header svg {
+ flex-shrink: 0;
+}
+.ast-retry-bar .btn svg {
+ vertical-align: -2px;
+ margin-right: 2px;
+}
+
+/* 工具调用块 */
+.ast-tool-block {
+ margin-bottom: 8px;
+ border: 1px solid var(--border-secondary);
+ border-radius: var(--radius-sm);
+ font-size: 12px;
+ overflow: hidden;
+ width: 100%;
+}
+
+.ast-tool-block.ok {
+ border-left: 3px solid var(--success);
+}
+
+.ast-tool-block.denied {
+ border-left: 3px solid var(--warning);
+}
+
+.ast-tool-block.pending {
+ border-left: 3px solid var(--accent);
+ animation: ast-pulse 1.5s ease-in-out infinite;
+}
+
+@keyframes ast-pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.7; }
+}
+
+.ast-tool-summary {
+ padding: 8px 12px;
+ cursor: pointer;
+ user-select: none;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ background: var(--bg-tertiary);
+ transition: background var(--transition-fast);
+}
+
+.ast-tool-summary:hover {
+ background: var(--bg-secondary);
+}
+
+.ast-tool-summary code {
+ font-size: 11px;
+ background: var(--bg-secondary);
+ padding: 1px 6px;
+ border-radius: 3px;
+ max-width: 300px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.ast-tool-status {
+ margin-left: auto;
+ font-size: 11px;
+ color: var(--text-tertiary);
+ flex-shrink: 0;
+}
+
+.ast-tool-block.ok .ast-tool-status {
+ color: var(--success);
+}
+
+.ast-tool-block.denied .ast-tool-status {
+ color: var(--warning);
+}
+
+.ast-tool-result {
+ margin: 0;
+ padding: 8px 12px;
+ font-size: 11px;
+ font-family: var(--font-mono);
+ line-height: 1.5;
+ max-height: 200px;
+ overflow: auto;
+ background: var(--bg-primary);
+ white-space: pre-wrap;
+ word-break: break-all;
+ border-top: 1px solid var(--border-secondary);
+}
+
+/* ── 图片附件 ── */
+
+/* 附件按钮 */
+.ast-attach-btn {
+ background: none;
+ border: none;
+ color: var(--text-tertiary);
+ cursor: pointer;
+ padding: 4px;
+ border-radius: var(--radius-sm);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ transition: color var(--transition-fast), background var(--transition-fast);
+}
+.ast-attach-btn:hover {
+ color: var(--accent);
+ background: var(--accent-muted);
+}
+
+/* 输入框布局调整 */
+.ast-input-wrap {
+ display: flex;
+ align-items: flex-end;
+ gap: 4px;
+}
+
+/* 图片预览区 */
+.ast-image-preview {
+ display: none;
+ flex-wrap: wrap;
+ gap: 8px;
+ padding: 8px 12px 4px;
+ animation: msg-in 0.15s ease-out;
+}
+
+.ast-img-thumb {
+ position: relative;
+ border-radius: var(--radius-sm);
+ overflow: hidden;
+ border: 2px solid var(--border-secondary);
+ transition: border-color var(--transition-fast);
+ cursor: pointer;
+ animation: msg-in 0.2s ease-out;
+}
+.ast-img-thumb:hover {
+ border-color: var(--accent);
+}
+.ast-img-thumb img {
+ display: block;
+ height: 64px;
+ max-width: 120px;
+ object-fit: cover;
+}
+.ast-img-thumb-del {
+ position: absolute;
+ top: 2px;
+ right: 2px;
+ background: rgba(0,0,0,0.65);
+ color: #fff;
+ border: none;
+ border-radius: 50%;
+ width: 18px;
+ height: 18px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ opacity: 0;
+ transition: opacity var(--transition-fast);
+ padding: 0;
+}
+.ast-img-thumb:hover .ast-img-thumb-del {
+ opacity: 1;
+}
+.ast-img-thumb-del:hover {
+ background: var(--error);
+}
+
+/* 消息中的图片 */
+.ast-msg-images {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-bottom: 6px;
+}
+.ast-msg-img {
+ border-radius: var(--radius-sm);
+ max-height: 240px;
+ object-fit: contain;
+ cursor: pointer;
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
+}
+.ast-msg-img:hover {
+ transform: scale(1.02);
+ box-shadow: 0 4px 16px rgba(0,0,0,0.15);
+}
+.ast-msg-img-placeholder,
+.ast-msg-img-loading {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 12px;
+ background: var(--bg-tertiary);
+ border: 1px dashed var(--border-secondary);
+ border-radius: var(--radius-sm);
+ color: var(--text-tertiary);
+ font-size: 11px;
+}
+.ast-msg-img-placeholder svg,
+.ast-msg-img-loading svg {
+ flex-shrink: 0;
+ opacity: 0.5;
+}
+.ast-msg-img-loading svg {
+ animation: status-pulse 1.2s ease-in-out infinite;
+}
+
+/* 拖拽覆盖层 */
+.ast-main.ast-drag-over {
+ position: relative;
+}
+.ast-main.ast-drag-over::after {
+ content: '释放以添加图片';
+ position: absolute;
+ top: 0; left: 0; right: 0; bottom: 0;
+ background: rgba(var(--accent-rgb, 139,92,246), 0.08);
+ border: 2px dashed var(--accent);
+ border-radius: var(--radius-md);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--accent);
+ z-index: 100;
+ pointer-events: none;
+ backdrop-filter: blur(4px);
+ -webkit-backdrop-filter: blur(4px);
+ animation: msg-in 0.15s ease-out;
+}
+
+/* 右键调试菜单 */
+.ast-ctx-menu {
+ position: fixed;
+ z-index: 99999;
+ min-width: 160px;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-primary);
+ border-radius: var(--radius-sm);
+ box-shadow: 0 8px 24px rgba(0,0,0,0.15);
+ padding: 4px 0;
+ animation: msg-in 0.12s ease-out;
+}
+.ast-ctx-menu button {
+ display: block;
+ width: 100%;
+ padding: 7px 14px;
+ border: none;
+ background: none;
+ color: var(--text-primary);
+ font-size: 13px;
+ text-align: left;
+ cursor: pointer;
+ white-space: nowrap;
+}
+.ast-ctx-menu button:hover {
+ background: var(--accent-muted);
+ color: var(--accent);
+}
+.ast-ctx-menu hr {
+ border: none;
+ border-top: 1px solid var(--border-secondary);
+ margin: 4px 0;
+}
+
+/* 调试弹窗 */
+.ast-debug-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 99999;
+ background: rgba(0,0,0,0.4);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ animation: msg-in 0.15s ease-out;
+}
+.ast-debug-modal {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-primary);
+ border-radius: var(--radius-md);
+ box-shadow: 0 16px 48px rgba(0,0,0,0.2);
+ width: min(640px, 90vw);
+ max-height: 80vh;
+ display: flex;
+ flex-direction: column;
+}
+.ast-debug-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--border-secondary);
+ font-weight: 600;
+ font-size: 14px;
+}
+.ast-debug-close {
+ border: none;
+ background: none;
+ font-size: 20px;
+ color: var(--text-tertiary);
+ cursor: pointer;
+ padding: 0 4px;
+ line-height: 1;
+}
+.ast-debug-close:hover { color: var(--text-primary); }
+.ast-debug-content {
+ flex: 1;
+ overflow: auto;
+ padding: 16px;
+ margin: 0;
+ font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', monospace;
+ font-size: 12px;
+ line-height: 1.6;
+ color: var(--text-secondary);
+ white-space: pre-wrap;
+ word-break: break-all;
+ background: var(--bg-tertiary);
+}
+.ast-debug-actions {
+ padding: 10px 16px;
+ border-top: 1px solid var(--border-secondary);
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+}
diff --git a/src/style/chat.css b/src/style/chat.css
index ff880e9..9ac08d9 100644
--- a/src/style/chat.css
+++ b/src/style/chat.css
@@ -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 {
diff --git a/src/style/components.css b/src/style/components.css
index c40ec7c..5dab6d5 100644
--- a/src/style/components.css
+++ b/src/style/components.css
@@ -160,7 +160,7 @@
/* Toast 通知 */
.toast-container {
position: fixed;
- top: var(--space-lg);
+ top: 56px;
right: var(--space-lg);
z-index: 9999;
display: flex;