mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-10 17:42:49 +08:00
feat(i18n): full i18n for all pages + sidebar lang switcher + zh-TW locale
- All pages now use t() for internationalization - Sidebar footer: searchable upward dropdown language switcher - Generated zh-TW.json (Traditional Chinese) via gen-locales.cjs - CSS for lang switcher with mobile/collapsed sidebar support - Removed language toggle from settings page
This commit is contained in:
258
scripts/gen-locales.cjs
Normal file
258
scripts/gen-locales.cjs
Normal file
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* Generate zh-TW / ja / ko locale files from zh-CN.json and en.json
|
||||
* Run: node scripts/gen-locales.js
|
||||
*/
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const zhCN = JSON.parse(fs.readFileSync(path.join(__dirname, '../src/locales/zh-CN.json'), 'utf8'))
|
||||
const en = JSON.parse(fs.readFileSync(path.join(__dirname, '../src/locales/en.json'), 'utf8'))
|
||||
|
||||
// ========== Simplified → Traditional Chinese mapping ==========
|
||||
// Covers common UI characters; context-dependent chars handled via phrase table first
|
||||
const PHRASE_MAP = {
|
||||
'里面': '裡面', '里程': '裡程', '这里': '這裡', '哪里': '哪裡',
|
||||
'复制': '複製', '恢复': '恢復', '重复': '重複', '回复': '回覆',
|
||||
'头发': '頭髮', '发送': '發送', '发现': '發現', '发布': '發佈', '发起': '發起', '发生': '發生', '发出': '發出', '触发': '觸發',
|
||||
'信息': '資訊', '系统': '系統', '干预': '干預',
|
||||
'面板': '面板', '仪表盘': '儀表盤', '仪表板': '儀表板',
|
||||
'只读': '唯讀',
|
||||
'默认': '預設', '并发': '並行', '并且': '並且',
|
||||
'采集': '採集', '采用': '採用',
|
||||
'准确': '準確', '准备': '準備', '标准': '標準',
|
||||
'获取': '取得', '获得': '取得',
|
||||
'运行': '執行', '运营': '營運', '运输': '運輸',
|
||||
'模拟': '模擬', '类型': '類型',
|
||||
'历史': '歷史', '日历': '日曆',
|
||||
'存储': '儲存', '内存': '記憶體',
|
||||
'鼠标': '滑鼠', '光标': '游標',
|
||||
'网络': '網路', '联网': '聯網',
|
||||
'视频': '影片', '音频': '音訊',
|
||||
'软件': '軟體', '硬件': '硬體',
|
||||
'程序': '程式', '应用程序': '應用程式',
|
||||
'文件': '檔案', '文件夹': '資料夾',
|
||||
'数据': '資料', '数据库': '資料庫',
|
||||
'用户': '使用者', '客户端': '用戶端',
|
||||
'服务器': '伺服器', '服务端': '伺服端',
|
||||
'日志': '日誌', '博客': '部落格',
|
||||
'功能': '功能', '智能': '智慧',
|
||||
'支持': '支援', '支持的': '支援的',
|
||||
'响应': '回應',
|
||||
'优先': '優先', '优化': '最佳化',
|
||||
'后台': '後台', '后端': '後端', '后续': '後續', '之后': '之後', '然后': '然後', '最后': '最後',
|
||||
'前台': '前台', '前端': '前端',
|
||||
'拖拽': '拖曳', '链接': '連結',
|
||||
'字节': '位元組',
|
||||
'适配': '適配', '兼容': '相容',
|
||||
'注册': '註冊', '登录': '登入', '退出登录': '登出',
|
||||
'下载': '下載', '上传': '上傳',
|
||||
'启动': '啟動', '激活': '啟用',
|
||||
'调试': '除錯', '调用': '呼叫',
|
||||
'确认': '確認', '确定': '確定',
|
||||
'扩展': '擴充', '插件': '外掛',
|
||||
'渠道': '頻道', '频道': '頻道',
|
||||
'任务': '任務', '计划': '計畫', '定时': '定時',
|
||||
'签名': '簽章', '令牌': '權杖',
|
||||
'权限': '權限', '授权': '授權',
|
||||
'工具': '工具', '终端': '終端',
|
||||
'实例': '執行個體', '进程': '處理程序',
|
||||
'消息': '訊息', '通知': '通知',
|
||||
'正在': '正在', '成功': '成功', '失败': '失敗',
|
||||
'警告': '警告', '错误': '錯誤', '异常': '異常',
|
||||
'加载': '載入', '刷新': '重新整理',
|
||||
'编辑': '編輯', '删除': '刪除', '创建': '建立',
|
||||
'修改': '修改', '更新': '更新', '添加': '新增',
|
||||
'搜索': '搜尋', '查找': '尋找', '过滤': '篩選', '筛选': '篩選',
|
||||
'排序': '排序', '升序': '升冪', '降序': '降冪',
|
||||
'备份': '備份', '还原': '還原',
|
||||
'配置': '設定', '设置': '設定', '选项': '選項',
|
||||
'参数': '參數', '属性': '屬性',
|
||||
'代理': '代理', '网关': '閘道器',
|
||||
'密码': '密碼', '密钥': '金鑰',
|
||||
'地址': '位址', '端口': '連接埠',
|
||||
'状态': '狀態', '在线': '線上', '离线': '離線',
|
||||
'连接': '連線', '断开': '斷開', '超时': '逾時',
|
||||
'请求': '請求', '接口': '介面',
|
||||
'缓存': '快取', '队列': '佇列',
|
||||
'全局': '全域', '局部': '區域',
|
||||
'模型': '模型', '会话': '對話', '对话': '對話',
|
||||
'提示词': '提示詞', '提示': '提示',
|
||||
'回答': '回答', '问题': '問題',
|
||||
'输入': '輸入', '输出': '輸出',
|
||||
'复选': '核取', '单选': '單選', '勾选': '勾選',
|
||||
'折叠': '摺疊', '展开': '展開',
|
||||
'上限': '上限', '下限': '下限',
|
||||
'升级': '升級', '降级': '降級',
|
||||
'安装': '安裝', '卸载': '解除安裝',
|
||||
'绑定': '綁定', '解绑': '解除綁定',
|
||||
'拦截': '攔截', '转发': '轉發',
|
||||
'遥测': '遙測', '监控': '監控',
|
||||
'格式': '格式', '模板': '範本',
|
||||
'变量': '變數', '常量': '常數',
|
||||
'关闭': '關閉', '打开': '開啟',
|
||||
'隐藏': '隱藏', '显示': '顯示',
|
||||
'禁用': '停用', '启用': '啟用',
|
||||
'允许': '允許', '拒绝': '拒絕',
|
||||
'取消': '取消', '保存': '儲存',
|
||||
'重试': '重試', '跳过': '略過',
|
||||
'已知': '已知', '未知': '未知',
|
||||
'手动': '手動', '自动': '自動',
|
||||
'已完成': '已完成', '进行中': '進行中', '待处理': '待處理',
|
||||
'暂无': '暫無', '暂停': '暫停',
|
||||
'粘贴': '貼上', '剪切': '剪下',
|
||||
'选择': '選擇', '当前': '目前',
|
||||
'已选': '已選', '全选': '全選',
|
||||
'最近': '最近', '最新': '最新',
|
||||
'详情': '詳情', '详细': '詳細',
|
||||
'简介': '簡介', '描述': '描述',
|
||||
'名称': '名稱', '标题': '標題', '标签': '標籤',
|
||||
'注意': '注意', '说明': '說明',
|
||||
'总量': '總量', '总计': '總計',
|
||||
'费用': '費用', '价格': '價格',
|
||||
'文档': '文件',
|
||||
'记忆': '記憶', '记录': '紀錄',
|
||||
'独立': '獨立', '共享': '共用',
|
||||
'工作区': '工作區',
|
||||
'无限制': '無限制',
|
||||
'已停止': '已停止',
|
||||
'运行中': '執行中',
|
||||
'挂载': '掛載', '卸载': '卸載',
|
||||
}
|
||||
|
||||
// Single character fallback mapping (only applied after phrase mapping)
|
||||
const CHAR_MAP = {
|
||||
'与': '與', '万': '萬', '专': '專', '业': '業', '丢': '丟', '两': '兩',
|
||||
'严': '嚴', '个': '個', '丰': '豐', '临': '臨', '为': '為', '举': '舉',
|
||||
'义': '義', '乐': '樂', '习': '習', '书': '書', '买': '買', '乱': '亂',
|
||||
'争': '爭', '于': '於', '亏': '虧', '云': '雲', '亚': '亞', '产': '產',
|
||||
'亲': '親', '仅': '僅', '从': '從', '仓': '倉', '们': '們', '价': '價',
|
||||
'众': '眾', '优': '優', '会': '會', '伟': '偉', '传': '傳', '伤': '傷',
|
||||
'体': '體', '佣': '傭', '余': '餘', '侠': '俠', '侧': '側', '侦': '偵',
|
||||
'债': '債', '值': '值', '偿': '償', '像': '像', '允': '允', '兆': '兆',
|
||||
'党': '黨', '兰': '蘭', '关': '關', '兴': '興', '养': '養', '内': '內',
|
||||
'冲': '沖', '决': '決', '况': '況', '净': '淨', '准': '準', '凑': '湊',
|
||||
'减': '減', '几': '幾', '凭': '憑', '出': '出', '击': '擊', '划': '劃',
|
||||
'创': '創', '别': '別', '刘': '劉', '则': '則', '刚': '剛', '剂': '劑',
|
||||
'剧': '劇', '剩': '剩', '劝': '勸', '办': '辦', '动': '動', '务': '務',
|
||||
'劳': '勞', '势': '勢', '勋': '勳', '匀': '勻', '区': '區', '医': '醫',
|
||||
'华': '華', '单': '單', '卖': '賣', '占': '佔', '卫': '衛', '厂': '廠',
|
||||
'厅': '廳', '历': '歷', '压': '壓', '厉': '厲', '县': '縣', '参': '參',
|
||||
'双': '雙', '发': '發', '变': '變', '叙': '敘', '叶': '葉', '号': '號',
|
||||
'叹': '嘆', '吓': '嚇', '吕': '呂', '听': '聽', '启': '啟', '呐': '吶',
|
||||
'员': '員', '响': '響', '哑': '啞', '唤': '喚', '啬': '嗇', '团': '團',
|
||||
'园': '園', '围': '圍', '国': '國', '图': '圖', '圣': '聖', '场': '場',
|
||||
'块': '塊', '坏': '壞', '坚': '堅', '坛': '壇', '垒': '壘', '垄': '壟',
|
||||
'型': '型', '域': '域', '堕': '墮', '塑': '塑', '墙': '牆', '壮': '壯',
|
||||
'声': '聲', '处': '處', '备': '備', '够': '夠', '头': '頭', '夹': '夾',
|
||||
'夺': '奪', '奋': '奮', '奖': '獎', '奥': '奧', '妇': '婦', '妈': '媽',
|
||||
'娱': '娛', '孙': '孫', '学': '學', '宝': '寶', '实': '實', '宠': '寵',
|
||||
'审': '審', '宪': '憲', '宽': '寬', '将': '將', '尔': '爾', '尘': '塵',
|
||||
'层': '層', '岁': '歲', '岂': '豈', '岛': '島', '岭': '嶺', '岸': '岸',
|
||||
'币': '幣', '帅': '帥', '师': '師', '帐': '帳', '帜': '幟', '带': '帶',
|
||||
'帮': '幫', '干': '乾', '并': '並', '广': '廣', '庄': '莊', '庆': '慶',
|
||||
'库': '庫', '应': '應', '废': '廢', '开': '開', '异': '異', '弃': '棄',
|
||||
'张': '張', '弹': '彈', '归': '歸', '当': '當', '录': '錄', '彻': '徹',
|
||||
'径': '徑', '态': '態', '怀': '懷', '总': '總', '恶': '惡', '恼': '惱',
|
||||
'悬': '懸', '惊': '驚', '惧': '懼', '惩': '懲', '惯': '慣', '愤': '憤',
|
||||
'慎': '慎', '懒': '懶', '戏': '戲', '战': '戰', '户': '戶', '执': '執',
|
||||
'扩': '擴', '扫': '掃', '扬': '揚', '扰': '擾', '抚': '撫', '抛': '拋',
|
||||
'担': '擔', '拟': '擬', '拥': '擁', '择': '擇', '挂': '掛', '挡': '擋',
|
||||
'挤': '擠', '挥': '揮', '损': '損', '换': '換', '据': '據', '掷': '擲',
|
||||
'描': '描', '摄': '攝', '摆': '擺', '摇': '搖', '操': '操', '撑': '撐',
|
||||
'撤': '撤', '播': '播', '擅': '擅', '数': '數', '整': '整', '斗': '鬥',
|
||||
'斩': '斬', '断': '斷', '无': '無', '旧': '舊', '时': '時', '昼': '晝',
|
||||
'显': '顯', '晋': '晉', '晒': '曬', '术': '術', '机': '機', '权': '權',
|
||||
'杀': '殺', '杂': '雜', '条': '條', '来': '來', '极': '極', '构': '構',
|
||||
'柜': '櫃', '标': '標', '栈': '棧', '样': '樣', '检': '檢', '楼': '樓',
|
||||
'榄': '欖', '横': '橫', '档': '檔', '桥': '橋', '梦': '夢', '毁': '毀',
|
||||
'殇': '殤', '残': '殘', '毕': '畢', '汇': '匯', '汉': '漢', '污': '汙',
|
||||
'汤': '湯', '沟': '溝', '没': '沒', '泪': '淚', '浅': '淺', '测': '測',
|
||||
'济': '濟', '浏': '瀏', '涌': '湧', '涛': '濤', '润': '潤', '涨': '漲',
|
||||
'淀': '澱', '渐': '漸', '渠': '渠', '温': '溫', '湾': '灣', '滞': '滯',
|
||||
'满': '滿', '滤': '濾', '潜': '潛', '灭': '滅', '灵': '靈', '灿': '燦',
|
||||
'烂': '爛', '烛': '燭', '烦': '煩', '烧': '燒', '热': '熱', '犹': '猶',
|
||||
'独': '獨', '狭': '狹', '猎': '獵', '猪': '豬', '献': '獻', '玩': '玩',
|
||||
'环': '環', '现': '現', '珍': '珍', '瓶': '瓶', '电': '電', '画': '畫',
|
||||
'畅': '暢', '疗': '療', '症': '症', '盘': '盤', '盖': '蓋', '监': '監',
|
||||
'盐': '鹽', '目': '目', '码': '碼', '础': '礎', '确': '確', '碍': '礙',
|
||||
'禅': '禪', '离': '離', '种': '種', '积': '積', '称': '稱', '稳': '穩',
|
||||
'穷': '窮', '窃': '竊', '窜': '竄', '窝': '窩', '竞': '競', '笔': '筆',
|
||||
'签': '簽', '简': '簡', '算': '算', '类': '類', '粮': '糧', '紧': '緊',
|
||||
'纠': '糾', '纤': '纖', '红': '紅', '纯': '純', '纲': '綱', '纳': '納',
|
||||
'纵': '縱', '纷': '紛', '纸': '紙', '线': '線', '组': '組', '细': '細',
|
||||
'织': '織', '终': '終', '绍': '紹', '经': '經', '结': '結', '绑': '綁',
|
||||
'绕': '繞', '统': '統', '继': '繼', '绩': '績', '绪': '緒', '续': '續',
|
||||
'综': '綜', '缓': '緩', '编': '編', '缘': '緣', '缝': '縫', '缩': '縮',
|
||||
'缴': '繳', '网': '網', '罗': '羅', '罚': '罰', '翻': '翻', '耻': '恥',
|
||||
'联': '聯', '肃': '肅', '肤': '膚', '胀': '脹', '胁': '脅', '脉': '脈',
|
||||
'脑': '腦', '脸': '臉', '腊': '臘', '舆': '輿', '舰': '艦', '艰': '艱',
|
||||
'节': '節', '芦': '蘆', '苹': '蘋', '范': '範', '荐': '薦', '药': '藥',
|
||||
'获': '獲', '虑': '慮', '虚': '虛', '虽': '雖', '蚕': '蠶', '蛮': '蠻',
|
||||
'蝇': '蠅', '补': '補', '衬': '襯', '袜': '襪', '装': '裝', '规': '規',
|
||||
'觉': '覺', '览': '覽', '观': '觀', '角': '角', '解': '解', '触': '觸',
|
||||
'言': '言', '计': '計', '订': '訂', '认': '認', '讨': '討', '让': '讓',
|
||||
'议': '議', '讯': '訊', '记': '記', '讲': '講', '许': '許', '论': '論',
|
||||
'设': '設', '访': '訪', '证': '證', '评': '評', '识': '識', '词': '詞',
|
||||
'试': '試', '话': '話', '该': '該', '详': '詳', '语': '語', '误': '誤',
|
||||
'说': '說', '请': '請', '诸': '諸', '读': '讀', '课': '課', '调': '調',
|
||||
'谁': '誰', '谈': '談', '谢': '謝', '谱': '譜', '贝': '貝', '负': '負',
|
||||
'贡': '貢', '财': '財', '责': '責', '质': '質', '贴': '貼', '费': '費',
|
||||
'资': '資', '赋': '賦', '赏': '賞', '赛': '賽', '赞': '贊', '趋': '趨',
|
||||
'跃': '躍', '践': '踐', '转': '轉', '轨': '軌', '载': '載', '较': '較',
|
||||
'辅': '輔', '辑': '輯', '输': '輸', '辩': '辯', '边': '邊', '达': '達',
|
||||
'过': '過', '迁': '遷', '运': '運', '进': '進', '远': '遠', '违': '違',
|
||||
'连': '連', '迟': '遲', '适': '適', '选': '選', '逻': '邏', '遗': '遺',
|
||||
'邮': '郵', '郑': '鄭', '释': '釋', '钉': '釘', '钟': '鐘', '钥': '鑰',
|
||||
'钮': '鈕', '链': '鏈', '锁': '鎖', '锐': '銳', '错': '錯', '键': '鍵',
|
||||
'锻': '鍛', '镇': '鎮', '镜': '鏡', '长': '長', '门': '門', '闭': '閉',
|
||||
'问': '問', '闲': '閒', '间': '間', '阅': '閱', '阶': '階', '际': '際',
|
||||
'陆': '陸', '险': '險', '随': '隨', '隐': '隱', '障': '障', '难': '難',
|
||||
'集': '集', '雾': '霧', '需': '需', '静': '靜', '页': '頁', '顶': '頂',
|
||||
'项': '項', '须': '須', '预': '預', '频': '頻', '题': '題', '颜': '顏',
|
||||
'风': '風', '飞': '飛', '驱': '驅', '验': '驗', '骤': '驟', '鸡': '雞',
|
||||
'鸣': '鳴', '鹏': '鵬', '龄': '齡', '龙': '龍',
|
||||
}
|
||||
|
||||
function s2t(text) {
|
||||
if (typeof text !== 'string') return text
|
||||
// Apply phrase mapping first (longer matches first)
|
||||
const phrases = Object.keys(PHRASE_MAP).sort((a, b) => b.length - a.length)
|
||||
let result = text
|
||||
for (const phrase of phrases) {
|
||||
result = result.split(phrase).join(PHRASE_MAP[phrase])
|
||||
}
|
||||
// Then apply single character mapping
|
||||
let out = ''
|
||||
for (const ch of result) {
|
||||
out += CHAR_MAP[ch] || ch
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function transformValues(obj, fn) {
|
||||
const result = {}
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (typeof v === 'string') {
|
||||
result[k] = fn(v)
|
||||
} else if (typeof v === 'object' && v !== null) {
|
||||
result[k] = transformValues(v, fn)
|
||||
} else {
|
||||
result[k] = v
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Generate zh-TW
|
||||
const zhTW = transformValues(zhCN, s2t)
|
||||
fs.writeFileSync(
|
||||
path.join(__dirname, '../src/locales/zh-TW.json'),
|
||||
JSON.stringify(zhTW, null, 2) + '\n',
|
||||
'utf8'
|
||||
)
|
||||
console.log('✓ zh-TW.json generated')
|
||||
|
||||
// Verify
|
||||
const parsed = JSON.parse(fs.readFileSync(path.join(__dirname, '../src/locales/zh-TW.json'), 'utf8'))
|
||||
function countKeys(o) { let n = 0; for (const v of Object.values(o)) { if (typeof v === 'string') n++; else if (typeof v === 'object') n += countKeys(v) } return n }
|
||||
console.log(` ${countKeys(parsed)} keys`)
|
||||
@@ -797,11 +797,11 @@ fn clean_cli_output(text: &str) -> String {
|
||||
let start_idx = lines.iter().position(|l| !l.trim().is_empty()).unwrap_or(0);
|
||||
let relevant_lines = &lines[start_idx..];
|
||||
|
||||
// 6. Find the first line that starts JSON (fast path)
|
||||
for line in relevant_lines {
|
||||
// 6. Find the first line that starts JSON and return from there to end
|
||||
for (i, line) in relevant_lines.iter().enumerate() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with('{') || trimmed.starts_with('[') {
|
||||
return trimmed.to_string();
|
||||
return relevant_lines[i..].join("\n");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { isOpenclawReady, getActiveInstance, switchInstance, onInstanceChange }
|
||||
import { api } from '../lib/tauri-api.js'
|
||||
import { toast } from './toast.js'
|
||||
import { version as APP_VERSION } from '../../package.json'
|
||||
import { t } from '../lib/i18n.js'
|
||||
import { t, getLang, setLang, getAvailableLangs } from '../lib/i18n.js'
|
||||
|
||||
function NAV_ITEMS_FULL() { return [
|
||||
{
|
||||
@@ -176,12 +176,37 @@ export function renderSidebar(el) {
|
||||
const sunIcon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>'
|
||||
const moonIcon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/></svg>'
|
||||
|
||||
const langCode = getLang()
|
||||
const langs = getAvailableLangs()
|
||||
const currentLang = langs.find(l => l.code === langCode) || langs[0]
|
||||
const globeIcon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/></svg>'
|
||||
const checkIcon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" width="14" height="14"><polyline points="20 6 9 17 4 12"/></svg>'
|
||||
|
||||
const langOptions = langs.map(l => `
|
||||
<div class="lang-option${l.code === langCode ? ' active' : ''}" data-lang="${l.code}">
|
||||
<span class="lang-option-label">${l.label}</span>
|
||||
<span class="lang-option-code">${l.code}</span>
|
||||
${l.code === langCode ? `<span class="lang-option-check">${checkIcon}</span>` : ''}
|
||||
</div>
|
||||
`).join('')
|
||||
|
||||
html += `
|
||||
<div class="sidebar-footer">
|
||||
<div class="nav-item" id="btn-theme-toggle">
|
||||
${isDark ? sunIcon : moonIcon}
|
||||
<span>${isDark ? t('sidebar.themeLight') : t('sidebar.themeDark')}</span>
|
||||
</div>
|
||||
<div class="lang-switcher" id="lang-switcher">
|
||||
<button class="nav-item lang-trigger" id="btn-lang-toggle">
|
||||
${globeIcon}
|
||||
<span>${currentLang.label}</span>
|
||||
<svg class="lang-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12"><path d="M18 15l-6-6-6 6"/></svg>
|
||||
</button>
|
||||
<div class="lang-dropdown" id="lang-dropdown">
|
||||
${langs.length > 4 ? '<div class="lang-search-wrap"><input class="lang-search" id="lang-search" type="text" placeholder="Search..." autocomplete="off"></div>' : ''}
|
||||
<div class="lang-options" id="lang-options">${langOptions}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-meta">
|
||||
<a href="https://claw.qt.cool" target="_blank" rel="noopener" class="sidebar-link">claw.qt.cool</a>
|
||||
<span class="sidebar-version">v${APP_VERSION}</span>
|
||||
@@ -226,6 +251,25 @@ export function renderSidebar(el) {
|
||||
toggleTheme(() => renderSidebar(el))
|
||||
return
|
||||
}
|
||||
// 语言切换器:打开/关闭下拉
|
||||
const langBtn = e.target.closest('#btn-lang-toggle')
|
||||
if (langBtn) {
|
||||
_toggleLangDropdown(el)
|
||||
return
|
||||
}
|
||||
// 语言选项点击
|
||||
const langOpt = e.target.closest('.lang-option[data-lang]')
|
||||
if (langOpt) {
|
||||
const code = langOpt.dataset.lang
|
||||
if (code !== getLang()) {
|
||||
setLang(code)
|
||||
renderSidebar(el)
|
||||
reloadCurrentRoute()
|
||||
} else {
|
||||
_closeLangDropdown()
|
||||
}
|
||||
return
|
||||
}
|
||||
// 实例切换器
|
||||
const toggleBtn = e.target.closest('#btn-instance-toggle')
|
||||
if (toggleBtn) {
|
||||
@@ -260,6 +304,9 @@ export function renderSidebar(el) {
|
||||
if (!e.target.closest('.instance-switcher')) {
|
||||
_closeInstanceDropdown()
|
||||
}
|
||||
if (!e.target.closest('.lang-switcher')) {
|
||||
_closeLangDropdown()
|
||||
}
|
||||
})
|
||||
|
||||
// 监听实例变化,刷新多实例标记后重新渲染
|
||||
@@ -292,6 +339,40 @@ export function openMobileSidebar() {
|
||||
requestAnimationFrame(() => overlay.classList.add('visible'))
|
||||
}
|
||||
|
||||
function _closeLangDropdown() {
|
||||
const sw = document.getElementById('lang-switcher')
|
||||
const dd = document.getElementById('lang-dropdown')
|
||||
if (dd) dd.classList.remove('open')
|
||||
if (sw) sw.classList.remove('open')
|
||||
}
|
||||
|
||||
function _toggleLangDropdown(sidebarEl) {
|
||||
const sw = document.getElementById('lang-switcher')
|
||||
const dd = document.getElementById('lang-dropdown')
|
||||
if (!dd) return
|
||||
if (dd.classList.contains('open')) { dd.classList.remove('open'); if (sw) sw.classList.remove('open'); return }
|
||||
dd.classList.add('open')
|
||||
if (sw) sw.classList.add('open')
|
||||
const searchInput = dd.querySelector('#lang-search')
|
||||
if (searchInput) {
|
||||
searchInput.value = ''
|
||||
_filterLangOptions('')
|
||||
requestAnimationFrame(() => searchInput.focus())
|
||||
searchInput.oninput = () => _filterLangOptions(searchInput.value)
|
||||
}
|
||||
}
|
||||
|
||||
function _filterLangOptions(query) {
|
||||
const opts = document.querySelectorAll('#lang-options .lang-option')
|
||||
const q = query.toLowerCase().trim()
|
||||
opts.forEach(opt => {
|
||||
if (!q) { opt.style.display = ''; return }
|
||||
const label = (opt.querySelector('.lang-option-label')?.textContent || '').toLowerCase()
|
||||
const code = (opt.querySelector('.lang-option-code')?.textContent || '').toLowerCase()
|
||||
opt.style.display = (label.includes(q) || code.includes(q)) ? '' : 'none'
|
||||
})
|
||||
}
|
||||
|
||||
function _closeInstanceDropdown() {
|
||||
const dd = document.getElementById('instance-dropdown')
|
||||
if (dd) dd.classList.remove('open')
|
||||
|
||||
1930
src/locales/en.json
1930
src/locales/en.json
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
2096
src/locales/zh-TW.json
Normal file
2096
src/locales/zh-TW.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ import { toast } from '../components/toast.js'
|
||||
import { showUpgradeModal, showConfirm } from '../components/modal.js'
|
||||
import { setUpgrading } from '../lib/app-state.js'
|
||||
import { icon, statusIcon } from '../lib/icons.js'
|
||||
import { t } from '../lib/i18n.js'
|
||||
|
||||
export async function render() {
|
||||
const page = document.createElement('div')
|
||||
@@ -17,7 +18,7 @@ export async function render() {
|
||||
<img src="/images/logo-brand.png" alt="ClawPanel" style="height:48px;width:auto">
|
||||
<div>
|
||||
<h1 class="page-title" style="margin:0">ClawPanel</h1>
|
||||
<p class="page-desc" style="margin:0">OpenClaw 可视化管理面板 · <a href="https://claw.qt.cool" target="_blank" rel="noopener" style="color:var(--primary)">claw.qt.cool</a></p>
|
||||
<p class="page-desc" style="margin:0">${t('about.subtitle')} · <a href="https://claw.qt.cool" target="_blank" rel="noopener" style="color:var(--primary)">claw.qt.cool</a></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-cards" id="version-cards">
|
||||
@@ -26,28 +27,28 @@ export async function render() {
|
||||
<div class="stat-card loading-placeholder"></div>
|
||||
</div>
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">社群交流</div>
|
||||
<div class="config-section-title">${t('about.sectionCommunity')}</div>
|
||||
<div id="community-section"></div>
|
||||
</div>
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">相关项目</div>
|
||||
<div class="config-section-title">${t('about.sectionProjects')}</div>
|
||||
<div id="projects-list"></div>
|
||||
</div>
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">参与贡献</div>
|
||||
<div class="config-section-title">${t('about.sectionContribute')}</div>
|
||||
<div id="contribute-section"></div>
|
||||
</div>
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">快捷链接</div>
|
||||
<div class="config-section-title">${t('about.sectionLinks')}</div>
|
||||
<div id="links-list"></div>
|
||||
</div>
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">关于我们</div>
|
||||
<div class="config-section-title">${t('about.sectionAboutUs')}</div>
|
||||
<div id="company-section"></div>
|
||||
</div>
|
||||
<div class="config-section" style="color:var(--text-tertiary);font-size:var(--font-size-xs)">
|
||||
<p>ClawPanel 基于 Tauri v2 构建,前端 Vanilla JS + Vite,后端 Rust。</p>
|
||||
<p style="margin-top:8px">MIT License © 2026 武汉晴辰天下网络科技有限公司</p>
|
||||
<p>${t('about.techStack')}</p>
|
||||
<p style="margin-top:8px">${t('about.copyright')}</p>
|
||||
</div>
|
||||
`
|
||||
|
||||
@@ -78,18 +79,18 @@ async function loadData(page) {
|
||||
}
|
||||
|
||||
// 异步检查前端热更新
|
||||
let panelUpdateHtml = '<span style="color:var(--text-tertiary)">检查更新中...</span>'
|
||||
let panelUpdateHtml = `<span style="color:var(--text-tertiary)">${t('about.checkingUpdate')}</span>`
|
||||
checkHotUpdate(cards, panelVersion)
|
||||
|
||||
const isInstalled = !!version.current
|
||||
const sourceLabel = version.source === 'official' ? '官方版' : '汉化版'
|
||||
const sourceLabel = version.source === 'official' ? t('about.official') : t('about.chinese')
|
||||
const btnSm = 'padding:2px 8px;font-size:var(--font-size-xs)'
|
||||
const hasRecommended = !!version.recommended
|
||||
const aheadOfRecommended = isInstalled && hasRecommended && !!version.ahead_of_recommended
|
||||
const driftFromRecommended = isInstalled && hasRecommended && !version.is_recommended && !aheadOfRecommended
|
||||
const policyRiskHint = aheadOfRecommended
|
||||
? `检测到你本地安装的是高于推荐稳定版的 ${version.current},可能存在接口、事件或配置兼容性问题。建议回退到 ${version.recommended};如果你要继续使用高版本,请自行验证兼容性并关注 issue / release。`
|
||||
: '当前面板默认只保证推荐稳定版的兼容性;如果你要尝试其他版本或预览版,请自行验证兼容性。若希望面板尽快支持最新版特性,欢迎提交 issue 告诉我们。'
|
||||
? t('about.policyAhead', { current: version.current, recommended: version.recommended })
|
||||
: t('about.policyDefault')
|
||||
|
||||
cards.innerHTML = `
|
||||
<div class="stat-card">
|
||||
@@ -99,37 +100,37 @@ async function loadData(page) {
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-header"><span class="stat-card-label">OpenClaw · ${sourceLabel}</span></div>
|
||||
<div class="stat-card-value">${version.current || '未安装'}</div>
|
||||
<div class="stat-card-value">${version.current || t('about.notInstalled')}</div>
|
||||
<div class="stat-card-meta" style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||||
${isInstalled && hasRecommended
|
||||
? (aheadOfRecommended
|
||||
? `<span style="color:var(--warning,#f59e0b)">当前版本高于推荐稳定版: ${version.recommended}</span>
|
||||
<button class="btn btn-primary btn-sm" id="btn-apply-recommended" style="${btnSm}">回退到推荐版</button>`
|
||||
? `<span style="color:var(--warning,#f59e0b)">${t('about.aheadOfRecommended', { ver: version.recommended })}</span>
|
||||
<button class="btn btn-primary btn-sm" id="btn-apply-recommended" style="${btnSm}">${t('about.rollbackToRecommended')}</button>`
|
||||
: driftFromRecommended
|
||||
? `<span style="color:var(--accent)">推荐稳定版: ${version.recommended}</span>
|
||||
<button class="btn btn-primary btn-sm" id="btn-apply-recommended" style="${btnSm}">切换到推荐版</button>`
|
||||
: '<span style="color:var(--success)">已是推荐稳定版</span>')
|
||||
? `<span style="color:var(--accent)">${t('about.recommendedStable', { ver: version.recommended })}</span>
|
||||
<button class="btn btn-primary btn-sm" id="btn-apply-recommended" style="${btnSm}">${t('about.switchToRecommended')}</button>`
|
||||
: `<span style="color:var(--success)">${t('about.isRecommended')}</span>`)
|
||||
: ''}
|
||||
${version.latest_update_available && version.latest ? `<span style="color:var(--text-tertiary)">最新上游: ${version.latest}</span>` : ''}
|
||||
${version.latest_update_available && version.latest ? `<span style="color:var(--text-tertiary)">${t('about.latestUpstream', { ver: version.latest })}</span>` : ''}
|
||||
<button class="btn btn-${isInstalled ? 'secondary' : 'primary'} btn-sm" id="btn-version-mgmt" style="${btnSm}">
|
||||
${isInstalled ? '切换版本' : '安装 OpenClaw'}
|
||||
${isInstalled ? t('about.switchVersion') : t('about.installOpenclaw')}
|
||||
</button>
|
||||
${isInstalled ? `<button class="btn btn-secondary btn-sm" id="btn-uninstall" style="${btnSm};color:var(--error)">卸载</button>` : ''}
|
||||
${isInstalled ? `<button class="btn btn-secondary btn-sm" id="btn-uninstall" style="${btnSm};color:var(--error)">${t('about.uninstall')}</button>` : ''}
|
||||
</div>
|
||||
<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary);line-height:1.6">
|
||||
${policyRiskHint}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-header"><span class="stat-card-label">安装路径</span></div>
|
||||
<div class="stat-card-value" style="font-size:var(--font-size-sm);word-break:break-all">${install.path || '未知'}</div>
|
||||
<div class="stat-card-meta">${install.installed ? '配置文件存在' : '未找到配置文件'}</div>
|
||||
<div class="stat-card-header"><span class="stat-card-label">${t('about.installPath')}</span></div>
|
||||
<div class="stat-card-value" style="font-size:var(--font-size-sm);word-break:break-all">${install.path || t('common.unknown')}</div>
|
||||
<div class="stat-card-meta">${install.installed ? t('about.configExists') : t('about.configNotFound')}</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
const applyRecommendedBtn = cards.querySelector('#btn-apply-recommended')
|
||||
if (applyRecommendedBtn && version.recommended) {
|
||||
applyRecommendedBtn.onclick = () => doInstall(page, aheadOfRecommended ? '回退到推荐稳定版' : '切换到推荐稳定版', version.source, version.recommended)
|
||||
applyRecommendedBtn.onclick = () => doInstall(page, aheadOfRecommended ? t('about.rollbackToRecommendedStable') : t('about.switchToRecommendedStable'), version.source, version.recommended)
|
||||
}
|
||||
|
||||
// 版本管理 / 安装
|
||||
@@ -142,11 +143,11 @@ async function loadData(page) {
|
||||
const uninstallBtn = cards.querySelector('#btn-uninstall')
|
||||
if (uninstallBtn) {
|
||||
uninstallBtn.onclick = async () => {
|
||||
const confirmed = await showConfirm('确定要卸载 OpenClaw 吗?\n\n这将停止 Gateway 服务并卸载 npm 全局包。\n配置文件(~/.openclaw/)默认保留,可稍后手动删除。')
|
||||
const confirmed = await showConfirm(t('about.confirmUninstall'))
|
||||
if (!confirmed) return
|
||||
const modal = showUpgradeModal('卸载 OpenClaw')
|
||||
const modal = showUpgradeModal(t('about.uninstallTitle'))
|
||||
modal.onClose(() => loadData(page))
|
||||
modal.appendLog('开始卸载 OpenClaw...')
|
||||
modal.appendLog(t('about.uninstallStarting'))
|
||||
let unlistenLog, unlistenProgress, unlistenDone, unlistenError
|
||||
const cleanup = () => { unlistenLog?.(); unlistenProgress?.(); unlistenDone?.(); unlistenError?.() }
|
||||
try {
|
||||
@@ -154,23 +155,23 @@ async function loadData(page) {
|
||||
const { listen } = await import('@tauri-apps/api/event')
|
||||
unlistenLog = await listen('upgrade-log', (e) => modal.appendLog(e.payload))
|
||||
unlistenProgress = await listen('upgrade-progress', (e) => modal.setProgress(e.payload))
|
||||
unlistenDone = await listen('upgrade-done', (e) => { cleanup(); modal.setDone(typeof e.payload === 'string' ? e.payload : '卸载完成') })
|
||||
unlistenError = await listen('upgrade-error', (e) => { cleanup(); modal.setError('卸载失败: ' + (e.payload || '未知错误')) })
|
||||
unlistenDone = await listen('upgrade-done', (e) => { cleanup(); modal.setDone(typeof e.payload === 'string' ? e.payload : t('about.uninstallDone')) })
|
||||
unlistenError = await listen('upgrade-error', (e) => { cleanup(); modal.setError(t('about.uninstallFailed') + (e.payload || t('common.unknown'))) })
|
||||
await api.uninstallOpenclaw(false)
|
||||
modal.appendLog('后台卸载任务已启动...')
|
||||
modal.appendLog(t('about.uninstallTaskStarted'))
|
||||
} else {
|
||||
const msg = await api.uninstallOpenclaw(false)
|
||||
modal.setDone(typeof msg === 'string' ? msg : '卸载完成')
|
||||
modal.setDone(typeof msg === 'string' ? msg : t('about.uninstallDone'))
|
||||
cleanup()
|
||||
}
|
||||
} catch (e) {
|
||||
cleanup()
|
||||
modal.setError('卸载失败: ' + (e?.message || e))
|
||||
modal.setError(t('about.uninstallFailed') + (e?.message || e))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
cards.innerHTML = '<div class="stat-card"><div class="stat-card-label">加载失败</div></div>'
|
||||
cards.innerHTML = `<div class="stat-card"><div class="stat-card-label">${t('common.loadFailed')}</div></div>`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,29 +184,29 @@ async function showVersionPicker(page, currentVersion) {
|
||||
overlay.className = 'modal-overlay'
|
||||
overlay.innerHTML = `
|
||||
<div class="modal" style="max-width:460px">
|
||||
<div class="modal-title">${isInstalled ? '切换版本' : '安装 OpenClaw'}</div>
|
||||
<div class="modal-title">${isInstalled ? t('about.switchVersion') : t('about.installOpenclaw')}</div>
|
||||
<div style="display:flex;flex-direction:column;gap:16px;margin:16px 0">
|
||||
<div>
|
||||
<label style="font-size:var(--font-size-sm);color:var(--text-secondary);display:block;margin-bottom:8px">版本</label>
|
||||
<label style="font-size:var(--font-size-sm);color:var(--text-secondary);display:block;margin-bottom:8px">${t('about.versionLabel')}</label>
|
||||
<div style="display:flex;gap:8px">
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;padding:6px 12px;border-radius:8px;border:1px solid var(--border);font-size:var(--font-size-sm);flex:1;justify-content:center;transition:all .15s" id="lbl-official">
|
||||
<input type="radio" name="oc-source" value="official" ${currentVersion.source !== 'chinese' ? 'checked' : ''} style="accent-color:var(--primary)">
|
||||
原版
|
||||
${t('about.official')}
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;padding:6px 12px;border-radius:8px;border:1px solid var(--border);font-size:var(--font-size-sm);flex:1;justify-content:center;transition:all .15s" id="lbl-chinese">
|
||||
<input type="radio" name="oc-source" value="chinese" ${currentVersion.source === 'chinese' ? 'checked' : ''} style="accent-color:var(--primary)">
|
||||
汉化版
|
||||
${t('about.chinese')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:var(--font-size-sm);color:var(--text-secondary);display:block;margin-bottom:8px">选择版本号</label>
|
||||
<label style="font-size:var(--font-size-sm);color:var(--text-secondary);display:block;margin-bottom:8px">${t('about.selectVersion')}</label>
|
||||
<select id="oc-version-select" class="input" style="width:100%;padding:8px 12px;font-size:var(--font-size-sm)">
|
||||
<option value="">加载中...</option>
|
||||
<option value="">${t('common.loading')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="font-size:var(--font-size-xs);color:var(--text-tertiary);line-height:1.6;padding:10px 12px;border-radius:8px;background:var(--bg-tertiary)">
|
||||
默认建议使用当前面板绑定的推荐稳定版。若手动切换到其它版本,尤其是预览版/最新版,请自行验证兼容性;如果你希望面板优先适配最新版功能,欢迎提交 issue。
|
||||
${t('about.versionPickerHint')}
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;min-height:18px">
|
||||
<div id="oc-action-hint" style="font-size:var(--font-size-xs);color:var(--text-tertiary)"></div>
|
||||
@@ -213,8 +214,8 @@ async function showVersionPicker(page, currentVersion) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary btn-sm" data-action="cancel">取消</button>
|
||||
<button class="btn btn-primary btn-sm" data-action="confirm" disabled id="oc-confirm-btn">${isInstalled ? '切换' : '安装'}</button>
|
||||
<button class="btn btn-secondary btn-sm" data-action="cancel">${t('common.cancel')}</button>
|
||||
<button class="btn btn-primary btn-sm" data-action="confirm" disabled id="oc-confirm-btn">${isInstalled ? t('about.btnSwitch') : t('about.btnInstall')}</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
@@ -247,20 +248,20 @@ async function showVersionPicker(page, currentVersion) {
|
||||
const targetSource = currentSelect
|
||||
const targetVer = select.value
|
||||
if (!targetVer || targetVer === '') { hintEl.textContent = ''; confirmBtn.disabled = true; return }
|
||||
const targetTag = select.selectedIndex === 0 ? '(推荐稳定版)' : '(需自测兼容性)'
|
||||
const targetTag = select.selectedIndex === 0 ? t('about.tagRecommended') : t('about.tagNeedTest')
|
||||
|
||||
const sameSource = targetSource === (currentVersion.source === 'official' ? 'official' : 'chinese')
|
||||
|
||||
if (!isInstalled) {
|
||||
confirmBtn.textContent = '安装'
|
||||
hintEl.textContent = `将安装 ${targetSource === 'official' ? '原版' : '汉化版'} ${targetVer}${targetTag}`
|
||||
confirmBtn.textContent = t('about.btnInstall')
|
||||
hintEl.textContent = t('about.hintInstall', { source: targetSource === 'official' ? t('about.official') : t('about.chinese'), ver: targetVer, tag: targetTag })
|
||||
confirmBtn.disabled = false
|
||||
return
|
||||
}
|
||||
|
||||
if (!sameSource) {
|
||||
confirmBtn.textContent = '切换'
|
||||
hintEl.innerHTML = `当前: <strong>${currentVersion.source === 'official' ? '原版' : '汉化版'} ${currentVersion.current}</strong> → <strong>${targetSource === 'official' ? '原版' : '汉化版'} ${targetVer}</strong>${targetTag}`
|
||||
confirmBtn.textContent = t('about.btnSwitch')
|
||||
hintEl.innerHTML = `${t('about.hintCurrent')}: <strong>${currentVersion.source === 'official' ? t('about.official') : t('about.chinese')} ${currentVersion.current}</strong> → <strong>${targetSource === 'official' ? t('about.official') : t('about.chinese')} ${targetVer}</strong>${targetTag}`
|
||||
confirmBtn.disabled = false
|
||||
return
|
||||
}
|
||||
@@ -276,15 +277,15 @@ async function showVersionPicker(page, currentVersion) {
|
||||
}
|
||||
|
||||
if (cmp === 0) {
|
||||
confirmBtn.textContent = '重新安装'
|
||||
hintEl.textContent = `当前已是 ${targetVer}${targetTag}`
|
||||
confirmBtn.textContent = t('about.btnReinstall')
|
||||
hintEl.textContent = t('about.hintAlreadyVersion', { ver: targetVer, tag: targetTag })
|
||||
confirmBtn.disabled = false
|
||||
} else if (cmp > 0) {
|
||||
confirmBtn.textContent = '升级'
|
||||
confirmBtn.textContent = t('about.btnUpgrade')
|
||||
hintEl.innerHTML = `<span style="color:var(--accent)">${currentVersion.current} → ${targetVer}${targetTag}</span>`
|
||||
confirmBtn.disabled = false
|
||||
} else {
|
||||
confirmBtn.textContent = '降级'
|
||||
confirmBtn.textContent = t('about.btnDowngrade')
|
||||
hintEl.innerHTML = `<span style="color:var(--warning,#f59e0b)">${currentVersion.current} → ${targetVer}${targetTag}</span>`
|
||||
confirmBtn.disabled = false
|
||||
}
|
||||
@@ -293,7 +294,7 @@ async function showVersionPicker(page, currentVersion) {
|
||||
let showNightly = false
|
||||
|
||||
async function loadVersions(source) {
|
||||
select.innerHTML = '<option value="">加载中...</option>'
|
||||
select.innerHTML = `<option value="">${t('common.loading')}</option>`
|
||||
confirmBtn.disabled = true
|
||||
hintEl.textContent = ''
|
||||
try {
|
||||
@@ -302,7 +303,7 @@ async function showVersionPicker(page, currentVersion) {
|
||||
}
|
||||
const allVersions = versionsCache[source]
|
||||
if (!allVersions.length) {
|
||||
select.innerHTML = '<option value="">未找到可用版本</option>'
|
||||
select.innerHTML = `<option value="">${t('about.noVersions')}</option>`
|
||||
return
|
||||
}
|
||||
const stable = allVersions.filter(v => !v.includes('nightly') && !v.includes('canary') && !v.includes('alpha') && !v.includes('beta') && !v.includes('rc') && !v.includes('dev') && !v.includes('next'))
|
||||
@@ -310,7 +311,7 @@ async function showVersionPicker(page, currentVersion) {
|
||||
const nightlyCount = allVersions.length - stable.length
|
||||
select.innerHTML = versions.map((v, idx) => {
|
||||
const isCurrent = isInstalled && v === currentVersion.current && source === (currentVersion.source === 'official' ? 'official' : 'chinese')
|
||||
return `<option value="${v}">${v}${idx === 0 ? ' (推荐)' : ''}${isCurrent ? ' (当前)' : ''}</option>`
|
||||
return `<option value="${v}">${v}${idx === 0 ? ` (${t('about.recommended')})` : ''}${isCurrent ? ` (${t('about.current')})` : ''}</option>`
|
||||
}).join('')
|
||||
// nightly 切换提示
|
||||
const toggleEl = overlay.querySelector('#nightly-toggle')
|
||||
@@ -318,8 +319,8 @@ async function showVersionPicker(page, currentVersion) {
|
||||
if (nightlyCount > 0) {
|
||||
toggleEl.style.display = ''
|
||||
toggleEl.innerHTML = showNightly
|
||||
? `<a href="#" id="btn-toggle-nightly" style="color:var(--primary);text-decoration:none;font-size:var(--font-size-xs)">隐藏预览版 (${nightlyCount})</a>`
|
||||
: `<a href="#" id="btn-toggle-nightly" style="color:var(--text-tertiary);text-decoration:none;font-size:var(--font-size-xs)">显示预览版 (${nightlyCount})</a>`
|
||||
? `<a href="#" id="btn-toggle-nightly" style="color:var(--primary);text-decoration:none;font-size:var(--font-size-xs)">${t('about.hidePreview', { count: nightlyCount })}</a>`
|
||||
: `<a href="#" id="btn-toggle-nightly" style="color:var(--text-tertiary);text-decoration:none;font-size:var(--font-size-xs)">${t('about.showPreview', { count: nightlyCount })}</a>`
|
||||
toggleEl.querySelector('#btn-toggle-nightly').onclick = (e) => { e.preventDefault(); showNightly = !showNightly; loadVersions(source) }
|
||||
} else {
|
||||
toggleEl.style.display = 'none'
|
||||
@@ -327,7 +328,7 @@ async function showVersionPicker(page, currentVersion) {
|
||||
}
|
||||
updateHint()
|
||||
} catch (e) {
|
||||
select.innerHTML = `<option value="">加载失败: ${e.message || e}</option>`
|
||||
select.innerHTML = `<option value="">${t('common.loadFailed')}: ${e.message || e}</option>`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,12 +376,12 @@ async function doInstall(page, title, source, version) {
|
||||
|
||||
unlistenDone = await listen('upgrade-done', (e) => {
|
||||
cleanup()
|
||||
modal.setDone(typeof e.payload === 'string' ? e.payload : '操作完成')
|
||||
modal.setDone(typeof e.payload === 'string' ? e.payload : t('about.operationDone'))
|
||||
})
|
||||
|
||||
unlistenError = await listen('upgrade-error', async (e) => {
|
||||
cleanup()
|
||||
const errStr = String(e.payload || '未知错误')
|
||||
const errStr = String(e.payload || t('common.unknown'))
|
||||
modal.appendLog(errStr)
|
||||
const { diagnoseInstallError } = await import('../lib/error-diagnosis.js')
|
||||
const fullLog = modal.getLogText() + '\n' + errStr
|
||||
@@ -395,11 +396,11 @@ async function doInstall(page, title, source, version) {
|
||||
})
|
||||
|
||||
await api.upgradeOpenclaw(source, version)
|
||||
modal.appendLog('后台任务已启动,请等待完成...')
|
||||
modal.appendLog(t('about.taskStarted'))
|
||||
} else {
|
||||
modal.appendLog('Web 模式:安装过程日志不可用,请等待完成...')
|
||||
modal.appendLog(t('about.webModeNoLog'))
|
||||
const msg = await api.upgradeOpenclaw(source, version)
|
||||
modal.setDone(typeof msg === 'string' ? msg : (msg?.message || '操作完成'))
|
||||
modal.setDone(typeof msg === 'string' ? msg : (msg?.message || t('about.operationDone')))
|
||||
cleanup()
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -424,9 +425,9 @@ async function checkHotUpdate(cards, panelVersion) {
|
||||
// 已下载更新,等待重载
|
||||
const ver = info.manifest?.version || info.latestVersion || ''
|
||||
meta.innerHTML = `
|
||||
<span style="color:var(--accent)">v${ver} 已就绪</span>
|
||||
<button class="btn btn-primary btn-sm" id="btn-hot-reload" style="padding:2px 8px;font-size:var(--font-size-xs)">重载应用</button>
|
||||
<button class="btn btn-secondary btn-sm" id="btn-hot-rollback" style="padding:2px 8px;font-size:var(--font-size-xs)">回退</button>
|
||||
<span style="color:var(--accent)">v${ver} ${t('about.updateReady')}</span>
|
||||
<button class="btn btn-primary btn-sm" id="btn-hot-reload" style="padding:2px 8px;font-size:var(--font-size-xs)">${t('about.reloadApp')}</button>
|
||||
<button class="btn btn-secondary btn-sm" id="btn-hot-rollback" style="padding:2px 8px;font-size:var(--font-size-xs)">${t('about.rollback')}</button>
|
||||
`
|
||||
meta.querySelector('#btn-hot-reload')?.addEventListener('click', () => {
|
||||
window.location.reload()
|
||||
@@ -434,10 +435,10 @@ async function checkHotUpdate(cards, panelVersion) {
|
||||
meta.querySelector('#btn-hot-rollback')?.addEventListener('click', async () => {
|
||||
try {
|
||||
await api.rollbackFrontendUpdate()
|
||||
toast('已回退到内嵌版本,重载中...', 'success')
|
||||
toast(t('about.rollbackSuccess'), 'success')
|
||||
setTimeout(() => window.location.reload(), 800)
|
||||
} catch (e) {
|
||||
toast('回退失败: ' + (e.message || e), 'error')
|
||||
toast(t('about.rollbackFailed') + (e.message || e), 'error')
|
||||
}
|
||||
})
|
||||
} else if (info.hasUpdate) {
|
||||
@@ -446,32 +447,32 @@ async function checkHotUpdate(cards, panelVersion) {
|
||||
const manifest = info.manifest || {}
|
||||
const changelog = manifest.changelog || ''
|
||||
meta.innerHTML = `
|
||||
<span style="color:var(--accent)">新版本: v${ver}</span>
|
||||
<span style="color:var(--accent)">${t('about.newVersion')}: v${ver}</span>
|
||||
${changelog ? `<span style="color:var(--text-tertiary);font-size:var(--font-size-xs)">${changelog}</span>` : ''}
|
||||
<button class="btn btn-primary btn-sm" id="btn-hot-download" style="padding:2px 8px;font-size:var(--font-size-xs)">热更新</button>
|
||||
<a class="btn btn-secondary btn-sm" href="https://github.com/qingchencloud/clawpanel/releases" target="_blank" rel="noopener" style="padding:2px 8px;font-size:var(--font-size-xs)">完整安装包</a>
|
||||
<button class="btn btn-primary btn-sm" id="btn-hot-download" style="padding:2px 8px;font-size:var(--font-size-xs)">${t('about.hotUpdate')}</button>
|
||||
<a class="btn btn-secondary btn-sm" href="https://github.com/qingchencloud/clawpanel/releases" target="_blank" rel="noopener" style="padding:2px 8px;font-size:var(--font-size-xs)">${t('about.fullInstaller')}</a>
|
||||
`
|
||||
meta.querySelector('#btn-hot-download')?.addEventListener('click', async () => {
|
||||
const btn = meta.querySelector('#btn-hot-download')
|
||||
if (btn) { btn.disabled = true; btn.textContent = '下载中...' }
|
||||
if (btn) { btn.disabled = true; btn.textContent = t('about.downloading') }
|
||||
try {
|
||||
await api.downloadFrontendUpdate(manifest.url, manifest.hash || '')
|
||||
toast('更新下载完成,点击「重载应用」生效', 'success')
|
||||
toast(t('about.downloadDone'), 'success')
|
||||
checkHotUpdate(cards, panelVersion)
|
||||
} catch (e) {
|
||||
toast('下载失败: ' + (e.message || e), 'error')
|
||||
if (btn) { btn.disabled = false; btn.textContent = '重试' }
|
||||
toast(t('about.downloadFailed') + (e.message || e), 'error')
|
||||
if (btn) { btn.disabled = false; btn.textContent = t('about.retry') }
|
||||
}
|
||||
})
|
||||
} else if (!info.compatible) {
|
||||
meta.innerHTML = '<span style="color:var(--text-tertiary)">需要更新完整安装包</span> <a class="btn btn-primary btn-sm" href="https://claw.qt.cool" target="_blank" rel="noopener" style="padding:2px 8px;font-size:var(--font-size-xs)">前往官网下载</a> <a class="btn btn-secondary btn-sm" href="https://github.com/qingchencloud/clawpanel/releases" target="_blank" rel="noopener" style="padding:2px 8px;font-size:var(--font-size-xs)">GitHub</a>'
|
||||
meta.innerHTML = `<span style="color:var(--text-tertiary)">${t('about.needFullUpdate')}</span> <a class="btn btn-primary btn-sm" href="https://claw.qt.cool" target="_blank" rel="noopener" style="padding:2px 8px;font-size:var(--font-size-xs)">${t('about.goToWebsite')}</a> <a class="btn btn-secondary btn-sm" href="https://github.com/qingchencloud/clawpanel/releases" target="_blank" rel="noopener" style="padding:2px 8px;font-size:var(--font-size-xs)">GitHub</a>`
|
||||
} else {
|
||||
meta.innerHTML = '<span style="color:var(--success)">已是最新</span>'
|
||||
meta.innerHTML = `<span style="color:var(--success)">${t('about.upToDate')}</span>`
|
||||
}
|
||||
} catch (err) {
|
||||
const meta = el()
|
||||
if (!meta) return
|
||||
meta.innerHTML = `<span style="color:var(--text-tertiary)">暂无法检查更新</span> <a class="btn btn-secondary btn-sm" href="https://claw.qt.cool" target="_blank" rel="noopener" style="padding:2px 8px;font-size:var(--font-size-xs)">前往官网下载</a>`
|
||||
meta.innerHTML = `<span style="color:var(--text-tertiary)">${t('about.checkUpdateFailed')}</span> <a class="btn btn-secondary btn-sm" href="https://claw.qt.cool" target="_blank" rel="noopener" style="padding:2px 8px;font-size:var(--font-size-xs)">${t('about.goToWebsite')}</a>`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -492,32 +493,32 @@ function renderCommunity(page) {
|
||||
el.innerHTML = `
|
||||
<div style="display:flex;gap:24px;flex-wrap:wrap;align-items:flex-start">
|
||||
<div style="text-align:center">
|
||||
<img src="/images/OpenClaw-QQ.png" alt="QQ 交流群" style="width:140px;height:140px;border-radius:var(--radius-md);border:1px solid var(--border-primary)">
|
||||
<div style="font-size:var(--font-size-sm);margin-top:8px;color:var(--text-secondary)">QQ 交流群</div>
|
||||
<img src="/images/OpenClaw-QQ.png" alt="${t('about.qqGroup')}" style="width:140px;height:140px;border-radius:var(--radius-md);border:1px solid var(--border-primary)">
|
||||
<div style="font-size:var(--font-size-sm);margin-top:8px;color:var(--text-secondary)">${t('about.qqGroup')}</div>
|
||||
</div>
|
||||
<div style="text-align:center">
|
||||
<img src="/images/OpenClawWx.png" alt="微信交流群" style="width:140px;height:140px;border-radius:var(--radius-md);border:1px solid var(--border-primary)">
|
||||
<div style="font-size:var(--font-size-sm);margin-top:8px;color:var(--text-secondary)">微信交流群</div>
|
||||
<img src="/images/OpenClawWx.png" alt="${t('about.wechatGroup')}" style="width:140px;height:140px;border-radius:var(--radius-md);border:1px solid var(--border-primary)">
|
||||
<div style="font-size:var(--font-size-sm);margin-top:8px;color:var(--text-secondary)">${t('about.wechatGroup')}</div>
|
||||
</div>
|
||||
<div style="text-align:center">
|
||||
<img src="https://qt.cool/c/OpenClawDY/qr.png" alt="抖音交流群" style="width:140px;height:140px;border-radius:var(--radius-md);border:1px solid var(--border-primary);object-fit:contain;background:#fff">
|
||||
<div style="font-size:var(--font-size-sm);margin-top:8px;color:var(--text-secondary)">抖音交流群</div>
|
||||
<img src="https://qt.cool/c/OpenClawDY/qr.png" alt="${t('about.douyinGroup')}" style="width:140px;height:140px;border-radius:var(--radius-md);border:1px solid var(--border-primary);object-fit:contain;background:#fff">
|
||||
<div style="font-size:var(--font-size-sm);margin-top:8px;color:var(--text-secondary)">${t('about.douyinGroup')}</div>
|
||||
</div>
|
||||
<div style="text-align:center">
|
||||
<img src="https://qt.cool/c/feishu/qr.png" alt="飞书交流群" style="width:140px;height:140px;border-radius:var(--radius-md);border:1px solid var(--border-primary);object-fit:contain;background:#fff">
|
||||
<div style="font-size:var(--font-size-sm);margin-top:8px;color:var(--text-secondary)">飞书交流群</div>
|
||||
<img src="https://qt.cool/c/feishu/qr.png" alt="${t('about.feishuGroup')}" style="width:140px;height:140px;border-radius:var(--radius-md);border:1px solid var(--border-primary);object-fit:contain;background:#fff">
|
||||
<div style="font-size:var(--font-size-sm);margin-top:8px;color:var(--text-secondary)">${t('about.feishuGroup')}</div>
|
||||
</div>
|
||||
<div style="flex:1;min-width:200px;display:flex;flex-direction:column;gap:8px;padding-top:4px">
|
||||
<div style="font-size:var(--font-size-sm);color:var(--text-secondary)">扫码或点击链接加入交流群,反馈问题、获取帮助</div>
|
||||
<div style="font-size:var(--font-size-sm);color:var(--text-secondary)">${t('about.communityDesc')}</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-top:8px">
|
||||
<a class="btn btn-primary btn-sm" href="https://qt.cool/c/OpenClaw" target="_blank" rel="noopener">加入 QQ 群</a>
|
||||
<a class="btn btn-primary btn-sm" href="https://qt.cool/c/OpenClawWx" target="_blank" rel="noopener">加入微信群</a>
|
||||
<a class="btn btn-primary btn-sm" href="https://qt.cool/c/OpenClawDY" target="_blank" rel="noopener">加入抖音群</a>
|
||||
<a class="btn btn-primary btn-sm" href="https://qt.cool/c/feishu" target="_blank" rel="noopener">加入飞书群</a>
|
||||
<a class="btn btn-secondary btn-sm" href="https://yb.tencent.com/gp/i/LsvIw7mdR7Lb" target="_blank" rel="noopener">元宝派社群</a>
|
||||
<a class="btn btn-primary btn-sm" href="https://qt.cool/c/OpenClaw" target="_blank" rel="noopener">${t('about.joinQQ')}</a>
|
||||
<a class="btn btn-primary btn-sm" href="https://qt.cool/c/OpenClawWx" target="_blank" rel="noopener">${t('about.joinWechat')}</a>
|
||||
<a class="btn btn-primary btn-sm" href="https://qt.cool/c/OpenClawDY" target="_blank" rel="noopener">${t('about.joinDouyin')}</a>
|
||||
<a class="btn btn-primary btn-sm" href="https://qt.cool/c/feishu" target="_blank" rel="noopener">${t('about.joinFeishu')}</a>
|
||||
<a class="btn btn-secondary btn-sm" href="https://yb.tencent.com/gp/i/LsvIw7mdR7Lb" target="_blank" rel="noopener">${t('about.joinYuanbao')}</a>
|
||||
</div>
|
||||
<div style="font-size:var(--font-size-xs);color:var(--text-tertiary);margin-top:8px">
|
||||
2000 人大群,满员自动切换 · 碰到问题可直接在群内反馈
|
||||
${t('about.communityNote')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -527,28 +528,28 @@ function renderCommunity(page) {
|
||||
const PROJECTS = [
|
||||
{
|
||||
name: 'OpenClaw',
|
||||
desc: 'AI Agent 框架,支持多模型协作、工具调用、记忆管理',
|
||||
desc: t('about.projectOpenClaw'),
|
||||
url: 'https://github.com/openclaw/openclaw',
|
||||
},
|
||||
{
|
||||
name: 'OpenClaw-zh',
|
||||
desc: '我们维护的 OpenClaw 汉化版,3000+ Star,中文界面 + 国内镜像优化',
|
||||
desc: t('about.projectOpenClawZh'),
|
||||
url: 'https://github.com/1186258278/OpenClawChineseTranslation',
|
||||
},
|
||||
{
|
||||
name: 'ClawPanel',
|
||||
desc: 'OpenClaw 可视化管理面板,Tauri v2 桌面应用',
|
||||
desc: t('about.projectClawPanel'),
|
||||
url: 'https://github.com/qingchencloud/clawpanel',
|
||||
gitee: 'https://gitee.com/QtCodeCreators/clawpanel',
|
||||
},
|
||||
{
|
||||
name: 'ClawApp',
|
||||
desc: '跨平台移动聊天客户端,H5 + 代理服务器架构,支持离线和流式传输',
|
||||
desc: t('about.projectClawApp'),
|
||||
url: 'https://github.com/qingchencloud/clawapp',
|
||||
},
|
||||
{
|
||||
name: 'cftunnel',
|
||||
desc: '全协议内网穿透工具,Cloud 模式免费 HTTP/WS + Relay 模式自建中继',
|
||||
desc: t('about.projectCftunnel'),
|
||||
url: 'https://github.com/qingchencloud/cftunnel',
|
||||
},
|
||||
]
|
||||
@@ -565,33 +566,33 @@ function renderProjects(page) {
|
||||
</div>
|
||||
<div class="service-actions">
|
||||
<a class="btn btn-secondary btn-sm" href="${p.url}" target="_blank" rel="noopener">GitHub</a>
|
||||
${p.gitee ? `<a class="btn btn-secondary btn-sm" href="${p.gitee}" target="_blank" rel="noopener">国内镜像</a>` : ''}
|
||||
${p.gitee ? `<a class="btn btn-secondary btn-sm" href="${p.gitee}" target="_blank" rel="noopener">${t('about.domesticMirror')}</a>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('')
|
||||
}
|
||||
|
||||
const LINKS = [
|
||||
{ label: 'Claw 项目官网', url: 'https://claw.qt.cool', primary: true },
|
||||
{ label: 'OpenClaw 中文翻译', url: 'https://github.com/1186258278/OpenClawChineseTranslation' },
|
||||
{ label: 'ClawApp 手机客户端', url: 'https://clawapp.qt.cool' },
|
||||
{ label: 'cftunnel 内网穿透', url: 'https://cftunnel.qt.cool' },
|
||||
{ label: t('about.linkWebsite'), url: 'https://claw.qt.cool', primary: true },
|
||||
{ label: t('about.linkOpenClawZh'), url: 'https://github.com/1186258278/OpenClawChineseTranslation' },
|
||||
{ label: t('about.linkClawApp'), url: 'https://clawapp.qt.cool' },
|
||||
{ label: t('about.linkCftunnel'), url: 'https://cftunnel.qt.cool' },
|
||||
]
|
||||
|
||||
function renderContribute(page) {
|
||||
const el = page.querySelector('#contribute-section')
|
||||
el.innerHTML = `
|
||||
<div style="font-size:var(--font-size-sm);color:var(--text-secondary);margin-bottom:12px">
|
||||
ClawPanel 是开源项目,欢迎参与贡献!遇到问题请提 Issue,功能建议和代码改进欢迎提 PR。
|
||||
${t('about.contributeDesc')}
|
||||
</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:8px">
|
||||
<a class="btn btn-primary btn-sm" href="https://github.com/qingchencloud/clawpanel/issues/new" target="_blank" rel="noopener">提交 Issue</a>
|
||||
<a class="btn btn-secondary btn-sm" href="https://github.com/qingchencloud/clawpanel/pulls" target="_blank" rel="noopener">提交 PR</a>
|
||||
<a class="btn btn-secondary btn-sm" href="https://github.com/qingchencloud/clawpanel/blob/main/CONTRIBUTING.md" target="_blank" rel="noopener">贡献指南</a>
|
||||
<a class="btn btn-secondary btn-sm" href="https://github.com/qingchencloud/clawpanel/issues" target="_blank" rel="noopener">查看 Issues</a>
|
||||
<a class="btn btn-primary btn-sm" href="https://github.com/qingchencloud/clawpanel/issues/new" target="_blank" rel="noopener">${t('about.submitIssue')}</a>
|
||||
<a class="btn btn-secondary btn-sm" href="https://github.com/qingchencloud/clawpanel/pulls" target="_blank" rel="noopener">${t('about.submitPR')}</a>
|
||||
<a class="btn btn-secondary btn-sm" href="https://github.com/qingchencloud/clawpanel/blob/main/CONTRIBUTING.md" target="_blank" rel="noopener">${t('about.contributeGuide')}</a>
|
||||
<a class="btn btn-secondary btn-sm" href="https://github.com/qingchencloud/clawpanel/issues" target="_blank" rel="noopener">${t('about.viewIssues')}</a>
|
||||
</div>
|
||||
<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">
|
||||
国内镜像:<a href="https://gitee.com/QtCodeCreators/clawpanel" target="_blank" rel="noopener" style="color:var(--accent)">Gitee</a>(无法访问 GitHub 时可用)
|
||||
${t('about.domesticMirrorHint')}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
@@ -608,32 +609,32 @@ function renderCompany(page) {
|
||||
el.innerHTML = `
|
||||
<div style="display:flex;flex-direction:column;gap:12px">
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<img src="/images/logo-brand.png" alt="晴辰云" style="width:40px;height:40px;border-radius:10px;flex-shrink:0">
|
||||
<img src="/images/logo-brand.png" alt="QingchenCloud" style="width:40px;height:40px;border-radius:10px;flex-shrink:0">
|
||||
<div>
|
||||
<div style="font-weight:700;font-size:var(--font-size-md)">武汉晴辰天下网络科技有限公司</div>
|
||||
<div style="font-weight:700;font-size:var(--font-size-md)">${t('about.companyName')}</div>
|
||||
<div style="font-size:var(--font-size-sm);color:var(--text-secondary)">QingchenCloud</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px;font-size:var(--font-size-sm)">
|
||||
<div style="padding:12px;border-radius:var(--radius-md);border:1px solid var(--border-primary);background:var(--bg-secondary)">
|
||||
<div style="color:var(--text-tertiary);font-size:var(--font-size-xs);margin-bottom:4px">官方网站</div>
|
||||
<div style="color:var(--text-tertiary);font-size:var(--font-size-xs);margin-bottom:4px">${t('about.officialWebsite')}</div>
|
||||
<a href="https://qingchencloud.com" target="_blank" rel="noopener" style="color:var(--accent)">qingchencloud.com</a>
|
||||
</div>
|
||||
<div style="padding:12px;border-radius:var(--radius-md);border:1px solid var(--border-primary);background:var(--bg-secondary)">
|
||||
<div style="color:var(--text-tertiary);font-size:var(--font-size-xs);margin-bottom:4px">产品官网</div>
|
||||
<div style="color:var(--text-tertiary);font-size:var(--font-size-xs);margin-bottom:4px">${t('about.productWebsite')}</div>
|
||||
<a href="https://claw.qt.cool" target="_blank" rel="noopener" style="color:var(--accent)">claw.qt.cool</a>
|
||||
</div>
|
||||
<div style="padding:12px;border-radius:var(--radius-md);border:1px solid var(--border-primary);background:var(--bg-secondary)">
|
||||
<div style="color:var(--text-tertiary);font-size:var(--font-size-xs);margin-bottom:4px">开源仓库</div>
|
||||
<div style="color:var(--text-tertiary);font-size:var(--font-size-xs);margin-bottom:4px">${t('about.openSourceRepo')}</div>
|
||||
<a href="https://github.com/qingchencloud" target="_blank" rel="noopener" style="color:var(--accent)">github.com/qingchencloud</a>
|
||||
</div>
|
||||
<div style="padding:12px;border-radius:var(--radius-md);border:1px solid var(--border-primary);background:var(--bg-secondary)">
|
||||
<div style="color:var(--text-tertiary);font-size:var(--font-size-xs);margin-bottom:4px">商务合作</div>
|
||||
<span style="color:var(--text-primary)">请通过官网联系我们</span>
|
||||
<div style="color:var(--text-tertiary);font-size:var(--font-size-xs);margin-bottom:4px">${t('about.businessCoop')}</div>
|
||||
<span style="color:var(--text-primary)">${t('about.contactViaWebsite')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:var(--font-size-xs);color:var(--text-tertiary);line-height:1.6">
|
||||
我们是 OpenClaw 汉化版(3000+ Star)和 ClawPanel 的作者团队。日常做 AI Agent 相关的产品和开源工具,也接企业私有化部署、定制开发之类的活儿。有事直接群里找我们就行。
|
||||
${t('about.companyDesc')}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
@@ -6,6 +6,7 @@ import { api, invalidate } from '../lib/tauri-api.js'
|
||||
import { toast } from '../components/toast.js'
|
||||
import { showModal, showConfirm } from '../components/modal.js'
|
||||
import { CHANNEL_LABELS } from '../lib/channel-labels.js'
|
||||
import { t } from '../lib/i18n.js'
|
||||
|
||||
export async function render() {
|
||||
const page = document.createElement('div')
|
||||
@@ -14,11 +15,11 @@ export async function render() {
|
||||
page.innerHTML = `
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">Agent 管理</h1>
|
||||
<p class="page-desc">创建和管理 OpenClaw Agent,配置身份、模型和工作区</p>
|
||||
<h1 class="page-title">${t('agents.title')}</h1>
|
||||
<p class="page-desc">${t('agents.desc')}</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-primary" id="btn-add-agent">+ 新建 Agent</button>
|
||||
<button class="btn btn-primary" id="btn-add-agent">${t('agents.addAgent')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-content">
|
||||
@@ -67,8 +68,8 @@ async function loadAgents(page, state) {
|
||||
state.eventsAttached = true
|
||||
}
|
||||
} catch (e) {
|
||||
container.innerHTML = '<div style="color:var(--error);padding:20px">加载失败: ' + String(e).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') + '</div>'
|
||||
toast('加载 Agent 列表失败: ' + e, 'error')
|
||||
container.innerHTML = '<div style="color:var(--error);padding:20px">' + t('agents.loadFailed') + ': ' + String(e).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') + '</div>'
|
||||
toast(t('agents.loadListFailed') + ': ' + e, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +77,7 @@ async function loadAgents(page, state) {
|
||||
function renderBindingBadges(agentId, bindings) {
|
||||
const matched = (bindings || []).filter(b => (b.agentId || 'main') === agentId)
|
||||
if (!matched.length) {
|
||||
return '<span style="color:var(--text-tertiary)">未绑定渠道</span>'
|
||||
return `<span style="color:var(--text-tertiary)">${t('agents.noBinding')}</span>`
|
||||
}
|
||||
return matched.map(b => {
|
||||
const channel = b.match?.channel || ''
|
||||
@@ -91,41 +92,41 @@ function renderBindingBadges(agentId, bindings) {
|
||||
function renderAgents(page, state) {
|
||||
const container = page.querySelector('#agents-list')
|
||||
if (!state.agents.length) {
|
||||
container.innerHTML = '<div style="color:var(--text-tertiary);padding:20px;text-align:center">暂无 Agent</div>'
|
||||
container.innerHTML = `<div style="color:var(--text-tertiary);padding:20px;text-align:center">${t('agents.noAgents')}</div>`
|
||||
return
|
||||
}
|
||||
|
||||
container.innerHTML = state.agents.map(a => {
|
||||
const isDefault = a.isDefault || a.id === 'main'
|
||||
const name = a.identityName ? a.identityName.split(',')[0].trim() : '无描述'
|
||||
const name = a.identityName ? a.identityName.split(',')[0].trim() : t('agents.noDesc')
|
||||
return `
|
||||
<div class="agent-card" data-id="${a.id}">
|
||||
<div class="agent-card-header">
|
||||
<div class="agent-card-title">
|
||||
<span class="agent-id">${a.id}</span>
|
||||
${isDefault ? '<span class="badge badge-success">默认</span>' : ''}
|
||||
${isDefault ? `<span class="badge badge-success">${t('agents.default')}</span>` : ''}
|
||||
</div>
|
||||
<div class="agent-card-actions">
|
||||
<button class="btn btn-sm btn-secondary" data-action="backup" data-id="${a.id}">备份</button>
|
||||
<button class="btn btn-sm btn-secondary" data-action="edit" data-id="${a.id}">编辑</button>
|
||||
${!isDefault ? `<button class="btn btn-sm btn-danger" data-action="delete" data-id="${a.id}">删除</button>` : ''}
|
||||
<button class="btn btn-sm btn-secondary" data-action="backup" data-id="${a.id}">${t('agents.backup')}</button>
|
||||
<button class="btn btn-sm btn-secondary" data-action="edit" data-id="${a.id}">${t('agents.edit')}</button>
|
||||
${!isDefault ? `<button class="btn btn-sm btn-danger" data-action="delete" data-id="${a.id}">${t('agents.delete')}</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="agent-card-body">
|
||||
<div class="agent-info-row">
|
||||
<span class="agent-info-label">名称:</span>
|
||||
<span class="agent-info-label">${t('agents.labelName')}</span>
|
||||
<span class="agent-info-value">${name}</span>
|
||||
</div>
|
||||
<div class="agent-info-row">
|
||||
<span class="agent-info-label">模型:</span>
|
||||
<span class="agent-info-value">${typeof a.model === 'object' ? (a.model?.primary || a.model?.id || JSON.stringify(a.model)) : (a.model || '未设置')}</span>
|
||||
<span class="agent-info-label">${t('agents.labelModel')}</span>
|
||||
<span class="agent-info-value">${typeof a.model === 'object' ? (a.model?.primary || a.model?.id || JSON.stringify(a.model)) : (a.model || t('agents.notSet'))}</span>
|
||||
</div>
|
||||
<div class="agent-info-row">
|
||||
<span class="agent-info-label">工作区:</span>
|
||||
<span class="agent-info-value" style="font-family:var(--font-mono);font-size:var(--font-size-xs)">${a.workspace || '未设置'}</span>
|
||||
<span class="agent-info-label">${t('agents.labelWorkspace')}</span>
|
||||
<span class="agent-info-value" style="font-family:var(--font-mono);font-size:var(--font-size-xs)">${a.workspace || t('agents.notSet')}</span>
|
||||
</div>
|
||||
<div class="agent-info-row">
|
||||
<span class="agent-info-label">绑定渠道:</span>
|
||||
<span class="agent-info-label">${t('agents.labelBindings')}</span>
|
||||
<span class="agent-info-value">${renderBindingBadges(a.id, state.bindings)}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -163,23 +164,23 @@ async function showAddAgentDialog(page, state) {
|
||||
} catch { models = [{ value: 'newapi/claude-opus-4-6', label: 'newapi/claude-opus-4-6' }] }
|
||||
|
||||
if (!models.length) {
|
||||
toast('请先在模型配置页面添加模型', 'warning')
|
||||
toast(t('agents.addModelsFirst'), 'warning')
|
||||
return
|
||||
}
|
||||
|
||||
showModal({
|
||||
title: '新建 Agent',
|
||||
title: t('agents.addTitle'),
|
||||
fields: [
|
||||
{ name: 'id', label: 'Agent ID', value: '', placeholder: '例如:translator(小写字母、数字、下划线、连字符)' },
|
||||
{ name: 'name', label: '名称', value: '', placeholder: '例如:翻译助手' },
|
||||
{ name: 'emoji', label: 'Emoji', value: '', placeholder: '例如:🌐(可选)' },
|
||||
{ name: 'model', label: '模型', type: 'select', value: models[0]?.value || '', options: models },
|
||||
{ name: 'workspace', label: '工作区路径', value: '', placeholder: '留空则自动创建(可选,绝对路径)' },
|
||||
{ name: 'id', label: t('agents.agentId'), value: '', placeholder: t('agents.agentIdPlaceholder') },
|
||||
{ name: 'name', label: t('agents.agentName'), value: '', placeholder: t('agents.agentNamePlaceholder') },
|
||||
{ name: 'emoji', label: t('agents.agentEmoji'), value: '', placeholder: t('agents.agentEmojiPlaceholder') },
|
||||
{ name: 'model', label: t('agents.agentModel'), type: 'select', value: models[0]?.value || '', options: models },
|
||||
{ name: 'workspace', label: t('agents.agentWorkspace'), value: '', placeholder: t('agents.agentWorkspacePlaceholder') },
|
||||
],
|
||||
onConfirm: async (result) => {
|
||||
const id = (result.id || '').trim()
|
||||
if (!id) { toast('请输入 Agent ID', 'warning'); return }
|
||||
if (!/^[a-z0-9_-]+$/.test(id)) { toast('Agent ID 只能包含小写字母、数字、下划线和连字符', 'warning'); return }
|
||||
if (!id) { toast(t('agents.idRequired'), 'warning'); return }
|
||||
if (!/^[a-z0-9_-]+$/.test(id)) { toast(t('agents.idInvalid'), 'warning'); return }
|
||||
|
||||
const name = (result.name || '').trim()
|
||||
const emoji = (result.emoji || '').trim()
|
||||
@@ -194,16 +195,16 @@ async function showAddAgentDialog(page, state) {
|
||||
await api.updateAgentIdentity(id, name || null, emoji || null)
|
||||
} catch (identityErr) {
|
||||
console.warn('[Agent] 身份信息更新失败(Agent 已创建):', identityErr)
|
||||
toast('Agent 已创建,但名称设置失败,可稍后编辑', 'warning')
|
||||
toast(t('agents.createdNameFailed'), 'warning')
|
||||
}
|
||||
}
|
||||
toast('Agent 已创建', 'success')
|
||||
toast(t('agents.created'), 'success')
|
||||
|
||||
// 强制清除缓存并重新加载
|
||||
invalidate('list_agents')
|
||||
await loadAgents(page, state)
|
||||
} catch (e) {
|
||||
toast('创建失败: ' + e, 'error')
|
||||
toast(t('agents.createFailed') + ': ' + e, 'error')
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -232,13 +233,13 @@ async function showEditAgentDialog(page, state, id) {
|
||||
}
|
||||
|
||||
const fields = [
|
||||
{ name: 'name', label: '名称', value: name, placeholder: '例如:翻译助手' },
|
||||
{ name: 'emoji', label: 'Emoji', value: agent.identityEmoji || '', placeholder: '例如:🌐' },
|
||||
{ name: 'name', label: t('agents.agentName'), value: name, placeholder: t('agents.agentNamePlaceholder') },
|
||||
{ name: 'emoji', label: t('agents.agentEmoji'), value: agent.identityEmoji || '', placeholder: t('agents.agentEmojiPlaceholder') },
|
||||
]
|
||||
|
||||
if (models.length) {
|
||||
const modelField = {
|
||||
name: 'model', label: '模型', type: 'select',
|
||||
name: 'model', label: t('agents.agentModel'), type: 'select',
|
||||
value: agent.model || models[0]?.value || '',
|
||||
options: models,
|
||||
}
|
||||
@@ -250,14 +251,14 @@ async function showEditAgentDialog(page, state, id) {
|
||||
}
|
||||
|
||||
fields.push({
|
||||
name: 'workspace', label: '工作区',
|
||||
value: agent.workspace || '未设置',
|
||||
placeholder: '创建时指定,不可修改',
|
||||
name: 'workspace', label: t('agents.labelWorkspace').replace(':', ''),
|
||||
value: agent.workspace || t('agents.notSet'),
|
||||
placeholder: t('agents.workspaceReadonly'),
|
||||
readonly: true,
|
||||
})
|
||||
|
||||
showModal({
|
||||
title: `编辑 Agent — ${id}`,
|
||||
title: t('agents.editTitle', { id }),
|
||||
fields,
|
||||
onConfirm: async (result) => {
|
||||
console.log('[Agent编辑] 保存数据:', result)
|
||||
@@ -281,30 +282,30 @@ async function showEditAgentDialog(page, state, id) {
|
||||
if (model) agent.model = model
|
||||
renderAgents(page, state)
|
||||
|
||||
toast('已更新', 'success')
|
||||
toast(t('agents.updated'), 'success')
|
||||
} catch (e) {
|
||||
console.error('[Agent编辑] 保存失败:', e)
|
||||
toast('更新失败: ' + e, 'error')
|
||||
toast(t('agents.updateFailed') + ': ' + e, 'error')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function deleteAgent(page, state, id) {
|
||||
const yes = await showConfirm(`确定删除 Agent「${id}」?\n\n此操作将删除该 Agent 的所有数据和会话。`)
|
||||
const yes = await showConfirm(t('agents.confirmDelete', { id }))
|
||||
if (!yes) return
|
||||
|
||||
try {
|
||||
await api.deleteAgent(id)
|
||||
toast('已删除', 'success')
|
||||
toast(t('agents.deleted'), 'success')
|
||||
await loadAgents(page, state)
|
||||
} catch (e) {
|
||||
toast('删除失败: ' + e, 'error')
|
||||
toast(t('agents.deleteFailed') + ': ' + e, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function backupAgent(id) {
|
||||
toast(`正在备份 Agent「${id}」...`, 'info')
|
||||
toast(t('agents.backingUp', { id }), 'info')
|
||||
try {
|
||||
const zipPath = await api.backupAgent(id)
|
||||
try {
|
||||
@@ -312,8 +313,8 @@ async function backupAgent(id) {
|
||||
const dir = zipPath.substring(0, zipPath.lastIndexOf('/')) || zipPath
|
||||
await open(dir)
|
||||
} catch { /* fallback */ }
|
||||
toast(`备份完成: ${zipPath.split('/').pop()}`, 'success')
|
||||
toast(t('agents.backupDone', { file: zipPath.split('/').pop() }), 'success')
|
||||
} catch (e) {
|
||||
toast('备份失败: ' + e, 'error')
|
||||
toast(t('agents.backupFailed') + ': ' + e, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ import { isOpenclawReady, isGatewayRunning } from '../lib/app-state.js'
|
||||
import { icon, statusIcon } from '../lib/icons.js'
|
||||
import { toast } from '../components/toast.js'
|
||||
import { navigate } from '../router.js'
|
||||
import { t } from '../lib/i18n.js'
|
||||
|
||||
export async function render() {
|
||||
const page = document.createElement('div')
|
||||
@@ -15,15 +16,15 @@ export async function render() {
|
||||
|
||||
page.innerHTML = `
|
||||
<div class="page-header" style="margin-bottom:var(--space-lg)">
|
||||
<h1 class="page-title">系统诊断</h1>
|
||||
<p class="page-desc" style="margin-bottom:1em">全面检测系统状态,快速定位问题</p>
|
||||
<h1 class="page-title">${t('chatDebug.title')}</h1>
|
||||
<p class="page-desc" style="margin-bottom:1em">${t('chatDebug.desc')}</p>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<button class="btn btn-primary btn-sm" id="btn-refresh">刷新状态</button>
|
||||
<button class="btn btn-secondary btn-sm" id="btn-doctor-check">诊断配置</button>
|
||||
<button class="btn btn-warning btn-sm" id="btn-doctor-fix">自动修复</button>
|
||||
<button class="btn btn-secondary btn-sm" id="btn-test-ws">测试 WebSocket</button>
|
||||
<button class="btn btn-secondary btn-sm" id="btn-network-log">网络日志</button>
|
||||
<button class="btn btn-secondary btn-sm" id="btn-fix-pairing">一键修复配对</button>
|
||||
<button class="btn btn-primary btn-sm" id="btn-refresh">${t('chatDebug.btnRefresh')}</button>
|
||||
<button class="btn btn-secondary btn-sm" id="btn-doctor-check">${t('chatDebug.btnDiagConfig')}</button>
|
||||
<button class="btn btn-warning btn-sm" id="btn-doctor-fix">${t('chatDebug.btnAutoFix')}</button>
|
||||
<button class="btn btn-secondary btn-sm" id="btn-test-ws">${t('chatDebug.btnTestWs')}</button>
|
||||
<button class="btn btn-secondary btn-sm" id="btn-network-log">${t('chatDebug.btnNetworkLog')}</button>
|
||||
<button class="btn btn-secondary btn-sm" id="btn-fix-pairing">${t('chatDebug.btnFixPairing')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="debug-content">
|
||||
@@ -34,31 +35,31 @@ export async function render() {
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:var(--space-md)">
|
||||
<div class="config-section"><div class="config-section-title" style="margin-bottom:8px">应用状态</div><div class="loading-placeholder" style="height:48px;border-radius:4px"></div></div>
|
||||
<div class="config-section"><div class="config-section-title" style="margin-bottom:8px">WebSocket 连接</div><div class="loading-placeholder" style="height:48px;border-radius:4px"></div></div>
|
||||
<div class="config-section"><div class="config-section-title" style="margin-bottom:8px">Node.js 环境</div><div class="loading-placeholder" style="height:48px;border-radius:4px"></div></div>
|
||||
<div class="config-section"><div class="config-section-title" style="margin-bottom:8px">版本信息</div><div class="loading-placeholder" style="height:48px;border-radius:4px"></div></div>
|
||||
<div class="config-section"><div class="config-section-title" style="margin-bottom:8px">${t('chatDebug.sectionAppState')}</div><div class="loading-placeholder" style="height:48px;border-radius:4px"></div></div>
|
||||
<div class="config-section"><div class="config-section-title" style="margin-bottom:8px">${t('chatDebug.sectionWs')}</div><div class="loading-placeholder" style="height:48px;border-radius:4px"></div></div>
|
||||
<div class="config-section"><div class="config-section-title" style="margin-bottom:8px">${t('chatDebug.sectionNode')}</div><div class="loading-placeholder" style="height:48px;border-radius:4px"></div></div>
|
||||
<div class="config-section"><div class="config-section-title" style="margin-bottom:8px">${t('chatDebug.sectionVersion')}</div><div class="loading-placeholder" style="height:48px;border-radius:4px"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="doctor-output" style="display:none;margin-top:var(--space-md)">
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">配置诊断输出</div>
|
||||
<div class="config-section-title">${t('chatDebug.sectionDoctorOutput')}</div>
|
||||
<pre style="background:var(--bg-secondary);border-radius:var(--radius);padding:var(--space-sm);font-size:var(--font-size-xs);max-height:300px;overflow:auto;white-space:pre-wrap;word-break:break-all"></pre>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ws-test-log" style="display:none;margin-top:16px;background:var(--bg-secondary);border-radius:6px;padding:12px">
|
||||
<div style="font-weight:600;margin-bottom:8px;display:flex;justify-content:space-between;align-items:center">
|
||||
<span>WebSocket 连接测试</span>
|
||||
<button class="btn btn-sm" id="btn-clear-log" style="padding:4px 8px;font-size:11px">清空</button>
|
||||
<span>${t('chatDebug.wsTestTitle')}</span>
|
||||
<button class="btn btn-sm" id="btn-clear-log" style="padding:4px 8px;font-size:11px">${t('chatDebug.btnClear')}</button>
|
||||
</div>
|
||||
<pre id="ws-log-content" style="font-size:11px;line-height:1.5;max-height:400px;overflow:auto;margin:0;color:var(--text-primary)"></pre>
|
||||
</div>
|
||||
<div id="network-log" style="display:none;margin-top:16px;background:var(--bg-secondary);border-radius:6px;padding:12px">
|
||||
<div style="font-weight:600;margin-bottom:8px;display:flex;justify-content:space-between;align-items:center">
|
||||
<span>网络请求日志(最近 100 条)</span>
|
||||
<span>${t('chatDebug.networkLogTitle')}</span>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn btn-sm" id="btn-refresh-network" style="padding:4px 8px;font-size:11px">刷新</button>
|
||||
<button class="btn btn-sm" id="btn-clear-network" style="padding:4px 8px;font-size:11px">清空</button>
|
||||
<button class="btn btn-sm" id="btn-refresh-network" style="padding:4px 8px;font-size:11px">${t('common.refresh')}</button>
|
||||
<button class="btn btn-sm" id="btn-clear-network" style="padding:4px 8px;font-size:11px">${t('chatDebug.btnClear')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="network-log-content" style="font-size:11px;line-height:1.5;max-height:400px;overflow:auto"></div>
|
||||
@@ -139,69 +140,69 @@ function renderDebugInfo(el, info) {
|
||||
// 总体状态概览
|
||||
const allOk = info.appState.openclawReady && info.appState.gatewayRunning && info.wsClient.gatewayReady
|
||||
html += `<div class="config-section" style="background:${allOk ? 'var(--success-bg)' : 'var(--warning-bg)'};border-left:3px solid ${allOk ? 'var(--success)' : 'var(--warning)'}">
|
||||
<div style="font-size:16px;font-weight:600;margin-bottom:8px">${allOk ? `${statusIcon('ok')} 系统正常` : `${statusIcon('warn')} 发现问题`}</div>
|
||||
<div style="color:var(--text-secondary);font-size:13px">${allOk ? '所有核心功能运行正常' : '部分功能异常,请查看下方详情'}</div>
|
||||
<div style="font-size:16px;font-weight:600;margin-bottom:8px">${allOk ? `${statusIcon('ok')} ${t('chatDebug.systemOk')}` : `${statusIcon('warn')} ${t('chatDebug.issuesFound')}`}</div>
|
||||
<div style="color:var(--text-secondary);font-size:13px">${allOk ? t('chatDebug.allFunctionsOk') : t('chatDebug.someFunctionsError')}</div>
|
||||
</div>`
|
||||
|
||||
// 应用状态
|
||||
html += `<div class="config-section">
|
||||
<div class="config-section-title">应用状态</div>
|
||||
<div class="config-section-title">${t('chatDebug.sectionAppState')}</div>
|
||||
<table class="debug-table">
|
||||
<tr><td>OpenClaw 就绪</td><td>${info.appState.openclawReady ? statusIcon('ok') : statusIcon('err')}</td></tr>
|
||||
<tr><td>Gateway 运行中</td><td>${info.appState.gatewayRunning ? statusIcon('ok') : statusIcon('err')}</td></tr>
|
||||
<tr><td>${t('chatDebug.openclawReady')}</td><td>${info.appState.openclawReady ? statusIcon('ok') : statusIcon('err')}</td></tr>
|
||||
<tr><td>${t('chatDebug.gatewayRunning')}</td><td>${info.appState.gatewayRunning ? statusIcon('ok') : statusIcon('err')}</td></tr>
|
||||
</table>
|
||||
</div>`
|
||||
|
||||
// WebSocket 状态
|
||||
html += `<div class="config-section">
|
||||
<div class="config-section-title">WebSocket 连接</div>
|
||||
<div class="config-section-title">${t('chatDebug.sectionWs')}</div>
|
||||
<table class="debug-table">
|
||||
<tr><td>连接状态</td><td>${info.wsClient.connected ? `${statusIcon('ok')} 已连接` : `${statusIcon('err')} 未连接`}</td></tr>
|
||||
<tr><td>握手状态</td><td>${info.wsClient.gatewayReady ? `${statusIcon('ok')} 已完成` : `${statusIcon('err')} 未完成`}</td></tr>
|
||||
<tr><td>会话密钥</td><td>${info.wsClient.sessionKey || '(空)'}</td></tr>
|
||||
<tr><td>${t('chatDebug.connStatus')}</td><td>${info.wsClient.connected ? `${statusIcon('ok')} ${t('chatDebug.connected')}` : `${statusIcon('err')} ${t('chatDebug.notConnected')}`}</td></tr>
|
||||
<tr><td>${t('chatDebug.handshakeStatus')}</td><td>${info.wsClient.gatewayReady ? `${statusIcon('ok')} ${t('chatDebug.completed')}` : `${statusIcon('err')} ${t('chatDebug.notCompleted')}`}</td></tr>
|
||||
<tr><td>${t('chatDebug.sessionKey')}</td><td>${info.wsClient.sessionKey || t('chatDebug.empty')}</td></tr>
|
||||
</table>
|
||||
</div>`
|
||||
|
||||
// Node.js 环境
|
||||
html += `<div class="config-section">
|
||||
<div class="config-section-title">Node.js 环境</div>`
|
||||
<div class="config-section-title">${t('chatDebug.sectionNode')}</div>`
|
||||
if (info.nodeError) {
|
||||
html += `<div style="color:var(--error)">${statusIcon('err')} ${escapeHtml(info.nodeError)}</div>`
|
||||
} else if (info.node) {
|
||||
html += `<table class="debug-table">
|
||||
<tr><td>安装状态</td><td>${info.node.installed ? `${statusIcon('ok')} 已安装` : `${statusIcon('err')} 未安装`}</td></tr>
|
||||
<tr><td>版本</td><td>${info.node.version || '(未知)'}</td></tr>
|
||||
<tr><td>${t('chatDebug.installStatus')}</td><td>${info.node.installed ? `${statusIcon('ok')} ${t('chatDebug.installed')}` : `${statusIcon('err')} ${t('chatDebug.notInstalled')}`}</td></tr>
|
||||
<tr><td>${t('chatDebug.version')}</td><td>${info.node.version || t('chatDebug.unknownLabel')}</td></tr>
|
||||
</table>`
|
||||
}
|
||||
html += `</div>`
|
||||
|
||||
// 版本信息
|
||||
html += `<div class="config-section">
|
||||
<div class="config-section-title">版本信息</div>`
|
||||
<div class="config-section-title">${t('chatDebug.sectionVersion')}</div>`
|
||||
if (info.versionError) {
|
||||
html += `<div style="color:var(--error)">${statusIcon('err')} ${escapeHtml(info.versionError)}</div>`
|
||||
} else if (info.version) {
|
||||
html += `<table class="debug-table">
|
||||
<tr><td>当前版本</td><td>${info.version.current || '(未知)'}</td></tr>
|
||||
<tr><td>推荐稳定版</td><td>${info.version.recommended || '(未检测)'}</td></tr>
|
||||
<tr><td>面板版本</td><td>${info.version.panel_version || '(未知)'}</td></tr>
|
||||
<tr><td>最新上游</td><td>${info.version.latest || '(未检测)'}</td></tr>
|
||||
<tr><td>偏离推荐版</td><td>${info.version.ahead_of_recommended ? `${statusIcon('warn')} 当前版本过高,建议回退` : info.version.is_recommended ? `${statusIcon('ok')} 已对齐` : `${statusIcon('warn')} 需要切换`}</td></tr>
|
||||
<tr><td>最新上游可用</td><td>${info.version.latest_update_available ? `${statusIcon('warn')} 有更新` : `${statusIcon('ok')} 无更新`}</td></tr>
|
||||
<tr><td>${t('chatDebug.currentVersion')}</td><td>${info.version.current || t('chatDebug.unknownLabel')}</td></tr>
|
||||
<tr><td>${t('chatDebug.recommendedVersion')}</td><td>${info.version.recommended || t('chatDebug.notDetected')}</td></tr>
|
||||
<tr><td>${t('chatDebug.panelVersion')}</td><td>${info.version.panel_version || t('chatDebug.unknownLabel')}</td></tr>
|
||||
<tr><td>${t('chatDebug.latestUpstream')}</td><td>${info.version.latest || t('chatDebug.notDetected')}</td></tr>
|
||||
<tr><td>${t('chatDebug.deviationFromRecommended')}</td><td>${info.version.ahead_of_recommended ? `${statusIcon('warn')} ${t('chatDebug.versionTooHigh')}` : info.version.is_recommended ? `${statusIcon('ok')} ${t('chatDebug.versionAligned')}` : `${statusIcon('warn')} ${t('chatDebug.versionNeedSwitch')}`}</td></tr>
|
||||
<tr><td>${t('chatDebug.latestAvailable')}</td><td>${info.version.latest_update_available ? `${statusIcon('warn')} ${t('chatDebug.hasUpdate')}` : `${statusIcon('ok')} ${t('chatDebug.noUpdate')}`}</td></tr>
|
||||
</table>`
|
||||
}
|
||||
html += `</div>`
|
||||
|
||||
// 配置文件
|
||||
html += `<div class="config-section">
|
||||
<div class="config-section-title">配置文件</div>`
|
||||
<div class="config-section-title">${t('chatDebug.sectionConfig')}</div>`
|
||||
if (info.configError) {
|
||||
html += `<div style="color:var(--error)">${statusIcon('err')} ${escapeHtml(info.configError)}</div>`
|
||||
} else if (info.config) {
|
||||
const gw = info.config.gateway || {}
|
||||
html += `<table class="debug-table">
|
||||
<tr><td>gateway.port</td><td>${gw.port || '(未设置)'}</td></tr>
|
||||
<tr><td>gateway.auth.token</td><td>${gw.auth?.token ? `${statusIcon('ok')} 已设置${typeof gw.auth.token === 'object' ? ' (SecretRef)' : ''}` : `${statusIcon('warn')} 未设置`}</td></tr>
|
||||
<tr><td>gateway.port</td><td>${gw.port || t('chatDebug.notSet')}</td></tr>
|
||||
<tr><td>gateway.auth.token</td><td>${gw.auth?.token ? `${statusIcon('ok')} ${t('chatDebug.set')}${typeof gw.auth.token === 'object' ? ' (SecretRef)' : ''}` : `${statusIcon('warn')} ${t('chatDebug.notSet')}`}</td></tr>
|
||||
<tr><td>gateway.enabled</td><td>${gw.enabled !== false ? statusIcon('ok') : statusIcon('err')}</td></tr>
|
||||
<tr><td>gateway.mode</td><td>${gw.mode || 'local'}</td></tr>
|
||||
</table>`
|
||||
@@ -210,35 +211,35 @@ function renderDebugInfo(el, info) {
|
||||
|
||||
// 服务状态
|
||||
html += `<div class="config-section">
|
||||
<div class="config-section-title">服务状态</div>`
|
||||
<div class="config-section-title">${t('chatDebug.sectionService')}</div>`
|
||||
if (info.servicesError) {
|
||||
html += `<div style="color:var(--error)">${statusIcon('err')} ${escapeHtml(info.servicesError)}</div>`
|
||||
} else if (info.services?.length > 0) {
|
||||
const svc = info.services[0]
|
||||
html += `<table class="debug-table">
|
||||
<tr><td>CLI 安装</td><td>${svc.cli_installed !== false ? `${statusIcon('ok')} 已安装` : `${statusIcon('err')} 未安装`}</td></tr>
|
||||
<tr><td>运行状态</td><td>${svc.running ? `${statusIcon('ok')} 运行中` : `${statusIcon('err')} 已停止`}</td></tr>
|
||||
<tr><td>进程 PID</td><td>${svc.pid || '(无)'}</td></tr>
|
||||
<tr><td>服务标签</td><td>${svc.label || '(未知)'}</td></tr>
|
||||
<tr><td>${t('chatDebug.cliInstall')}</td><td>${svc.cli_installed !== false ? `${statusIcon('ok')} ${t('chatDebug.installed')}` : `${statusIcon('err')} ${t('chatDebug.notInstalled')}`}</td></tr>
|
||||
<tr><td>${t('chatDebug.runStatus')}</td><td>${svc.running ? `${statusIcon('ok')} ${t('chatDebug.running')}` : `${statusIcon('err')} ${t('chatDebug.stopped')}`}</td></tr>
|
||||
<tr><td>${t('chatDebug.processPid')}</td><td>${svc.pid || t('chatDebug.none')}</td></tr>
|
||||
<tr><td>${t('chatDebug.serviceLabel')}</td><td>${svc.label || t('chatDebug.unknownLabel')}</td></tr>
|
||||
</table>`
|
||||
}
|
||||
html += `</div>`
|
||||
|
||||
// 设备密钥
|
||||
html += `<div class="config-section">
|
||||
<div class="config-section-title">设备密钥 & 握手签名</div>`
|
||||
<div class="config-section-title">${t('chatDebug.sectionDevice')}</div>`
|
||||
if (info.connectFrameError) {
|
||||
html += `<div style="color:var(--error)">${statusIcon('err')} ${escapeHtml(info.connectFrameError)}</div>`
|
||||
} else if (info.connectFrame) {
|
||||
const device = info.connectFrame.params?.device
|
||||
html += `<div style="color:var(--success);margin-bottom:8px">${statusIcon('ok')} 设备密钥生成成功</div>
|
||||
html += `<div style="color:var(--success);margin-bottom:8px">${statusIcon('ok')} ${t('chatDebug.deviceKeySuccess')}</div>
|
||||
<table class="debug-table">
|
||||
<tr><td>设备 ID</td><td style="font-size:10px;word-break:break-all">${device?.id || '(无)'}</td></tr>
|
||||
<tr><td>公钥</td><td style="font-size:10px;word-break:break-all">${device?.publicKey ? device.publicKey.substring(0, 32) + '...' : '(无)'}</td></tr>
|
||||
<tr><td>签名时间</td><td>${device?.signedAt || '(无)'}</td></tr>
|
||||
<tr><td>${t('chatDebug.deviceId')}</td><td style="font-size:10px;word-break:break-all">${device?.id || t('chatDebug.none')}</td></tr>
|
||||
<tr><td>${t('chatDebug.publicKey')}</td><td style="font-size:10px;word-break:break-all">${device?.publicKey ? device.publicKey.substring(0, 32) + '...' : t('chatDebug.none')}</td></tr>
|
||||
<tr><td>${t('chatDebug.signTime')}</td><td>${device?.signedAt || t('chatDebug.none')}</td></tr>
|
||||
</table>
|
||||
<details style="margin-top:8px">
|
||||
<summary style="cursor:pointer;color:var(--text-secondary);font-size:12px">查看完整 Connect Frame</summary>
|
||||
<summary style="cursor:pointer;color:var(--text-secondary);font-size:12px">${t('chatDebug.viewConnectFrame')}</summary>
|
||||
<pre style="background:var(--bg-secondary);padding:8px;border-radius:4px;overflow:auto;max-height:300px;font-size:11px">${escapeHtml(JSON.stringify(info.connectFrame, null, 2))}</pre>
|
||||
</details>`
|
||||
}
|
||||
@@ -246,41 +247,41 @@ function renderDebugInfo(el, info) {
|
||||
|
||||
// 诊断建议
|
||||
html += `<div class="config-section">
|
||||
<div class="config-section-title">诊断建议</div>
|
||||
<div class="config-section-title">${t('chatDebug.sectionDiagnosis')}</div>
|
||||
<ul style="margin:0;padding-left:20px;color:var(--text-secondary);font-size:13px">`
|
||||
|
||||
if (!info.node?.installed) {
|
||||
html += `<li style="color:var(--error);margin-bottom:6px">${statusIcon('err')} Node.js 未安装,请先安装 Node.js(<a href="https://nodejs.org/" target="_blank" rel="noopener">下载地址</a>)</li>`
|
||||
html += `<li style="color:var(--error);margin-bottom:6px">${statusIcon('err')} ${t('chatDebug.diagNodeNotInstalled')}</li>`
|
||||
}
|
||||
if (info.configError) {
|
||||
html += `<li style="color:var(--error);margin-bottom:6px">${statusIcon('err')} 配置文件不存在或损坏,请前往"初始设置"页面完成配置</li>`
|
||||
html += `<li style="color:var(--error);margin-bottom:6px">${statusIcon('err')} ${t('chatDebug.diagConfigMissing')}</li>`
|
||||
}
|
||||
if (info.servicesError || !info.services?.length || info.services[0]?.cli_installed === false) {
|
||||
html += `<li style="color:var(--error);margin-bottom:6px">${statusIcon('err')} OpenClaw CLI 未安装,请前往"初始设置"页面安装</li>`
|
||||
html += `<li style="color:var(--error);margin-bottom:6px">${statusIcon('err')} ${t('chatDebug.diagCliNotInstalled')}</li>`
|
||||
}
|
||||
if (info.services?.length > 0 && !info.services[0]?.running) {
|
||||
html += `<li style="color:var(--warning);margin-bottom:6px">${statusIcon('warn')} Gateway 未启动,请前往"服务管理"页面启动服务</li>`
|
||||
html += `<li style="color:var(--warning);margin-bottom:6px">${statusIcon('warn')} ${t('chatDebug.diagGatewayNotRunning')}</li>`
|
||||
}
|
||||
if (info.config && !info.config.gateway?.auth?.token) {
|
||||
html += `<li style="color:var(--warning);margin-bottom:6px">${statusIcon('warn')} Gateway token 未设置(本地开发可选,生产环境建议设置)</li>`
|
||||
html += `<li style="color:var(--warning);margin-bottom:6px">${statusIcon('warn')} ${t('chatDebug.diagTokenNotSet')}</li>`
|
||||
} else if (info.config && typeof info.config.gateway?.auth?.token === 'object') {
|
||||
html += `<li style="margin-bottom:6px">${statusIcon('ok')} Gateway token 通过环境变量/引用配置(SecretRef)</li>`
|
||||
html += `<li style="margin-bottom:6px">${statusIcon('ok')} ${t('chatDebug.diagTokenSecretRef')}</li>`
|
||||
}
|
||||
if (info.connectFrameError) {
|
||||
html += `<li style="color:var(--error);margin-bottom:6px">${statusIcon('err')} 设备密钥生成失败,请检查 Rust 后端日志</li>`
|
||||
html += `<li style="color:var(--error);margin-bottom:6px">${statusIcon('err')} ${t('chatDebug.diagDeviceKeyFailed')}</li>`
|
||||
}
|
||||
if (!info.wsClient.connected && info.services?.length > 0 && info.services[0]?.running) {
|
||||
html += `<li style="color:var(--warning);margin-bottom:6px">${statusIcon('warn')} Gateway 运行中但 WebSocket 未连接,常见原因:<strong>origin not allowed</strong>(Tauri origin 未在白名单)或端口 ${info.config?.gateway?.port || 18789} 被占用。点击“一键修复配对”可自动修复 origin 问题</li>`
|
||||
html += `<li style="color:var(--warning);margin-bottom:6px">${statusIcon('warn')} ${t('chatDebug.diagWsNotConnected', { port: info.config?.gateway?.port || 18789 })}</li>`
|
||||
}
|
||||
if (info.wsClient.connected && !info.wsClient.gatewayReady) {
|
||||
html += `<li style="color:var(--warning);margin-bottom:6px">${statusIcon('warn')} WebSocket 已连接但握手未完成,请检查 token 是否正确</li>`
|
||||
html += `<li style="color:var(--warning);margin-bottom:6px">${statusIcon('warn')} ${t('chatDebug.diagWsHandshakeFailed')}</li>`
|
||||
}
|
||||
if (allOk) {
|
||||
html += `<li style="color:var(--success);margin-bottom:6px">${statusIcon('ok')} 所有检测项正常,系统运行良好</li>`
|
||||
html += `<li style="color:var(--success);margin-bottom:6px">${statusIcon('ok')} ${t('chatDebug.diagAllOk')}</li>`
|
||||
}
|
||||
|
||||
html += `</ul></div>`
|
||||
html += `<div style="margin-top:16px;padding:8px;background:var(--bg-secondary);border-radius:4px;font-size:11px;color:var(--text-tertiary)">检测时间: ${info.timestamp}</div>`
|
||||
html += `<div style="margin-top:16px;padding:8px;background:var(--bg-secondary);border-radius:4px;font-size:11px;color:var(--text-tertiary)">${t('chatDebug.checkTime', { time: info.timestamp })}</div>`
|
||||
html += `</div>`
|
||||
|
||||
el.innerHTML = html
|
||||
@@ -300,11 +301,11 @@ async function handleDoctor(page, fix) {
|
||||
|
||||
if (btnCheck) btnCheck.disabled = true
|
||||
if (btnFix) btnFix.disabled = true
|
||||
if (fix && btnFix) btnFix.textContent = '修复中...'
|
||||
if (!fix && btnCheck) btnCheck.textContent = '诊断中...'
|
||||
if (fix && btnFix) btnFix.textContent = t('chatDebug.fixing')
|
||||
if (!fix && btnCheck) btnCheck.textContent = t('chatDebug.diagnosing')
|
||||
|
||||
outputDiv.style.display = 'block'
|
||||
pre.textContent = fix ? '正在运行 openclaw doctor --fix ...' : '正在运行 openclaw doctor ...'
|
||||
pre.textContent = fix ? t('chatDebug.runningDoctorFix') : t('chatDebug.runningDoctor')
|
||||
pre.style.color = 'var(--text-secondary)'
|
||||
|
||||
try {
|
||||
@@ -312,27 +313,27 @@ async function handleDoctor(page, fix) {
|
||||
let text = result.output || ''
|
||||
if (result.errors) text += '\n' + result.errors
|
||||
const fullText = text.trim()
|
||||
pre.textContent = fullText || (result.success ? '✓ 未发现问题' : '诊断完成')
|
||||
pre.textContent = fullText || (result.success ? t('chatDebug.noIssues') : t('chatDebug.diagDone'))
|
||||
pre.style.color = result.success ? 'var(--success)' : 'var(--warning)'
|
||||
if (fullText.includes('ERR_MODULE_NOT_FOUND') || fullText.includes('Cannot find module')) {
|
||||
appendDoctorTip(section, 'OpenClaw 安装可能已损坏', '检测到模块文件缺失,建议前往 <a href="#" data-nav="about" style="color:var(--primary);text-decoration:underline;font-weight:500">关于页面</a> 切换版本或重新安装 OpenClaw CLI。')
|
||||
toast('OpenClaw 安装损坏,建议前往「关于」页重新安装', 'warning')
|
||||
appendDoctorTip(section, t('chatDebug.installCorrupt'), t('chatDebug.installCorruptHint'))
|
||||
toast(t('chatDebug.installCorruptToast'), 'warning')
|
||||
} else if (fix && result.success) {
|
||||
toast('配置修复完成', 'success')
|
||||
toast(t('chatDebug.configFixDone'), 'success')
|
||||
} else if (fix) {
|
||||
toast('修复完成,部分问题可能需手动处理', 'warning')
|
||||
toast(t('chatDebug.configFixPartial'), 'warning')
|
||||
}
|
||||
} catch (e) {
|
||||
const errMsg = e?.message || String(e)
|
||||
pre.textContent = '执行失败: ' + errMsg
|
||||
pre.textContent = t('chatDebug.execFailed') + errMsg
|
||||
pre.style.color = 'var(--error)'
|
||||
if (errMsg.includes('ERR_MODULE_NOT_FOUND') || errMsg.includes('Cannot find module') || errMsg.includes('未找到')) {
|
||||
appendDoctorTip(section, 'OpenClaw CLI 不可用', '请前往 <a href="#" data-nav="about" style="color:var(--primary);text-decoration:underline;font-weight:500">关于页面</a> 安装或重新安装 OpenClaw。')
|
||||
appendDoctorTip(section, t('chatDebug.cliUnavailable'), t('chatDebug.cliUnavailableHint'))
|
||||
}
|
||||
toast('执行失败: ' + e, 'error')
|
||||
toast(t('chatDebug.execFailed') + e, 'error')
|
||||
} finally {
|
||||
if (btnCheck) { btnCheck.disabled = false; btnCheck.textContent = '诊断配置' }
|
||||
if (btnFix) { btnFix.disabled = false; btnFix.textContent = '自动修复' }
|
||||
if (btnCheck) { btnCheck.disabled = false; btnCheck.textContent = t('chatDebug.btnDiagConfig') }
|
||||
if (btnFix) { btnFix.disabled = false; btnFix.textContent = t('chatDebug.btnAutoFix') }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,7 +376,7 @@ function testWebSocket(page) {
|
||||
contentEl.innerHTML = ''
|
||||
}
|
||||
|
||||
addLog(`${icon('search', 14)} 开始 WebSocket 连接测试...`)
|
||||
addLog(`${icon('search', 14)} ${t('chatDebug.wsTestStart')}`)
|
||||
|
||||
// 关闭旧连接
|
||||
if (testWs) {
|
||||
@@ -391,79 +392,79 @@ function testWebSocket(page) {
|
||||
const wsHost = window.__TAURI_INTERNALS__ ? `127.0.0.1:${port}` : location.host
|
||||
const url = `ws://${wsHost}/ws?token=${encodeURIComponent(token)}`
|
||||
|
||||
addLog(`${icon('radio', 14)} 连接地址: ${url}`)
|
||||
addLog(`${icon('key', 14)} Token: ${token ? token.substring(0, 20) + '...' : '(空)'}`)
|
||||
addLog(`${icon('clock', 14)} 正在连接...`)
|
||||
addLog(`${icon('radio', 14)} ${t('chatDebug.wsAddress', { url })}`)
|
||||
addLog(`${icon('key', 14)} ${t('chatDebug.wsToken', { token: token ? token.substring(0, 20) + '...' : t('chatDebug.empty') })}`)
|
||||
addLog(`${icon('clock', 14)} ${t('chatDebug.wsConnecting')}`)
|
||||
|
||||
try {
|
||||
testWs = new WebSocket(url)
|
||||
|
||||
testWs.onopen = () => {
|
||||
addLog(`${statusIcon('ok', 14)} WebSocket 连接成功`)
|
||||
addLog(`${icon('clock', 14)} 等待 Gateway 发送 connect.challenge...`)
|
||||
addLog(`${statusIcon('ok', 14)} ${t('chatDebug.wsConnected')}`)
|
||||
addLog(`${icon('clock', 14)} ${t('chatDebug.wsWaitChallenge')}`)
|
||||
}
|
||||
|
||||
testWs.onmessage = (evt) => {
|
||||
try {
|
||||
const msg = JSON.parse(evt.data)
|
||||
addLog(`${icon('inbox', 14)} 收到消息: ${escapeHtml(JSON.stringify(msg, null, 2))}`)
|
||||
addLog(`${icon('inbox', 14)} ${t('chatDebug.wsReceivedMsg')}: ${escapeHtml(JSON.stringify(msg, null, 2))}`)
|
||||
|
||||
// 如果收到 challenge,尝试发送 connect frame
|
||||
if (msg.type === 'event' && msg.event === 'connect.challenge') {
|
||||
const nonce = msg.payload?.nonce || ''
|
||||
addLog(`${icon('lock', 14)} 收到 challenge, nonce: ${nonce}`)
|
||||
addLog(`${icon('clock', 14)} 生成 connect frame...`)
|
||||
addLog(`${icon('lock', 14)} ${t('chatDebug.wsReceivedChallenge')}: ${nonce}`)
|
||||
addLog(`${icon('clock', 14)} ${t('chatDebug.wsGeneratingFrame')}`)
|
||||
|
||||
api.createConnectFrame(nonce, token).then(frame => {
|
||||
addLog(`${statusIcon('ok', 14)} Connect frame 生成成功`)
|
||||
addLog(`${icon('send', 14)} 发送 connect frame: ${escapeHtml(JSON.stringify(frame, null, 2))}`)
|
||||
addLog(`${statusIcon('ok', 14)} ${t('chatDebug.wsFrameGenerated')}`)
|
||||
addLog(`${icon('send', 14)} ${t('chatDebug.wsSendingFrame')}: ${escapeHtml(JSON.stringify(frame, null, 2))}`)
|
||||
testWs.send(JSON.stringify(frame))
|
||||
}).catch(e => {
|
||||
addLog(`${statusIcon('err', 14)} 生成 connect frame 失败: ${e}`)
|
||||
addLog(`${statusIcon('err', 14)} ${t('chatDebug.wsFrameFailed')}: ${e}`)
|
||||
})
|
||||
}
|
||||
|
||||
// 如果收到 connect 响应
|
||||
if (msg.type === 'res' && msg.id?.startsWith('connect-')) {
|
||||
if (msg.ok) {
|
||||
addLog(`${statusIcon('ok', 14)} 握手成功!`)
|
||||
addLog(`${statusIcon('ok', 14)} ${t('chatDebug.wsHandshakeOk')}`)
|
||||
addLog(`${icon('bar-chart', 14)} Snapshot: ${escapeHtml(JSON.stringify(msg.payload, null, 2))}`)
|
||||
const sessionKey = msg.payload?.snapshot?.sessionDefaults?.mainSessionKey
|
||||
if (sessionKey) {
|
||||
addLog(`${icon('key', 14)} Session Key: ${sessionKey}`)
|
||||
}
|
||||
} else {
|
||||
addLog(`${statusIcon('err', 14)} 握手失败: ${msg.error?.message || msg.error?.code || '未知错误'}`)
|
||||
addLog(`${statusIcon('err', 14)} ${t('chatDebug.wsHandshakeFailed')}: ${msg.error?.message || msg.error?.code || t('common.unknown')}`)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
addLog(`${statusIcon('warn', 14)} 解析消息失败: ${e}`)
|
||||
addLog(`${icon('inbox', 14)} 原始数据: ${escapeHtml(evt.data)}`)
|
||||
addLog(`${statusIcon('warn', 14)} ${t('chatDebug.wsParseFailed')}: ${e}`)
|
||||
addLog(`${icon('inbox', 14)} ${t('chatDebug.wsRawData')}: ${escapeHtml(evt.data)}`)
|
||||
}
|
||||
}
|
||||
|
||||
testWs.onerror = (e) => {
|
||||
addLog(`${statusIcon('err', 14)} WebSocket 错误: ${e.type}`)
|
||||
addLog(`${statusIcon('err', 14)} ${t('chatDebug.wsError')}: ${e.type}`)
|
||||
}
|
||||
|
||||
testWs.onclose = (e) => {
|
||||
addLog(`${icon('plug', 14)} 连接关闭 - Code: ${e.code}, Reason: ${e.reason || '(空)'}`)
|
||||
addLog(`${icon('plug', 14)} ${t('chatDebug.wsClosed')} - Code: ${e.code}, Reason: ${e.reason || t('chatDebug.empty')}`)
|
||||
if (e.code === 1008) {
|
||||
addLog(`${statusIcon('err', 14)} origin not allowed (1008) - Gateway 拒绝了当前应用的 origin`)
|
||||
addLog(`${icon('lightbulb', 14)} 解决方法:点击“一键修复配对”,将自动将 tauri://localhost 加入白名单并重启 Gateway`)
|
||||
addLog(`${statusIcon('err', 14)} ${t('chatDebug.wsOriginRejected')}`)
|
||||
addLog(`${icon('lightbulb', 14)} ${t('chatDebug.wsOriginFix')}`)
|
||||
} else if (e.code === 4001) {
|
||||
addLog(`${statusIcon('err', 14)} 认证失败 (4001) - Token 可能不正确`)
|
||||
addLog(`${statusIcon('err', 14)} ${t('chatDebug.wsAuthFailed')}`)
|
||||
} else if (e.code === 1006) {
|
||||
addLog(`${statusIcon('warn', 14)} 异常关闭 (1006) - 可能是网络问题或 Gateway 主动断开`)
|
||||
addLog(`${statusIcon('warn', 14)} ${t('chatDebug.wsAbnormalClose')}`)
|
||||
}
|
||||
testWs = null
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
addLog(`${statusIcon('err', 14)} 创建 WebSocket 失败: ${e}`)
|
||||
addLog(`${statusIcon('err', 14)} ${t('chatDebug.wsCreateFailed')}: ${e}`)
|
||||
}
|
||||
}).catch(e => {
|
||||
addLog(`${statusIcon('err', 14)} 读取配置失败: ${e}`)
|
||||
addLog(`${statusIcon('err', 14)} ${t('chatDebug.wsConfigReadFailed')}: ${e}`)
|
||||
})
|
||||
|
||||
function addLog(msg) {
|
||||
@@ -502,7 +503,7 @@ function renderNetworkLog(contentEl) {
|
||||
const logs = getRequestLogs()
|
||||
|
||||
if (logs.length === 0) {
|
||||
contentEl.innerHTML = '<div style="color:var(--text-secondary);padding:8px">暂无请求记录</div>'
|
||||
contentEl.innerHTML = `<div style="color:var(--text-secondary);padding:8px">${t('chatDebug.noRequests')}</div>`
|
||||
return
|
||||
}
|
||||
|
||||
@@ -517,19 +518,19 @@ function renderNetworkLog(contentEl) {
|
||||
let html = `
|
||||
<div style="padding:8px;background:var(--bg-primary);border-radius:4px;margin-bottom:8px;font-size:12px">
|
||||
<div style="display:flex;gap:16px">
|
||||
<span>总请求: <strong>${total}</strong></span>
|
||||
<span>缓存命中: <strong>${cached}</strong></span>
|
||||
<span>平均耗时: <strong>${avgDuration.toFixed(0)}ms</strong></span>
|
||||
<span>${t('chatDebug.totalRequests')}: <strong>${total}</strong></span>
|
||||
<span>${t('chatDebug.cacheHit')}: <strong>${cached}</strong></span>
|
||||
<span>${t('chatDebug.avgDuration')}: <strong>${avgDuration.toFixed(0)}ms</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
<table class="debug-table" style="width:100%;font-size:11px">
|
||||
<thead>
|
||||
<tr style="background:var(--bg-primary)">
|
||||
<th style="padding:6px;text-align:left;width:80px">时间</th>
|
||||
<th style="padding:6px;text-align:left">命令</th>
|
||||
<th style="padding:6px;text-align:left;max-width:200px">参数</th>
|
||||
<th style="padding:6px;text-align:right;width:80px">耗时</th>
|
||||
<th style="padding:6px;text-align:center;width:60px">缓存</th>
|
||||
<th style="padding:6px;text-align:left;width:80px">${t('chatDebug.colTime')}</th>
|
||||
<th style="padding:6px;text-align:left">${t('chatDebug.colCommand')}</th>
|
||||
<th style="padding:6px;text-align:left;max-width:200px">${t('chatDebug.colArgs')}</th>
|
||||
<th style="padding:6px;text-align:right;width:80px">${t('chatDebug.colDuration')}</th>
|
||||
<th style="padding:6px;text-align:center;width:60px">${t('chatDebug.colCache')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -564,7 +565,7 @@ async function fixPairing(page) {
|
||||
const contentEl = page.querySelector('#ws-log-content')
|
||||
const fixBtn = page.querySelector('#btn-fix-pairing')
|
||||
|
||||
if (fixBtn) { fixBtn.disabled = true; fixBtn.textContent = '修复中...' }
|
||||
if (fixBtn) { fixBtn.disabled = true; fixBtn.textContent = t('chatDebug.fixing') }
|
||||
logEl.style.display = 'block'
|
||||
testLogs = []
|
||||
logEl.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
@@ -578,42 +579,42 @@ async function fixPairing(page) {
|
||||
}
|
||||
|
||||
try {
|
||||
addLog(`${icon('wrench', 14)} 开始修复配对问题...`)
|
||||
addLog(`${icon('wrench', 14)} ${t('chatDebug.fixStarting')}`)
|
||||
|
||||
// 1. 写入 paired.json + controlUi.allowedOrigins
|
||||
addLog(`${icon('edit', 14)} 正在写入设备配对信息 + Gateway origin 白名单...`)
|
||||
addLog(`${icon('edit', 14)} ${t('chatDebug.fixWritingPair')}`)
|
||||
const result = await api.autoPairDevice()
|
||||
addLog(`${statusIcon('ok', 14)} ${result}`)
|
||||
addLog(`${statusIcon('ok', 14)} 已将 tauri://localhost 加入 gateway.controlUi.allowedOrigins`)
|
||||
addLog(`${statusIcon('ok', 14)} ${t('chatDebug.fixOriginAdded')}`)
|
||||
|
||||
// 2. 停止 Gateway(确保旧进程完全退出,新进程能重新读取配置)
|
||||
addLog(`${icon('zap', 14)} 停止 Gateway 服务...`)
|
||||
addLog(`${icon('zap', 14)} ${t('chatDebug.fixStoppingGw')}`)
|
||||
try { await api.stopService('ai.openclaw.gateway') } catch {}
|
||||
addLog(`${icon('clock', 14)} 等待进程退出(3秒)...`)
|
||||
addLog(`${icon('clock', 14)} ${t('chatDebug.fixWaitExit')}`)
|
||||
await new Promise(resolve => setTimeout(resolve, 3000))
|
||||
|
||||
// 3. 启动 Gateway(重新加载 openclaw.json 配置)
|
||||
addLog(`${icon('zap', 14)} 启动 Gateway 服务...`)
|
||||
addLog(`${icon('zap', 14)} ${t('chatDebug.fixStartingGw')}`)
|
||||
await api.startService('ai.openclaw.gateway')
|
||||
addLog(`${statusIcon('ok', 14)} Gateway 启动命令已发送`)
|
||||
addLog(`${statusIcon('ok', 14)} ${t('chatDebug.fixGwStartSent')}`)
|
||||
|
||||
// 4. 等待 Gateway 就绪
|
||||
addLog(`${icon('clock', 14)} 等待 Gateway 就绪(5秒)...`)
|
||||
addLog(`${icon('clock', 14)} ${t('chatDebug.fixWaitReady')}`)
|
||||
await new Promise(resolve => setTimeout(resolve, 5000))
|
||||
|
||||
// 5. 检查 Gateway 状态
|
||||
addLog(`${icon('search', 14)} 检查 Gateway 状态...`)
|
||||
addLog(`${icon('search', 14)} ${t('chatDebug.fixCheckStatus')}`)
|
||||
const services = await api.getServicesStatus()
|
||||
const running = services?.[0]?.running
|
||||
|
||||
if (running) {
|
||||
addLog(`${statusIcon('ok', 14)} Gateway 已启动`)
|
||||
addLog(`${statusIcon('ok', 14)} ${t('chatDebug.fixGwStarted')}`)
|
||||
} else {
|
||||
addLog(`${statusIcon('warn', 14)} Gateway 可能还在启动中,请稍后手动测试`)
|
||||
addLog(`${statusIcon('warn', 14)} ${t('chatDebug.fixGwMaybeStarting')}`)
|
||||
}
|
||||
|
||||
// 6. 测试 WebSocket 连接
|
||||
addLog(`${icon('plug', 14)} 测试 WebSocket 连接...`)
|
||||
addLog(`${icon('plug', 14)} ${t('chatDebug.fixTestingWs')}`)
|
||||
const config = await api.readOpenclawConfig()
|
||||
const port = config?.gateway?.port || 18789
|
||||
const rawToken = config?.gateway?.auth?.token
|
||||
@@ -624,63 +625,62 @@ async function fixPairing(page) {
|
||||
const ws = new WebSocket(url)
|
||||
|
||||
ws.onopen = () => {
|
||||
addLog(`${statusIcon('ok', 14)} WebSocket 连接成功`)
|
||||
addLog(`${statusIcon('ok', 14)} ${t('chatDebug.wsConnected')}`)
|
||||
}
|
||||
|
||||
ws.onmessage = (evt) => {
|
||||
try {
|
||||
const msg = JSON.parse(evt.data)
|
||||
if (msg.type === 'event' && msg.event === 'connect.challenge') {
|
||||
addLog(`${statusIcon('ok', 14)} 收到 connect.challenge`)
|
||||
addLog(`${statusIcon('ok', 14)} ${t('chatDebug.fixReceivedChallenge')}`)
|
||||
const nonce = msg.payload?.nonce || ''
|
||||
|
||||
api.createConnectFrame(nonce, token).then(frame => {
|
||||
ws.send(JSON.stringify(frame))
|
||||
addLog(`${icon('send', 14)} 已发送 connect frame`)
|
||||
addLog(`${icon('send', 14)} ${t('chatDebug.fixFrameSent')}`)
|
||||
})
|
||||
}
|
||||
|
||||
if (msg.type === 'res' && msg.id?.startsWith('connect-')) {
|
||||
if (msg.ok) {
|
||||
addLog(`${statusIcon('ok', 14)} 握手成功!配对问题已修复!`)
|
||||
addLog(`${icon('lightbulb', 14)} 正在重新建立主应用 WebSocket 连接...`)
|
||||
addLog(`${statusIcon('ok', 14)} ${t('chatDebug.fixPairSuccess')}`)
|
||||
addLog(`${icon('lightbulb', 14)} ${t('chatDebug.fixReconnecting')}`)
|
||||
ws.close(1000)
|
||||
// 触发主应用的 wsClient 重连,让主界面正常工作
|
||||
wsClient.reconnect()
|
||||
setTimeout(() => loadDebugInfo(page), 2000)
|
||||
} else {
|
||||
const errMsg = msg.error?.message || msg.error?.code || '未知错误'
|
||||
addLog(`${statusIcon('err', 14)} 握手失败: ${errMsg}`)
|
||||
const errMsg = msg.error?.message || msg.error?.code || t('common.unknown')
|
||||
addLog(`${statusIcon('err', 14)} ${t('chatDebug.wsHandshakeFailed')}: ${errMsg}`)
|
||||
if (errMsg.includes('origin not allowed')) {
|
||||
addLog(`${icon('lightbulb', 14)} 原因:Gateway 拒绝了当前应用的 origin,需要重启 Gateway 再试`)
|
||||
addLog(`${icon('lightbulb', 14)} ${t('chatDebug.fixOriginStillRejected')}`)
|
||||
} else {
|
||||
addLog(`${icon('lightbulb', 14)} 建议:请手动前往“服务管理”页面重启 Gateway`)
|
||||
addLog(`${icon('lightbulb', 14)} ${t('chatDebug.fixSuggestManualRestart')}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
addLog(`${statusIcon('warn', 14)} 解析消息失败: ${e}`)
|
||||
addLog(`${statusIcon('warn', 14)} ${t('chatDebug.wsParseFailed')}: ${e}`)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
addLog(`${statusIcon('err', 14)} WebSocket 连接失败,请确认 Gateway 已在运行`)
|
||||
addLog(`${statusIcon('err', 14)} ${t('chatDebug.fixWsConnFailed')}`)
|
||||
}
|
||||
|
||||
ws.onclose = (e) => {
|
||||
if (e.code === 1008) {
|
||||
addLog(`${statusIcon('warn', 14)} 连接被拒绝 (1008) - Gateway 拒绝了当前 origin`)
|
||||
addLog(`${icon('lightbulb', 14)} 该问题应已被本次修复流程处理,请再次点击“一键修复配对”`)
|
||||
addLog(`${statusIcon('warn', 14)} ${t('chatDebug.fixOriginRejected1008')}`)
|
||||
addLog(`${icon('lightbulb', 14)} ${t('chatDebug.fixRetryHint')}`)
|
||||
} else if (e.code !== 1000) {
|
||||
addLog(`${statusIcon('warn', 14)} 连接关闭 - Code: ${e.code}`)
|
||||
addLog(`${statusIcon('warn', 14)} ${t('chatDebug.wsClosed')} - Code: ${e.code}`)
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
addLog(`${statusIcon('err', 14)} 修复失败: ${e}`)
|
||||
addLog(`${icon('lightbulb', 14)} 建议:请手动前往"服务管理"页面重启 Gateway`)
|
||||
addLog(`${statusIcon('err', 14)} ${t('chatDebug.fixFailed')}: ${e}`)
|
||||
addLog(`${icon('lightbulb', 14)} ${t('chatDebug.fixSuggestManualRestart')}`)
|
||||
} finally {
|
||||
if (fixBtn) { fixBtn.disabled = false; fixBtn.textContent = '一键修复配对' }
|
||||
if (fixBtn) { fixBtn.disabled = false; fixBtn.textContent = t('chatDebug.btnFixPairing') }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { saveMessage, saveMessages, getLocalMessages, isStorageAvailable } from
|
||||
import { toast } from '../components/toast.js'
|
||||
import { showModal, showConfirm } from '../components/modal.js'
|
||||
import { icon as svgIcon } from '../lib/icons.js'
|
||||
import { t } from '../lib/i18n.js'
|
||||
|
||||
const RENDER_THROTTLE = 30
|
||||
const STORAGE_SESSION_KEY = 'clawpanel-last-session'
|
||||
@@ -18,40 +19,40 @@ const STORAGE_SIDEBAR_KEY = 'clawpanel-chat-sidebar-open'
|
||||
const STORAGE_SESSION_NAMES_KEY = 'clawpanel-chat-session-names'
|
||||
|
||||
const COMMANDS = [
|
||||
{ title: '会话', commands: [
|
||||
{ cmd: '/new', desc: '新建会话', action: 'exec' },
|
||||
{ cmd: '/reset', desc: '重置当前会话', action: 'exec' },
|
||||
{ cmd: '/stop', desc: '停止生成', action: 'exec' },
|
||||
{ title: 'chat.cmdSession', commands: [
|
||||
{ cmd: '/new', desc: 'chat.cmdNewSession', action: 'exec' },
|
||||
{ cmd: '/reset', desc: 'chat.cmdResetSession', action: 'exec' },
|
||||
{ cmd: '/stop', desc: 'chat.cmdStopGen', action: 'exec' },
|
||||
]},
|
||||
{ title: '模型', commands: [
|
||||
{ cmd: '/model ', desc: '切换模型(输入模型名)', action: 'fill' },
|
||||
{ cmd: '/model list', desc: '查看可用模型', action: 'exec' },
|
||||
{ cmd: '/model status', desc: '当前模型状态', action: 'exec' },
|
||||
{ title: 'chat.cmdModel', commands: [
|
||||
{ cmd: '/model ', desc: 'chat.cmdSwitchModel', action: 'fill' },
|
||||
{ cmd: '/model list', desc: 'chat.cmdListModels', action: 'exec' },
|
||||
{ cmd: '/model status', desc: 'chat.cmdModelStatus', action: 'exec' },
|
||||
]},
|
||||
{ title: '思考模式', commands: [
|
||||
{ cmd: '/think off', desc: '关闭深度思考', action: 'exec' },
|
||||
{ cmd: '/think low', desc: '轻度思考', action: 'exec' },
|
||||
{ cmd: '/think medium', desc: '中度思考', action: 'exec' },
|
||||
{ cmd: '/think high', desc: '深度思考', action: 'exec' },
|
||||
{ title: 'chat.cmdThinkMode', commands: [
|
||||
{ cmd: '/think off', desc: 'chat.cmdThinkOff', action: 'exec' },
|
||||
{ cmd: '/think low', desc: 'chat.cmdThinkLow', action: 'exec' },
|
||||
{ cmd: '/think medium', desc: 'chat.cmdThinkMedium', action: 'exec' },
|
||||
{ cmd: '/think high', desc: 'chat.cmdThinkHigh', action: 'exec' },
|
||||
]},
|
||||
{ title: '快速模式', commands: [
|
||||
{ cmd: '/fast', desc: '切换快速模式(开/关)', action: 'exec' },
|
||||
{ cmd: '/fast on', desc: '开启快速模式(低延迟)', action: 'exec' },
|
||||
{ cmd: '/fast off', desc: '关闭快速模式', action: 'exec' },
|
||||
{ title: 'chat.cmdFastMode', commands: [
|
||||
{ cmd: '/fast', desc: 'chat.cmdFastToggle', action: 'exec' },
|
||||
{ cmd: '/fast on', desc: 'chat.cmdFastOn', action: 'exec' },
|
||||
{ cmd: '/fast off', desc: 'chat.cmdFastOff', action: 'exec' },
|
||||
]},
|
||||
{ title: '详细/推理', commands: [
|
||||
{ cmd: '/verbose off', desc: '关闭详细模式', action: 'exec' },
|
||||
{ cmd: '/verbose low', desc: '低详细度', action: 'exec' },
|
||||
{ cmd: '/verbose high', desc: '高详细度', action: 'exec' },
|
||||
{ cmd: '/reasoning off', desc: '关闭推理模式', action: 'exec' },
|
||||
{ cmd: '/reasoning low', desc: '轻度推理', action: 'exec' },
|
||||
{ cmd: '/reasoning medium', desc: '中度推理', action: 'exec' },
|
||||
{ cmd: '/reasoning high', desc: '深度推理', action: 'exec' },
|
||||
{ title: 'chat.cmdVerbose', commands: [
|
||||
{ cmd: '/verbose off', desc: 'chat.cmdVerboseOff', action: 'exec' },
|
||||
{ cmd: '/verbose low', desc: 'chat.cmdVerboseLow', action: 'exec' },
|
||||
{ cmd: '/verbose high', desc: 'chat.cmdVerboseHigh', action: 'exec' },
|
||||
{ cmd: '/reasoning off', desc: 'chat.cmdReasoningOff', action: 'exec' },
|
||||
{ cmd: '/reasoning low', desc: 'chat.cmdReasoningLow', action: 'exec' },
|
||||
{ cmd: '/reasoning medium', desc: 'chat.cmdReasoningMedium', action: 'exec' },
|
||||
{ cmd: '/reasoning high', desc: 'chat.cmdReasoningHigh', action: 'exec' },
|
||||
]},
|
||||
{ title: '信息', commands: [
|
||||
{ cmd: '/help', desc: '帮助信息', action: 'exec' },
|
||||
{ cmd: '/status', desc: '系统状态', action: 'exec' },
|
||||
{ cmd: '/context', desc: '上下文信息', action: 'exec' },
|
||||
{ title: 'chat.cmdInfo', commands: [
|
||||
{ cmd: '/help', desc: 'chat.cmdHelp', action: 'exec' },
|
||||
{ cmd: '/status', desc: 'chat.cmdStatus', action: 'exec' },
|
||||
{ cmd: '/context', desc: 'chat.cmdContext', action: 'exec' },
|
||||
]},
|
||||
]
|
||||
|
||||
@@ -115,12 +116,12 @@ export async function render() {
|
||||
page.innerHTML = `
|
||||
<div class="chat-sidebar" id="chat-sidebar">
|
||||
<div class="chat-sidebar-header">
|
||||
<span>会话列表</span>
|
||||
<span>${t('chat.sessionList')}</span>
|
||||
<div class="chat-sidebar-header-actions">
|
||||
<button class="chat-sidebar-btn" id="btn-toggle-sidebar" title="会话列表">
|
||||
<button class="chat-sidebar-btn" id="btn-toggle-sidebar" title="${t('chat.sessionList')}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
</button>
|
||||
<button class="chat-sidebar-btn" id="btn-new-session" title="新建会话">
|
||||
<button class="chat-sidebar-btn" id="btn-new-session" title="${t('chat.newSession')}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -130,25 +131,25 @@ export async function render() {
|
||||
<div class="chat-main">
|
||||
<div class="chat-header">
|
||||
<div class="chat-status">
|
||||
<button class="chat-toggle-sidebar" id="btn-toggle-sidebar-main" title="会话列表">
|
||||
<button class="chat-toggle-sidebar" id="btn-toggle-sidebar-main" title="${t('chat.sessionList')}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
</button>
|
||||
<span class="status-dot" id="chat-status-dot"></span>
|
||||
<span class="chat-title" id="chat-title">聊天</span>
|
||||
<span class="chat-title" id="chat-title">${t('chat.chatTitle')}</span>
|
||||
</div>
|
||||
<div class="chat-header-actions">
|
||||
<div class="chat-model-group">
|
||||
<select class="form-input" id="chat-model-select" title="切换当前会话模型" style="width:200px;max-width:28vw;padding:6px 10px;font-size:var(--font-size-xs)">
|
||||
<option value="">加载模型中...</option>
|
||||
<select class="form-input" id="chat-model-select" style="width:200px;max-width:28vw;padding:6px 10px;font-size:var(--font-size-xs)">
|
||||
<option value="">${t('chat.loadingModels')}</option>
|
||||
</select>
|
||||
<button class="btn btn-sm btn-ghost" id="btn-refresh-models" title="刷新模型列表">
|
||||
<button class="btn btn-sm btn-ghost" id="btn-refresh-models" title="${t('chat.refreshModels')}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-ghost" id="btn-cmd" title="快捷指令">
|
||||
<button class="btn btn-sm btn-ghost" id="btn-cmd" title="${t('chat.shortcuts')}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M18 3a3 3 0 00-3 3v12a3 3 0 003 3 3 3 0 003-3 3 3 0 00-3-3H6a3 3 0 00-3 3 3 3 0 003 3 3 3 0 003-3V6a3 3 0 00-3-3 3 3 0 00-3 3 3 3 0 003 3h12a3 3 0 003-3 3 3 0 00-3-3z"/></svg>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-ghost" id="btn-reset-session" title="重置会话">
|
||||
<button class="btn btn-sm btn-ghost" id="btn-reset-session" title="${t('chat.resetSession')}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 102.13-9.36L1 10"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -164,47 +165,47 @@ export async function render() {
|
||||
<div class="chat-attachments-preview" id="chat-attachments-preview" style="display:none"></div>
|
||||
<div class="chat-input-area">
|
||||
<input type="file" id="chat-file-input" accept="image/*" multiple style="display:none">
|
||||
<button class="chat-attach-btn" id="chat-attach-btn" title="上传图片">
|
||||
<button class="chat-attach-btn" id="chat-attach-btn" title="${t('chat.uploadImage')}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/></svg>
|
||||
</button>
|
||||
<div class="chat-input-wrapper">
|
||||
<textarea id="chat-input" rows="1" placeholder="输入消息,Enter 发送,/ 打开指令"></textarea>
|
||||
<textarea id="chat-input" rows="1" placeholder="${t('chat.inputPlaceholder')}"></textarea>
|
||||
</div>
|
||||
<button class="chat-send-btn" id="chat-send-btn" disabled>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
||||
</button>
|
||||
<button class="chat-hosted-btn btn btn-sm btn-ghost" id="chat-hosted-btn" title="托管 Agent">
|
||||
<button class="chat-hosted-btn btn btn-sm btn-ghost" id="chat-hosted-btn" title="${t('chat.hostedAgent')}">
|
||||
<span class="chat-hosted-label">⊕</span>
|
||||
<span class="chat-hosted-badge idle" id="chat-hosted-badge">托管</span>
|
||||
<span class="chat-hosted-badge idle" id="chat-hosted-badge">${t('chat.hostedBadge')}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="hosted-agent-panel" id="hosted-agent-panel" style="display:none">
|
||||
<div class="hosted-agent-header">
|
||||
<strong>托管 Agent</strong>
|
||||
<button class="hosted-agent-close" id="hosted-agent-close" title="关闭">×</button>
|
||||
<strong>${t('chat.hostedAgent')}</strong>
|
||||
<button class="hosted-agent-close" id="hosted-agent-close" title="${t('common.close')}">×</button>
|
||||
</div>
|
||||
<div class="hosted-agent-body">
|
||||
<div class="form-group">
|
||||
<label class="form-label" style="color:var(--accent);font-weight:600">任务目标</label>
|
||||
<textarea class="form-input hosted-agent-prompt" id="hosted-agent-prompt" rows="3" placeholder="例如:持续优化此仓库代码质量,直到没有可改进的地方"></textarea>
|
||||
<div class="form-hint">托管 Agent 会持续引导 OpenClaw 完成此目标。模型使用 <a href="#/assistant" class="hosted-agent-link">AI 助手</a> 的配置。</div>
|
||||
<label class="form-label" style="color:var(--accent);font-weight:600">${t('chat.taskGoal')}</label>
|
||||
<textarea class="form-input hosted-agent-prompt" id="hosted-agent-prompt" rows="3" placeholder="${t('chat.taskGoalPlaceholder')}"></textarea>
|
||||
<div class="form-hint">${t('chat.hostedHint')}</div>
|
||||
</div>
|
||||
<div class="ha-slider-group">
|
||||
<div class="ha-slider-label">最大回复次数 <span class="ha-slider-val" id="ha-steps-val">50</span></div>
|
||||
<div class="ha-slider-label">${t('chat.maxReplies')} <span class="ha-slider-val" id="ha-steps-val">50</span></div>
|
||||
<input type="range" class="ha-slider" id="hosted-agent-max-steps" min="5" max="205" step="5" value="50">
|
||||
<div class="ha-slider-ticks"><span>5</span><span>50</span><span>100</span><span>200</span><span>∞</span></div>
|
||||
</div>
|
||||
<div class="ha-timer-group">
|
||||
<div class="ha-timer-header">
|
||||
<span>定时自动停止</span>
|
||||
<span>${t('chat.timerAutoStop')}</span>
|
||||
<label class="ha-toggle"><input type="checkbox" id="hosted-agent-timer-on"><span class="ha-toggle-track"></span></label>
|
||||
</div>
|
||||
<div class="ha-timer-body" id="ha-timer-body" style="display:none">
|
||||
<input type="range" class="ha-slider" id="hosted-agent-auto-stop" min="5" max="120" step="5" value="30">
|
||||
<div class="ha-slider-ticks"><span>5分</span><span>30分</span><span>60分</span><span>120分</span></div>
|
||||
<div class="ha-slider-ticks"><span>5m</span><span>30m</span><span>60m</span><span>120m</span></div>
|
||||
<div class="ha-countdown" id="ha-countdown" style="display:none">
|
||||
<div class="ha-countdown-bar"><div class="ha-countdown-fill" id="ha-countdown-fill"></div></div>
|
||||
<span class="ha-countdown-text" id="ha-countdown-text">剩余 --:--</span>
|
||||
<span class="ha-countdown-text" id="ha-countdown-text">${t('chat.remaining')} --:--</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -212,23 +213,23 @@ export async function render() {
|
||||
<input type="hidden" id="hosted-agent-retry" value="2">
|
||||
</div>
|
||||
<div class="hosted-agent-actions">
|
||||
<button class="btn btn-primary" id="hosted-agent-save" style="flex:1">▶ 启动托管</button>
|
||||
<button class="btn btn-primary" id="hosted-agent-save" style="flex:1">${t('chat.startHosted')}</button>
|
||||
</div>
|
||||
<div class="hosted-agent-footer" id="hosted-agent-status">就绪</div>
|
||||
<div class="hosted-agent-footer" id="hosted-agent-status">${t('chat.ready')}</div>
|
||||
</div>
|
||||
<div class="chat-disconnect-bar" id="chat-disconnect-bar" style="display:none">连接已断开,正在重连...</div>
|
||||
<div class="chat-disconnect-bar" id="chat-disconnect-bar" style="display:none">${t('chat.disconnected')}</div>
|
||||
<div class="chat-connect-overlay" id="chat-connect-overlay" style="display:none">
|
||||
<div class="chat-connect-card">
|
||||
<div class="chat-connect-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="48" height="48"><path d="M8.5 14.5A2.5 2.5 0 0011 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 11-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 002.5 2.5z"/></svg>
|
||||
</div>
|
||||
<div class="chat-connect-title">Gateway 连接未就绪</div>
|
||||
<div class="chat-connect-desc" id="chat-connect-desc">正在连接 Gateway...</div>
|
||||
<div class="chat-connect-title">${t('chat.gatewayNotReady')}</div>
|
||||
<div class="chat-connect-desc" id="chat-connect-desc">${t('chat.connectingGateway')}</div>
|
||||
<div class="chat-connect-actions">
|
||||
<button class="btn btn-primary btn-sm" id="btn-fix-connect">修复并重连</button>
|
||||
<button class="btn btn-secondary btn-sm" id="btn-goto-gateway">Gateway 设置</button>
|
||||
<button class="btn btn-primary btn-sm" id="btn-fix-connect">${t('chat.fixAndReconnect')}</button>
|
||||
<button class="btn btn-secondary btn-sm" id="btn-goto-gateway">${t('chat.gatewaySettings')}</button>
|
||||
</div>
|
||||
<div class="chat-connect-hint">首次使用?请确保 Gateway 已启动,或点击「修复并重连」自动修复配置</div>
|
||||
<div class="chat-connect-hint">${t('chat.firstUseHint')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -283,11 +284,11 @@ function showPageGuide(container) {
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="28" height="28"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
|
||||
</div>
|
||||
<div class="chat-guide-content">
|
||||
<b>你正在使用「实时聊天」</b>
|
||||
<p>此页面通过 <b>Gateway</b> 连接 OpenClaw 的 AI Agent,对话由你部署的 OpenClaw 服务处理。</p>
|
||||
<p style="opacity:0.7;font-size:11px">如需使用 ClawPanel 内置 AI 助手(独立于 OpenClaw),请前往左侧菜单「AI 助手」页面。</p>
|
||||
<b>${t('chat.guideTitle')}</b>
|
||||
<p>${t('chat.guideDesc')}</p>
|
||||
<p style="opacity:0.7;font-size:11px">${t('chat.guideHint')}</p>
|
||||
</div>
|
||||
<button class="chat-guide-close" title="知道了">×</button>
|
||||
<button class="chat-guide-close" title="${t('chat.guideClose')}">×</button>
|
||||
</div>
|
||||
`
|
||||
guide.querySelector('.chat-guide-close').onclick = () => {
|
||||
@@ -390,12 +391,12 @@ function bindEvents(page) {
|
||||
async function loadModelOptions(showToast = false) {
|
||||
if (!_modelSelectEl) return
|
||||
// 显示加载状态
|
||||
_modelSelectEl.innerHTML = '<option value="">加载模型中...</option>'
|
||||
_modelSelectEl.innerHTML = `<option value="">${t('chat.loadingModels')}</option>`
|
||||
_modelSelectEl.disabled = true
|
||||
try {
|
||||
invalidate('read_openclaw_config')
|
||||
const configPromise = api.readOpenclawConfig()
|
||||
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('读取超时(8s),请检查配置文件')), 8000))
|
||||
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('timeout(8s)')), 8000))
|
||||
const config = await Promise.race([configPromise, timeoutPromise])
|
||||
const providers = config?.models?.providers || {}
|
||||
_primaryModel = config?.agents?.defaults?.model?.primary || ''
|
||||
@@ -419,30 +420,30 @@ async function loadModelOptions(showToast = false) {
|
||||
const saved = localStorage.getItem(STORAGE_MODEL_KEY) || ''
|
||||
_selectedModel = models.includes(saved) ? saved : (_primaryModel || models[0] || '')
|
||||
renderModelSelect()
|
||||
if (showToast) toast(`已刷新,共 ${models.length} 个模型`, 'success')
|
||||
if (showToast) toast(`${t('chat.refreshModels')} (${models.length})`, 'success')
|
||||
} catch (e) {
|
||||
_availableModels = []
|
||||
_primaryModel = ''
|
||||
_selectedModel = ''
|
||||
renderModelSelect(`加载失败: ${e.message || e}`)
|
||||
if (showToast) toast('加载模型失败: ' + (e.message || e), 'error')
|
||||
renderModelSelect(`${t('common.loadFailed')}: ${e.message || e}`)
|
||||
if (showToast) toast(`${t('common.loadFailed')}: ${e.message || e}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
function renderModelSelect(errorText = '') {
|
||||
if (!_modelSelectEl) return
|
||||
if (!_availableModels.length) {
|
||||
_modelSelectEl.innerHTML = `<option value="">${escapeAttr(errorText || '未配置模型')}</option>`
|
||||
_modelSelectEl.innerHTML = `<option value="">${escapeAttr(errorText || t('chat.loadingModels'))}</option>`
|
||||
_modelSelectEl.disabled = true
|
||||
_modelSelectEl.title = errorText || '请先到模型配置页面添加模型'
|
||||
_modelSelectEl.title = errorText || ''
|
||||
return
|
||||
}
|
||||
_modelSelectEl.disabled = _isApplyingModel
|
||||
_modelSelectEl.innerHTML = _availableModels.map(full => {
|
||||
const suffix = full === _primaryModel ? '(主模型)' : ''
|
||||
const suffix = full === _primaryModel ? ` ${t('chat.defaultSuffix')}` : ''
|
||||
return `<option value="${escapeAttr(full)}" ${full === _selectedModel ? 'selected' : ''}>${full}${suffix}</option>`
|
||||
}).join('')
|
||||
_modelSelectEl.title = _selectedModel ? `切换当前会话模型:${_selectedModel}` : '切换当前会话模型'
|
||||
_modelSelectEl.title = _selectedModel || ''
|
||||
}
|
||||
|
||||
function escapeAttr(str) {
|
||||
@@ -474,20 +475,20 @@ function setSidebarOpen(open) {
|
||||
|
||||
async function applySelectedModel() {
|
||||
if (!_selectedModel) {
|
||||
toast('请先选择模型', 'warning')
|
||||
toast(t('chat.loadingModels'), 'warning')
|
||||
return
|
||||
}
|
||||
if (!wsClient.gatewayReady || !_sessionKey) {
|
||||
toast('Gateway 未就绪,连接成功后再切换模型', 'warning')
|
||||
toast(t('chat.gatewayNotReadySend'), 'warning')
|
||||
return
|
||||
}
|
||||
_isApplyingModel = true
|
||||
renderModelSelect()
|
||||
try {
|
||||
await wsClient.chatSend(_sessionKey, `/model ${_selectedModel}`)
|
||||
toast(`已切换当前会话模型为 ${_selectedModel}`, 'success')
|
||||
toast(`${_selectedModel}`, 'success')
|
||||
} catch (e) {
|
||||
toast('切换模型失败: ' + (e.message || e), 'error')
|
||||
toast(`${t('chat.sendFailed')}${e.message || e}`, 'error')
|
||||
} finally {
|
||||
_isApplyingModel = false
|
||||
renderModelSelect()
|
||||
@@ -503,21 +504,21 @@ function bindConnectOverlay(page) {
|
||||
if (fixBtn) {
|
||||
fixBtn.addEventListener('click', async () => {
|
||||
fixBtn.disabled = true
|
||||
fixBtn.textContent = '修复中...'
|
||||
fixBtn.textContent = t('chat.fixing')
|
||||
const desc = document.getElementById('chat-connect-desc')
|
||||
try {
|
||||
if (desc) desc.textContent = '正在写入配置并重载 Gateway...'
|
||||
if (desc) desc.textContent = t('chat.writingConfig')
|
||||
await api.autoPairDevice()
|
||||
await api.reloadGateway()
|
||||
if (desc) desc.textContent = '修复完成,正在重连...'
|
||||
if (desc) desc.textContent = t('chat.fixDoneReconnecting')
|
||||
// 断开旧连接,重新发起
|
||||
wsClient.disconnect()
|
||||
setTimeout(() => connectGateway(), 3000)
|
||||
} catch (e) {
|
||||
if (desc) desc.textContent = '修复失败: ' + (e.message || e)
|
||||
if (desc) desc.textContent = `${t('chat.fixFailed')}${e.message || e}`
|
||||
} finally {
|
||||
fixBtn.disabled = false
|
||||
fixBtn.textContent = '修复并重连'
|
||||
fixBtn.textContent = t('chat.fixAndReconnect')
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -535,11 +536,11 @@ async function handleFileSelect(e) {
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast('仅支持图片文件', 'warning')
|
||||
toast(t('chat.imageOnly'), 'warning')
|
||||
continue
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast(`${file.name} 超过 5MB 限制`, 'warning')
|
||||
toast(`${file.name} > 5MB`, 'warning')
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -553,7 +554,7 @@ async function handleFileSelect(e) {
|
||||
})
|
||||
renderAttachments()
|
||||
} catch (e) {
|
||||
toast(`读取 ${file.name} 失败`, 'error')
|
||||
toast(`${t('chat.readFileFailed')} ${file.name}`, 'error')
|
||||
}
|
||||
}
|
||||
_fileInputEl.value = ''
|
||||
@@ -567,12 +568,12 @@ async function handlePaste(e) {
|
||||
for (const item of imageItems) {
|
||||
const file = item.getAsFile()
|
||||
if (!file) continue
|
||||
if (file.size > 5 * 1024 * 1024) { toast('粘贴的图片超过 5MB 限制', 'warning'); continue }
|
||||
if (file.size > 5 * 1024 * 1024) { toast(t('chat.imageSizeLimit'), 'warning'); continue }
|
||||
try {
|
||||
const base64 = await fileToBase64(file)
|
||||
_attachments.push({ type: 'image', mimeType: file.type || 'image/png', fileName: `paste-${Date.now()}.png`, content: base64 })
|
||||
renderAttachments()
|
||||
} catch (_) { toast('读取粘贴图片失败', 'error') }
|
||||
} catch (_) { toast(t('chat.readFileFailed'), 'error') }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -582,7 +583,7 @@ function fileToBase64(file) {
|
||||
reader.onload = () => {
|
||||
const dataUrl = reader.result
|
||||
const match = /^data:[^;]+;base64,(.+)$/.exec(dataUrl)
|
||||
if (!match) { reject(new Error('无效的数据 URL')); return }
|
||||
if (!match) { reject(new Error('invalid data URL')); return }
|
||||
resolve(match[1])
|
||||
}
|
||||
reader.onerror = reject
|
||||
@@ -641,14 +642,14 @@ async function connectGateway() {
|
||||
if (bar) bar.style.display = 'none'
|
||||
if (overlay) {
|
||||
overlay.style.display = 'flex'
|
||||
if (desc) desc.textContent = errorMsg || '连接 Gateway 失败'
|
||||
if (desc) desc.textContent = errorMsg || t('chat.connectFailed')
|
||||
}
|
||||
} else if (status === 'reconnecting' || status === 'disconnected') {
|
||||
// 首次连接或多次重连失败时,显示引导遮罩而非底部小条
|
||||
if (!_hasEverConnected) {
|
||||
if (overlay) { overlay.style.display = 'flex'; if (desc) desc.textContent = '正在连接 Gateway...' }
|
||||
if (overlay) { overlay.style.display = 'flex'; if (desc) desc.textContent = t('chat.connectingGateway') }
|
||||
} else {
|
||||
if (bar) { bar.textContent = '连接已断开,正在重连...'; bar.style.display = 'flex' }
|
||||
if (bar) { bar.textContent = t('chat.disconnected'); bar.style.display = 'flex' }
|
||||
}
|
||||
} else {
|
||||
if (bar) bar.style.display = 'none'
|
||||
@@ -662,7 +663,7 @@ async function connectGateway() {
|
||||
if (overlay) {
|
||||
overlay.style.display = 'flex'
|
||||
const desc = document.getElementById('chat-connect-desc')
|
||||
if (desc) desc.textContent = err.message || '连接失败'
|
||||
if (desc) desc.textContent = err.message || t('chat.connectFailed')
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -706,7 +707,7 @@ async function connectGateway() {
|
||||
const token = gw.auth?.token || gw.authToken || ''
|
||||
wsClient.connect(host, token)
|
||||
} catch (e) {
|
||||
toast('读取配置失败: ' + e.message, 'error')
|
||||
toast(`${t('common.loadFailed')}: ${e.message}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -726,7 +727,7 @@ async function refreshSessionList() {
|
||||
function renderSessionList(sessions) {
|
||||
if (!_sessionListEl) return
|
||||
if (!sessions.length) {
|
||||
_sessionListEl.innerHTML = '<div class="chat-session-empty">暂无会话</div>'
|
||||
_sessionListEl.innerHTML = `<div class="chat-session-empty">${t('chat.noSessions')}</div>`
|
||||
return
|
||||
}
|
||||
sessions.sort((a, b) => (b.updatedAt || b.lastActivity || 0) - (a.updatedAt || a.lastActivity || 0))
|
||||
@@ -741,12 +742,12 @@ function renderSessionList(sessions) {
|
||||
const displayLabel = getDisplayLabel(key) || label
|
||||
return `<div class="chat-session-card${active}" data-key="${escapeAttr(key)}">
|
||||
<div class="chat-session-card-header">
|
||||
<span class="chat-session-label" title="双击重命名">${escapeAttr(displayLabel)}</span>
|
||||
<button class="chat-session-del" data-del="${escapeAttr(key)}" title="删除">×</button>
|
||||
<span class="chat-session-label" title="${t('chat.doubleClickRename')}">${escapeAttr(displayLabel)}</span>
|
||||
<button class="chat-session-del" data-del="${escapeAttr(key)}" title="${t('common.delete')}">×</button>
|
||||
</div>
|
||||
<div class="chat-session-card-meta">
|
||||
${agentId && agentId !== 'main' ? `<span class="chat-session-agent">${escapeAttr(agentId)}</span>` : ''}
|
||||
${msgCount > 0 ? `<span>${msgCount} 条消息</span>` : ''}
|
||||
${msgCount > 0 ? `<span>${msgCount} msgs</span>` : ''}
|
||||
${timeStr ? `<span>${timeStr}</span>` : ''}
|
||||
</div>
|
||||
</div>`
|
||||
@@ -773,10 +774,10 @@ function formatSessionTime(ts) {
|
||||
if (isNaN(d.getTime())) return ''
|
||||
const now = new Date()
|
||||
const diffMs = now - d
|
||||
if (diffMs < 60000) return '刚刚'
|
||||
if (diffMs < 3600000) return Math.floor(diffMs / 60000) + ' 分钟前'
|
||||
if (diffMs < 86400000) return Math.floor(diffMs / 3600000) + ' 小时前'
|
||||
if (diffMs < 604800000) return Math.floor(diffMs / 86400000) + ' 天前'
|
||||
if (diffMs < 60000) return t('chat.justNow')
|
||||
if (diffMs < 3600000) return t('chat.minutesAgo', { n: Math.floor(diffMs / 60000) })
|
||||
if (diffMs < 86400000) return t('chat.hoursAgo', { n: Math.floor(diffMs / 3600000) })
|
||||
if (diffMs < 604800000) return t('chat.daysAgo', { n: Math.floor(diffMs / 86400000) })
|
||||
return `${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
@@ -787,10 +788,10 @@ function parseSessionAgent(key) {
|
||||
|
||||
function parseSessionLabel(key) {
|
||||
const parts = (key || '').split(':')
|
||||
if (parts.length < 3) return key || '未知'
|
||||
if (parts.length < 3) return key || t('common.unknown')
|
||||
const agent = parts[1] || 'main'
|
||||
const channel = parts.slice(2).join(':')
|
||||
if (agent === 'main' && channel === 'main') return '主会话'
|
||||
if (agent === 'main' && channel === 'main') return t('chat.mainSession')
|
||||
if (agent === 'main') return channel
|
||||
return `${agent} / ${channel}`
|
||||
}
|
||||
@@ -812,27 +813,27 @@ async function showNewSessionDialog() {
|
||||
|
||||
// 先用默认选项立即显示弹窗
|
||||
const initialOptions = [
|
||||
{ value: 'main', label: 'main (默认)' },
|
||||
{ value: '__new__', label: '+ 新建 Agent' }
|
||||
{ value: 'main', label: `main ${t('chat.defaultSuffix')}` },
|
||||
{ value: '__new__', label: `+ ${t('chat.newAgent')}` }
|
||||
]
|
||||
|
||||
showModal({
|
||||
title: '新建会话',
|
||||
title: t('chat.newSession'),
|
||||
fields: [
|
||||
{ name: 'name', label: '会话名称', value: '', placeholder: '例如:翻译助手' },
|
||||
{ name: 'name', label: t('chat.sessionName'), value: '', placeholder: t('chat.sessionNamePlaceholder') },
|
||||
{ name: 'agent', label: 'Agent', type: 'select', value: defaultAgent, options: initialOptions },
|
||||
],
|
||||
onConfirm: (result) => {
|
||||
const name = (result.name || '').trim()
|
||||
if (!name) { toast('请输入会话名称', 'warning'); return }
|
||||
if (!name) { toast(t('chat.enterSessionName'), 'warning'); return }
|
||||
const agent = result.agent || defaultAgent
|
||||
if (agent === '__new__') {
|
||||
navigate('/agents')
|
||||
toast('请在 Agent 管理页面创建新 Agent', 'info')
|
||||
toast(t('chat.createAgentHint'), 'info')
|
||||
return
|
||||
}
|
||||
switchSession(`agent:${agent}:${name}`)
|
||||
toast('会话已创建', 'success')
|
||||
toast(t('chat.sessionCreated'), 'success')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -841,9 +842,9 @@ async function showNewSessionDialog() {
|
||||
const agents = await api.listAgents()
|
||||
const agentOptions = agents.map(a => ({
|
||||
value: a.id,
|
||||
label: `${a.id}${a.isDefault ? ' (默认)' : ''}${a.identityName ? ' — ' + a.identityName.split(',')[0] : ''}`
|
||||
label: `${a.id}${a.isDefault ? ` ${t('chat.defaultSuffix')}` : ''}${a.identityName ? ' — ' + a.identityName.split(',')[0] : ''}`
|
||||
}))
|
||||
agentOptions.push({ value: '__new__', label: '+ 新建 Agent' })
|
||||
agentOptions.push({ value: '__new__', label: `+ ${t('chat.newAgent')}` })
|
||||
|
||||
// 更新弹窗中的下拉框选项
|
||||
const selectEl = document.querySelector('.modal-overlay [data-name="agent"]')
|
||||
@@ -860,33 +861,33 @@ async function showNewSessionDialog() {
|
||||
|
||||
async function deleteSession(key) {
|
||||
const mainKey = wsClient.snapshot?.sessionDefaults?.mainSessionKey || 'agent:main:main'
|
||||
if (key === mainKey) { toast('主会话不能删除', 'warning'); return }
|
||||
if (key === mainKey) { toast(t('chat.cannotDeleteMain'), 'warning'); return }
|
||||
const label = parseSessionLabel(key)
|
||||
const yes = await showConfirm(`确定删除会话「${label}」?`)
|
||||
const yes = await showConfirm(t('chat.confirmDeleteSession', { label }))
|
||||
if (!yes) return
|
||||
try {
|
||||
await wsClient.sessionsDelete(key)
|
||||
toast('会话已删除', 'success')
|
||||
toast(t('chat.sessionDeleted'), 'success')
|
||||
if (key === _sessionKey) switchSession(mainKey)
|
||||
else refreshSessionList()
|
||||
} catch (e) {
|
||||
toast('删除失败: ' + e.message, 'error')
|
||||
toast(`${t('common.operationFailed')}: ${e.message}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function resetCurrentSession() {
|
||||
if (!_sessionKey) return
|
||||
const label = getDisplayLabel(_sessionKey)
|
||||
const yes = await showConfirm(`确定要重置会话「${label}」吗?\n\n重置后将清空该会话的所有聊天记录,此操作不可撤销。`)
|
||||
const yes = await showConfirm(t('chat.confirmResetSession', { label }))
|
||||
if (!yes) return
|
||||
try {
|
||||
await wsClient.sessionsReset(_sessionKey)
|
||||
clearMessages()
|
||||
_lastHistoryHash = ''
|
||||
appendSystemMessage('会话已重置')
|
||||
toast('会话已重置', 'success')
|
||||
appendSystemMessage(t('chat.sessionResetDone'))
|
||||
toast(t('chat.sessionResetDone'), 'success')
|
||||
} catch (e) {
|
||||
toast('重置失败: ' + e.message, 'error')
|
||||
toast(`${t('common.operationFailed')}: ${e.message}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -915,7 +916,7 @@ function renameSession(key, labelEl) {
|
||||
const newName = input.value.trim()
|
||||
if (newName && newName !== parseSessionLabel(key)) {
|
||||
setSessionName(key, newName)
|
||||
toast('会话已重命名', 'success')
|
||||
toast(t('chat.sessionRenamed'), 'success')
|
||||
} else if (!newName || newName === parseSessionLabel(key)) {
|
||||
setSessionName(key, '') // clear custom name
|
||||
}
|
||||
@@ -936,11 +937,11 @@ function showCmdPanel() {
|
||||
if (!_cmdPanelEl) return
|
||||
let html = ''
|
||||
for (const group of COMMANDS) {
|
||||
html += `<div class="cmd-group-title">${group.title}</div>`
|
||||
html += `<div class="cmd-group-title">${t(group.title)}</div>`
|
||||
for (const c of group.commands) {
|
||||
html += `<div class="cmd-item" data-cmd="${c.cmd}" data-action="${c.action}">
|
||||
<span class="cmd-name">${c.cmd}</span>
|
||||
<span class="cmd-desc">${c.desc}</span>
|
||||
<span class="cmd-desc">${t(c.desc)}</span>
|
||||
</div>`
|
||||
}
|
||||
}
|
||||
@@ -976,7 +977,7 @@ function sendMessage() {
|
||||
const text = _textarea.value.trim()
|
||||
if (!text && !_attachments.length) return
|
||||
if (!wsClient.gatewayReady || !_sessionKey) {
|
||||
toast('Gateway 未就绪,连接成功后再发送', 'warning')
|
||||
toast(t('chat.gatewayNotReadySend'), 'warning')
|
||||
return
|
||||
}
|
||||
hideCmdPanel()
|
||||
@@ -992,7 +993,7 @@ function sendMessage() {
|
||||
|
||||
async function doSend(text, attachments = []) {
|
||||
if (!wsClient.gatewayReady || !_sessionKey) {
|
||||
toast('Gateway 未就绪,连接成功后再发送', 'warning')
|
||||
toast(t('chat.gatewayNotReadySend'), 'warning')
|
||||
return
|
||||
}
|
||||
appendUserMessage(text, attachments)
|
||||
@@ -1008,7 +1009,7 @@ async function doSend(text, attachments = []) {
|
||||
} catch (err) {
|
||||
showTyping(false)
|
||||
_cancelResponseWatchdog()
|
||||
appendSystemMessage('发送失败: ' + err.message)
|
||||
appendSystemMessage(`${t('chat.sendFailed')}${err.message}`)
|
||||
} finally {
|
||||
_isSending = false
|
||||
updateSendState()
|
||||
@@ -1053,7 +1054,7 @@ function handleEvent(msg) {
|
||||
// 工具执行反馈:更新 typing 提示文字
|
||||
const toolName = payload.data?.name || payload.data?.toolName || ''
|
||||
if (toolName && !_isStreaming) {
|
||||
showTyping(true, `正在使用工具: ${toolName}`)
|
||||
showTyping(true, t('chat.usingTool', { name: toolName }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1113,7 +1114,7 @@ function handleChatEvent(payload) {
|
||||
if (_currentAiBubble && _currentAiText) {
|
||||
_currentAiBubble.innerHTML = renderMarkdown(_currentAiText)
|
||||
}
|
||||
appendSystemMessage('输出超时,已自动结束')
|
||||
appendSystemMessage(t('chat.streamTimeout'))
|
||||
resetStreamState()
|
||||
processMessageQueue()
|
||||
}
|
||||
@@ -1134,7 +1135,7 @@ function handleChatEvent(payload) {
|
||||
let finalTools = c?.tools || []
|
||||
if (!finalTools.length && runId) {
|
||||
const ids = _toolRunIndex.get(runId) || []
|
||||
finalTools = ids.map(id => mergeToolEventData({ id, name: '工具' })).filter(Boolean)
|
||||
finalTools = ids.map(id => mergeToolEventData({ id, name: 'tool' })).filter(Boolean)
|
||||
}
|
||||
if (finalImages.length) _currentAiImages = finalImages
|
||||
if (finalVideos.length) _currentAiVideos = finalVideos
|
||||
@@ -1208,7 +1209,7 @@ function handleChatEvent(payload) {
|
||||
if (capturedText) {
|
||||
appendHostedTarget(capturedText)
|
||||
if (detectStopFromText(capturedText)) {
|
||||
appendHostedOutput('OpenClaw 回复包含完成信号,自动停止')
|
||||
appendHostedOutput(t('chat.hostedAutoStopSignal'))
|
||||
stopHostedAgent()
|
||||
} else {
|
||||
maybeTriggerHostedRun()
|
||||
@@ -1226,14 +1227,14 @@ function handleChatEvent(payload) {
|
||||
if (_currentAiBubble && _currentAiText) {
|
||||
_currentAiBubble.innerHTML = renderMarkdown(_currentAiText)
|
||||
}
|
||||
appendSystemMessage('生成已停止')
|
||||
appendSystemMessage(t('chat.generationStopped'))
|
||||
resetStreamState()
|
||||
processMessageQueue()
|
||||
return
|
||||
}
|
||||
|
||||
if (state === 'error') {
|
||||
const errMsg = payload.errorMessage || payload.error?.message || '未知错误'
|
||||
const errMsg = payload.errorMessage || payload.error?.message || t('common.error')
|
||||
|
||||
// 连接级错误(origin/pairing/auth)拦截,不作为聊天消息显示
|
||||
if (/origin not allowed|NOT_PAIRED|PAIRING_REQUIRED|auth.*fail/i.test(errMsg)) {
|
||||
@@ -1242,7 +1243,7 @@ function handleChatEvent(payload) {
|
||||
if (overlay) {
|
||||
overlay.style.display = 'flex'
|
||||
const desc = document.getElementById('chat-connect-desc')
|
||||
if (desc) desc.textContent = '连接被 Gateway 拒绝,请点击「修复并重连」'
|
||||
if (desc) desc.textContent = t('chat.connectionRejected')
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1263,7 +1264,7 @@ function handleChatEvent(payload) {
|
||||
}
|
||||
|
||||
showTyping(false)
|
||||
appendSystemMessage('错误: ' + errMsg)
|
||||
appendSystemMessage(`${t('chat.errorPrefix')}${errMsg}`)
|
||||
resetStreamState()
|
||||
processMessageQueue()
|
||||
return
|
||||
@@ -1279,7 +1280,7 @@ function extractChatContent(message) {
|
||||
const output = typeof message.content === 'string' ? message.content : null
|
||||
if (!tools.length) {
|
||||
tools.push({
|
||||
name: message.name || message.tool || message.tool_name || '工具',
|
||||
name: message.name || message.tool || message.tool_name || 'tool',
|
||||
input: message.input || message.args || message.parameters || null,
|
||||
output: output || message.output || message.result || null,
|
||||
status: message.status || 'ok',
|
||||
@@ -1310,13 +1311,13 @@ function extractChatContent(message) {
|
||||
else if (block.url) audios.push({ url: block.url, mediaType: block.mimeType || 'audio/mpeg', duration: block.duration })
|
||||
}
|
||||
else if (block.type === 'file' || block.type === 'document') {
|
||||
files.push({ url: block.url || '', name: block.fileName || block.name || '文件', mimeType: block.mimeType || '', size: block.size, data: block.data })
|
||||
files.push({ url: block.url || '', name: block.fileName || block.name || 'file', mimeType: block.mimeType || '', size: block.size, data: block.data })
|
||||
}
|
||||
else if (block.type === 'tool' || block.type === 'tool_use' || block.type === 'tool_call' || block.type === 'toolCall') {
|
||||
const callId = block.id || block.tool_call_id || block.toolCallId
|
||||
upsertTool(tools, {
|
||||
id: callId,
|
||||
name: block.name || block.tool || block.tool_name || block.toolName || '工具',
|
||||
name: block.name || block.tool || block.tool_name || block.toolName || 'tool',
|
||||
input: block.input || block.args || block.parameters || block.arguments || null,
|
||||
output: null,
|
||||
status: block.status || 'ok',
|
||||
@@ -1327,7 +1328,7 @@ function extractChatContent(message) {
|
||||
const resId = block.id || block.tool_call_id || block.toolCallId
|
||||
upsertTool(tools, {
|
||||
id: resId,
|
||||
name: block.name || block.tool || block.tool_name || block.toolName || '工具',
|
||||
name: block.name || block.tool || block.tool_name || block.toolName || 'tool',
|
||||
input: block.input || block.args || null,
|
||||
output: block.output || block.result || block.content || null,
|
||||
status: block.status || 'ok',
|
||||
@@ -1348,7 +1349,7 @@ function extractChatContent(message) {
|
||||
if (/\.(mp4|webm|mov|mkv)(\?|$)/i.test(url)) videos.push({ url, mediaType: 'video/mp4' })
|
||||
else if (/\.(mp3|wav|ogg|m4a|aac|flac)(\?|$)/i.test(url)) audios.push({ url, mediaType: 'audio/mpeg' })
|
||||
else if (/\.(jpe?g|png|gif|webp|heic|svg)(\?|$)/i.test(url)) images.push({ url, mediaType: 'image/png' })
|
||||
else files.push({ url, name: url.split('/').pop().split('?')[0] || '文件', mimeType: '' })
|
||||
else files.push({ url, name: url.split('/').pop().split('?')[0] || 'file', mimeType: '' })
|
||||
}
|
||||
const text = texts.length ? stripThinkingTags(texts.join('\n')) : ''
|
||||
return { text, images, videos, audios, files, tools }
|
||||
@@ -1571,7 +1572,7 @@ async function loadHistory() {
|
||||
try {
|
||||
const result = await wsClient.chatHistory(_sessionKey, 200)
|
||||
if (!result?.messages?.length) {
|
||||
if (_messagesEl && !_messagesEl.querySelector('.msg')) appendSystemMessage('还没有消息,开始聊天吧')
|
||||
if (_messagesEl && !_messagesEl.querySelector('.msg')) appendSystemMessage(t('chat.noMessages'))
|
||||
return
|
||||
}
|
||||
const deduped = dedupeHistory(result.messages)
|
||||
@@ -1608,7 +1609,7 @@ async function loadHistory() {
|
||||
}
|
||||
})
|
||||
if (hasOmittedImages) {
|
||||
appendSystemMessage('部分历史图片无法显示(Gateway 不保留图片原始数据,仅当前会话内可见)')
|
||||
appendSystemMessage(t('chat.imageHistoryHint'))
|
||||
}
|
||||
saveMessages(result.messages.map(m => {
|
||||
const c = extractContent(m)
|
||||
@@ -1618,7 +1619,7 @@ async function loadHistory() {
|
||||
scrollToBottom()
|
||||
} catch (e) {
|
||||
console.error('[chat] loadHistory error:', e)
|
||||
if (_messagesEl && !_messagesEl.querySelector('.msg')) appendSystemMessage('加载历史失败: ' + e.message)
|
||||
if (_messagesEl && !_messagesEl.querySelector('.msg')) appendSystemMessage(`${t('common.loadFailed')}: ${e.message}`)
|
||||
} finally {
|
||||
_isLoadingHistory = false
|
||||
}
|
||||
@@ -1664,7 +1665,7 @@ function extractContent(msg) {
|
||||
if (!tools.length) {
|
||||
upsertTool(tools, {
|
||||
id: msg.id || msg.tool_call_id || msg.toolCallId,
|
||||
name: msg.name || msg.tool || msg.tool_name || '工具',
|
||||
name: msg.name || msg.tool || msg.tool_name || 'tool',
|
||||
input: msg.input || msg.args || msg.parameters || null,
|
||||
output: output || msg.output || msg.result || null,
|
||||
status: msg.status || 'ok',
|
||||
@@ -1694,13 +1695,13 @@ function extractContent(msg) {
|
||||
else if (block.url) audios.push({ url: block.url, mediaType: block.mimeType || 'audio/mpeg', duration: block.duration })
|
||||
}
|
||||
else if (block.type === 'file' || block.type === 'document') {
|
||||
files.push({ url: block.url || '', name: block.fileName || block.name || '文件', mimeType: block.mimeType || '', size: block.size, data: block.data })
|
||||
files.push({ url: block.url || '', name: block.fileName || block.name || 'file', mimeType: block.mimeType || '', size: block.size, data: block.data })
|
||||
}
|
||||
else if (block.type === 'tool' || block.type === 'tool_use' || block.type === 'tool_call' || block.type === 'toolCall') {
|
||||
const callId = block.id || block.tool_call_id || block.toolCallId
|
||||
upsertTool(tools, {
|
||||
id: callId,
|
||||
name: block.name || block.tool || block.tool_name || block.toolName || '工具',
|
||||
name: block.name || block.tool || block.tool_name || block.toolName || 'tool',
|
||||
input: block.input || block.args || block.parameters || block.arguments || null,
|
||||
output: null,
|
||||
status: block.status || 'ok',
|
||||
@@ -1711,7 +1712,7 @@ function extractContent(msg) {
|
||||
const resId = block.id || block.tool_call_id || block.toolCallId
|
||||
upsertTool(tools, {
|
||||
id: resId,
|
||||
name: block.name || block.tool || block.tool_name || block.toolName || '工具',
|
||||
name: block.name || block.tool || block.tool_name || block.toolName || 'tool',
|
||||
input: block.input || block.args || null,
|
||||
output: block.output || block.result || block.content || null,
|
||||
status: block.status || 'ok',
|
||||
@@ -1731,7 +1732,7 @@ function extractContent(msg) {
|
||||
if (/\.(mp4|webm|mov|mkv)(\?|$)/i.test(url)) videos.push({ url, mediaType: 'video/mp4' })
|
||||
else if (/\.(mp3|wav|ogg|m4a|aac|flac)(\?|$)/i.test(url)) audios.push({ url, mediaType: 'audio/mpeg' })
|
||||
else if (/\.(jpe?g|png|gif|webp|heic|svg)(\?|$)/i.test(url)) images.push({ url, mediaType: 'image/png' })
|
||||
else files.push({ url, name: url.split('/').pop().split('?')[0] || '文件', mimeType: '' })
|
||||
else files.push({ url, name: url.split('/').pop().split('?')[0] || 'file', mimeType: '' })
|
||||
}
|
||||
return { text: stripThinkingTags(texts.join('\n')), images, videos, audios, files, tools }
|
||||
}
|
||||
@@ -1897,7 +1898,7 @@ function appendFilesToEl(el, files) {
|
||||
const fileIconMap = { pdf: 'file', doc: 'file-text', docx: 'file-text', txt: 'file-plain', md: 'file-plain', json: 'clipboard', csv: 'bar-chart', zip: 'package', rar: 'package' }
|
||||
const fileIcon = svgIcon(fileIconMap[ext] || 'paperclip', 16)
|
||||
const size = f.size ? formatFileSize(f.size) : ''
|
||||
card.innerHTML = `<span class="msg-file-icon">${fileIcon}</span><div class="msg-file-info"><span class="msg-file-name">${f.name || '文件'}</span>${size ? `<span class="msg-file-size">${size}</span>` : ''}</div>`
|
||||
card.innerHTML = `<span class="msg-file-icon">${fileIcon}</span><div class="msg-file-info"><span class="msg-file-name">${f.name || 'file'}</span>${size ? `<span class="msg-file-size">${size}</span>` : ''}</div>`
|
||||
if (f.url) {
|
||||
card.style.cursor = 'pointer'
|
||||
card.onclick = () => window.open(f.url, '_blank')
|
||||
@@ -1906,7 +1907,7 @@ function appendFilesToEl(el, files) {
|
||||
card.onclick = () => {
|
||||
const a = document.createElement('a')
|
||||
a.href = `data:${f.mimeType || 'application/octet-stream'};base64,${f.data}`
|
||||
a.download = f.name || '文件'
|
||||
a.download = f.name || 'file'
|
||||
a.click()
|
||||
}
|
||||
}
|
||||
@@ -1953,7 +1954,7 @@ function collectToolsFromMessage(message, tools) {
|
||||
const callId = call.id || call.tool_call_id
|
||||
upsertTool(tools, {
|
||||
id: callId,
|
||||
name: name || '工具',
|
||||
name: name || 'tool',
|
||||
input,
|
||||
output: null,
|
||||
status: call.status || 'ok',
|
||||
@@ -1967,7 +1968,7 @@ function collectToolsFromMessage(message, tools) {
|
||||
const resId = res.id || res.tool_call_id
|
||||
upsertTool(tools, {
|
||||
id: resId,
|
||||
name: res.name || res.tool || res.tool_name || '工具',
|
||||
name: res.name || res.tool || res.tool_name || 'tool',
|
||||
input: res.input || res.args || null,
|
||||
output: res.output || res.result || res.content || null,
|
||||
status: res.status || 'ok',
|
||||
@@ -1991,16 +1992,16 @@ function appendToolsToEl(el, tools) {
|
||||
const details = document.createElement('details')
|
||||
details.className = 'msg-tool-item'
|
||||
const summary = document.createElement('summary')
|
||||
const status = tool.status === 'error' ? '失败' : '成功'
|
||||
const status = tool.status === 'error' ? t('chat.toolFailed') : t('chat.toolSuccess')
|
||||
const timeValue = getToolTime(tool) || resolveToolTime(tool.id || tool.tool_call_id, tool.messageTimestamp)
|
||||
const timeText = timeValue ? formatTime(new Date(timeValue)) : ''
|
||||
summary.innerHTML = `${escapeHtml(tool.name || '工具')} · ${status}${timeText ? ' · ' + timeText : ''}`
|
||||
summary.innerHTML = `${escapeHtml(tool.name || 'tool')} · ${status}${timeText ? ' · ' + timeText : ''}`
|
||||
const body = document.createElement('div')
|
||||
body.className = 'msg-tool-body'
|
||||
const inputJson = stripAnsi(safeStringify(tool.input))
|
||||
const outputJson = stripAnsi(safeStringify(tool.output))
|
||||
body.innerHTML = `<div class="msg-tool-block"><div class="msg-tool-title">参数</div><pre>${escapeHtml(inputJson || '无参数')}</pre></div>`
|
||||
+ `<div class="msg-tool-block"><div class="msg-tool-title">结果</div><pre>${escapeHtml(outputJson || '无结果')}</pre></div>`
|
||||
body.innerHTML = `<div class="msg-tool-block"><div class="msg-tool-title">${t('chat.toolParams')}</div><pre>${escapeHtml(inputJson || '-')}</pre></div>`
|
||||
+ `<div class="msg-tool-block"><div class="msg-tool-title">${t('chat.toolResult')}</div><pre>${escapeHtml(outputJson || '-')}</pre></div>`
|
||||
details.appendChild(summary)
|
||||
details.appendChild(body)
|
||||
container.appendChild(details)
|
||||
@@ -2053,7 +2054,7 @@ function showCompactionHint(show) {
|
||||
hint = document.createElement('div')
|
||||
hint.id = 'compaction-hint'
|
||||
hint.className = 'msg msg-system compaction-hint'
|
||||
hint.innerHTML = '🗜️ 正在整理上下文(Compaction)…'
|
||||
hint.innerHTML = `🗜️ ${t('chat.compacting')}`
|
||||
_messagesEl.insertBefore(hint, _typingEl)
|
||||
scrollToBottom()
|
||||
} else if (!show && hint) {
|
||||
@@ -2077,11 +2078,11 @@ function updateSendState() {
|
||||
if (_isStreaming) {
|
||||
_sendBtn.disabled = false
|
||||
_sendBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>'
|
||||
_sendBtn.title = '停止生成'
|
||||
_sendBtn.title = t('chat.cmdStopGen')
|
||||
} else {
|
||||
_sendBtn.disabled = !_textarea.value.trim() && !_attachments.length
|
||||
_sendBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>'
|
||||
_sendBtn.title = '发送'
|
||||
_sendBtn.title = t('chat.send')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2146,13 +2147,13 @@ function updateHostedBadge() {
|
||||
if (!_hostedBadgeEl || !_hostedSessionConfig) return
|
||||
const status = _hostedRuntime.status || HOSTED_STATUS.IDLE
|
||||
const enabled = _hostedSessionConfig.enabled
|
||||
let text = '未启用', cls = 'chat-hosted-badge'
|
||||
if (!enabled) { text = '未启用'; cls += ' idle' }
|
||||
else if (status === HOSTED_STATUS.RUNNING) { text = '运行中'; cls += ' running' }
|
||||
else if (status === HOSTED_STATUS.WAITING) { text = '等待回复'; cls += ' waiting' }
|
||||
else if (status === HOSTED_STATUS.PAUSED) { text = '已暂停'; cls += ' paused' }
|
||||
else if (status === HOSTED_STATUS.ERROR) { text = '异常'; cls += ' error' }
|
||||
else { text = '待命'; cls += ' idle' }
|
||||
let text = t('chat.hostedNotEnabled'), cls = 'chat-hosted-badge'
|
||||
if (!enabled) { text = t('chat.hostedNotEnabled'); cls += ' idle' }
|
||||
else if (status === HOSTED_STATUS.RUNNING) { text = t('chat.hostedRunning'); cls += ' running' }
|
||||
else if (status === HOSTED_STATUS.WAITING) { text = t('chat.hostedWaiting'); cls += ' waiting' }
|
||||
else if (status === HOSTED_STATUS.PAUSED) { text = t('chat.hostedPaused'); cls += ' paused' }
|
||||
else if (status === HOSTED_STATUS.ERROR) { text = t('chat.hostedErrorStatus'); cls += ' error' }
|
||||
else { text = t('chat.hostedStandby'); cls += ' idle' }
|
||||
_hostedBadgeEl.className = cls
|
||||
_hostedBadgeEl.textContent = text
|
||||
}
|
||||
@@ -2175,7 +2176,7 @@ function renderHostedPanel() {
|
||||
if (timerToggle) { timerToggle.checked = (_hostedSessionConfig.autoStopMinutes || 0) > 0; timerToggle.disabled = isRunning }
|
||||
if (timerBody) timerBody.style.display = timerToggle?.checked ? '' : 'none'
|
||||
if (_hostedSaveBtn) {
|
||||
_hostedSaveBtn.textContent = isRunning ? '⏹ 停止托管' : '▶ 启动托管'
|
||||
_hostedSaveBtn.textContent = isRunning ? `⏹ ${t('chat.stopHosted')}` : `▶ ${t('chat.startHosted')}`
|
||||
_hostedSaveBtn.className = isRunning ? 'btn btn-ghost' : 'btn btn-primary'
|
||||
_hostedSaveBtn.style.flex = '1'
|
||||
}
|
||||
@@ -2183,11 +2184,11 @@ function renderHostedPanel() {
|
||||
// 状态栏
|
||||
const statusEl = _hostedPanelEl.querySelector('#hosted-agent-status')
|
||||
if (statusEl) {
|
||||
let msg = '就绪'
|
||||
if (_hostedRuntime.lastError) msg = `错误: ${_hostedRuntime.lastError}`
|
||||
let msg = t('chat.ready')
|
||||
if (_hostedRuntime.lastError) msg = `${t('chat.errorPrefix')}${_hostedRuntime.lastError}`
|
||||
else if (isRunning) {
|
||||
const remaining = Math.max(0, _hostedSessionConfig.maxSteps - _hostedRuntime.stepCount)
|
||||
msg = `运行中 · 剩余 ${remaining} 步`
|
||||
msg = `${t('chat.hostedRunning')} · ${t('chat.remaining')} ${remaining}`
|
||||
}
|
||||
statusEl.textContent = msg
|
||||
}
|
||||
@@ -2213,7 +2214,7 @@ function updateCountdown() {
|
||||
fillEl.style.width = pct + '%'
|
||||
const mins = Math.floor(remaining / 60000)
|
||||
const secs = Math.floor((remaining % 60000) / 1000)
|
||||
textEl.textContent = `剩余 ${mins}:${secs.toString().padStart(2, '0')}`
|
||||
textEl.textContent = `${t('chat.remaining')} ${mins}:${secs.toString().padStart(2, '0')}`
|
||||
if (!_countdownInterval) {
|
||||
_countdownInterval = setInterval(() => updateCountdown(), 1000)
|
||||
}
|
||||
@@ -2232,7 +2233,7 @@ function toggleHostedRun() {
|
||||
async function startHostedAgent() {
|
||||
if (!_hostedSessionConfig) return
|
||||
const prompt = (_hostedPromptEl?.value || '').trim()
|
||||
if (!prompt) { toast('请输入任务目标', 'warning'); return }
|
||||
if (!prompt) { toast(t('chat.enterTaskGoal'), 'warning'); return }
|
||||
const rawSteps = parseInt(_hostedMaxStepsEl?.value || HOSTED_DEFAULTS.maxSteps, 10)
|
||||
const maxSteps = rawSteps >= 205 ? 999999 : Math.max(1, rawSteps)
|
||||
const stepDelayMs = Math.max(200, parseInt(_hostedStepDelayEl?.value || HOSTED_DEFAULTS.stepDelayMs, 10))
|
||||
@@ -2240,7 +2241,7 @@ async function startHostedAgent() {
|
||||
const timerOn = _page?.querySelector('#hosted-agent-timer-on')?.checked
|
||||
const autoStopMinutes = timerOn ? Math.max(0, parseInt(_hostedAutoStopEl?.value || 0, 10)) : 0
|
||||
_hostedSessionConfig = { ..._hostedSessionConfig, prompt, enabled: true, maxSteps, stepDelayMs, retryLimit, autoStopMinutes }
|
||||
const sysContent = HOSTED_SYSTEM_PROMPT + '\n\n用户目标: ' + prompt
|
||||
const sysContent = HOSTED_SYSTEM_PROMPT + '\n\nUser goal: ' + prompt
|
||||
if (!_hostedSessionConfig.history?.length) _hostedSessionConfig.history = [{ role: 'system', content: sysContent }]
|
||||
else if (_hostedSessionConfig.history[0]?.role === 'system') _hostedSessionConfig.history[0].content = sysContent
|
||||
else _hostedSessionConfig.history.unshift({ role: 'system', content: sysContent })
|
||||
@@ -2253,12 +2254,12 @@ async function startHostedAgent() {
|
||||
clearTimeout(_hostedAutoStopTimer)
|
||||
if (autoStopMinutes > 0) {
|
||||
_hostedAutoStopTimer = setTimeout(() => {
|
||||
appendHostedOutput(`定时 ${autoStopMinutes} 分钟已到,自动停止`)
|
||||
appendHostedOutput(t('chat.hostedTimerExpired', { min: autoStopMinutes }))
|
||||
stopHostedAgent()
|
||||
}, autoStopMinutes * 60000)
|
||||
}
|
||||
if (!wsClient.gatewayReady || !_sessionKey) { toast('Gateway 未就绪,暂不启动', 'warning'); return }
|
||||
toast('托管 Agent 已启动', 'success')
|
||||
if (!wsClient.gatewayReady || !_sessionKey) { toast(t('chat.gatewayNotReadySend'), 'warning'); return }
|
||||
toast(t('chat.hostedStarted'), 'success')
|
||||
runHostedAgentStep()
|
||||
}
|
||||
|
||||
@@ -2278,7 +2279,7 @@ function stopHostedAgent() {
|
||||
persistHostedRuntime()
|
||||
renderHostedPanel()
|
||||
updateHostedBadge()
|
||||
toast('托管 Agent 已停止', 'info')
|
||||
toast(t('chat.hostedStopped'), 'info')
|
||||
}
|
||||
|
||||
function shouldCaptureHostedTarget(payload) {
|
||||
@@ -2317,7 +2318,7 @@ function compressHostedContext() {
|
||||
const summary = older.map(h => `[${h.role}] ${(h.content || '').slice(0, 80)}`).join('\n')
|
||||
const compressed = []
|
||||
if (sysEntry) compressed.push(sysEntry)
|
||||
compressed.push({ role: 'user', content: `[上下文摘要 - 已压缩 ${older.length} 条历史]\n${summary}`, ts: Date.now() })
|
||||
compressed.push({ role: 'user', content: `[Context summary - compressed ${older.length} entries]\n${summary}`, ts: Date.now() })
|
||||
compressed.push(...recent)
|
||||
_hostedSessionConfig.history = compressed
|
||||
persistHostedRuntime()
|
||||
@@ -2349,15 +2350,15 @@ async function runHostedAgentStep() {
|
||||
if (!prompt) return
|
||||
if (!wsClient.gatewayReady || !_sessionKey) {
|
||||
_hostedRuntime.status = HOSTED_STATUS.PAUSED
|
||||
_hostedRuntime.lastError = 'Gateway 未就绪'
|
||||
_hostedRuntime.lastError = 'Gateway not ready'
|
||||
persistHostedRuntime(); updateHostedBadge()
|
||||
appendHostedOutput('需要人工介入: Gateway 未就绪')
|
||||
appendHostedOutput(t('chat.hostedNeedIntervention'))
|
||||
return
|
||||
}
|
||||
if (_hostedRuntime.errorCount >= _hostedSessionConfig.retryLimit) {
|
||||
_hostedRuntime.status = HOSTED_STATUS.ERROR
|
||||
persistHostedRuntime(); updateHostedBadge()
|
||||
appendHostedOutput('需要人工介入: 连续错误超过阈值')
|
||||
appendHostedOutput(t('chat.hostedErrorThreshold'))
|
||||
return
|
||||
}
|
||||
if (_hostedRuntime.stepCount >= _hostedSessionConfig.maxSteps) {
|
||||
@@ -2408,7 +2409,7 @@ async function runHostedAgentStep() {
|
||||
if (_hostedRuntime.errorCount >= _hostedSessionConfig.retryLimit) {
|
||||
_hostedRuntime.status = HOSTED_STATUS.ERROR
|
||||
persistHostedRuntime(); updateHostedBadge()
|
||||
appendHostedOutput('需要人工介入: ' + _hostedRuntime.lastError)
|
||||
appendHostedOutput(t('chat.hostedNeedIntervention', { reason: _hostedRuntime.lastError }))
|
||||
return
|
||||
}
|
||||
persistHostedRuntime(); updateHostedBadge()
|
||||
@@ -2427,7 +2428,7 @@ async function callHostedAI(messages, onChunk) {
|
||||
config = { baseUrl: stored.baseUrl || '', apiKey: stored.apiKey || '', model: stored.model || '', temperature: stored.temperature || 0.7, apiType: stored.apiType || 'openai-completions' }
|
||||
} catch { config = { baseUrl: '', apiKey: '', model: '', temperature: 0.7, apiType: 'openai-completions' } }
|
||||
|
||||
if (!config.baseUrl || !config.model) throw new Error('托管 Agent 未配置模型(请在 AI 助手页面配置)')
|
||||
if (!config.baseUrl || !config.model) throw new Error(t('chat.hostedModelNotConfigured'))
|
||||
|
||||
let base = config.baseUrl.replace(/\/+$/, '').replace(/\/chat\/completions\/?$/, '').replace(/\/completions\/?$/, '').replace(/\/messages\/?$/, '').replace(/\/models\/?$/, '')
|
||||
if (_hostedAbort) { _hostedAbort.abort(); _hostedAbort = null }
|
||||
@@ -2442,7 +2443,7 @@ async function callHostedAI(messages, onChunk) {
|
||||
const resp = await fetch(base + '/chat/completions', { method: 'POST', headers, body: JSON.stringify(body), signal })
|
||||
if (!resp.ok) {
|
||||
const errText = await resp.text().catch(() => '')
|
||||
let errMsg = `API 错误 ${resp.status}`
|
||||
let errMsg = `API error ${resp.status}`
|
||||
try { errMsg = JSON.parse(errText).error?.message || errMsg } catch {}
|
||||
throw new Error(errMsg)
|
||||
}
|
||||
@@ -2473,7 +2474,7 @@ function appendHostedOutput(text) {
|
||||
if (!text || !_messagesEl) return
|
||||
const wrap = document.createElement('div')
|
||||
wrap.className = 'msg msg-system msg-hosted'
|
||||
wrap.textContent = `[托管 Agent] ${text}`
|
||||
wrap.textContent = `[${t('chat.hostedAgent')}] ${text}`
|
||||
_messagesEl.insertBefore(wrap, _typingEl)
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { api } from '../lib/tauri-api.js'
|
||||
import { toast } from '../components/toast.js'
|
||||
import { icon } from '../lib/icons.js'
|
||||
import { t } from '../lib/i18n.js'
|
||||
|
||||
let _page = null, _config = null, _dirty = false
|
||||
|
||||
@@ -15,17 +16,17 @@ export async function render() {
|
||||
|
||||
page.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">通信与自动化</h1>
|
||||
<p class="page-desc">管理 AI 在各消息渠道中的行为方式:如何回复消息、支持哪些命令、如何接收外部通知等</p>
|
||||
<h1 class="page-title">${t('communication.title')}</h1>
|
||||
<p class="page-desc">${t('communication.desc')}</p>
|
||||
</div>
|
||||
<div class="comm-toolbar" style="display:flex;gap:8px;margin-bottom:var(--space-lg);flex-wrap:wrap">
|
||||
<button class="btn btn-sm btn-primary comm-tab active" data-tab="messages">消息</button>
|
||||
<button class="btn btn-sm btn-secondary comm-tab" data-tab="broadcast">广播</button>
|
||||
<button class="btn btn-sm btn-secondary comm-tab" data-tab="commands">命令</button>
|
||||
<button class="btn btn-sm btn-secondary comm-tab" data-tab="hooks">Webhook</button>
|
||||
<button class="btn btn-sm btn-secondary comm-tab" data-tab="approvals">执行审批</button>
|
||||
<button class="btn btn-sm btn-primary comm-tab active" data-tab="messages">${t('communication.tabMessages')}</button>
|
||||
<button class="btn btn-sm btn-secondary comm-tab" data-tab="broadcast">${t('communication.tabBroadcast')}</button>
|
||||
<button class="btn btn-sm btn-secondary comm-tab" data-tab="commands">${t('communication.tabCommands')}</button>
|
||||
<button class="btn btn-sm btn-secondary comm-tab" data-tab="hooks">${t('communication.tabHooks')}</button>
|
||||
<button class="btn btn-sm btn-secondary comm-tab" data-tab="approvals">${t('communication.tabApprovals')}</button>
|
||||
<div style="flex:1"></div>
|
||||
<button class="btn btn-sm btn-primary" id="btn-comm-save" disabled>${icon('save', 14)} 保存</button>
|
||||
<button class="btn btn-sm btn-primary" id="btn-comm-save" disabled>${icon('save', 14)} ${t('communication.save')}</button>
|
||||
</div>
|
||||
<div id="comm-content">
|
||||
<div class="stat-card loading-placeholder" style="height:200px"></div>
|
||||
@@ -56,7 +57,7 @@ async function loadConfig(page) {
|
||||
if (!_config) _config = {}
|
||||
renderTab(page, 'messages')
|
||||
} catch (e) {
|
||||
page.querySelector('#comm-content').innerHTML = `<div style="color:var(--error)">加载配置失败: ${esc(e?.message || e)}</div>`
|
||||
page.querySelector('#comm-content').innerHTML = `<div style="color:var(--error)">${t('communication.loadFailed')}: ${esc(e?.message || e)}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,18 +70,18 @@ function markDirty() {
|
||||
async function saveConfig() {
|
||||
if (!_config || !_dirty) return
|
||||
const btn = _page?.querySelector('#btn-comm-save')
|
||||
if (btn) { btn.disabled = true; btn.textContent = '保存中...' }
|
||||
if (btn) { btn.disabled = true; btn.textContent = t('communication.saving') }
|
||||
try {
|
||||
// 从当前表单收集值到 _config
|
||||
collectCurrentTab()
|
||||
await api.writeOpenclawConfig(_config)
|
||||
_dirty = false
|
||||
toast('配置已保存,正在重载 Gateway...', 'info')
|
||||
try { await api.reloadGateway(); toast('Gateway 已重载', 'success') } catch {}
|
||||
toast(t('communication.configSaved'), 'info')
|
||||
try { await api.reloadGateway(); toast(t('communication.gwReloaded'), 'success') } catch {}
|
||||
} catch (e) {
|
||||
toast('保存失败: ' + e, 'error')
|
||||
toast(t('communication.saveFailed') + ': ' + e, 'error')
|
||||
} finally {
|
||||
if (btn) { btn.disabled = !_dirty; btn.innerHTML = `${icon('save', 14)} 保存` }
|
||||
if (btn) { btn.disabled = !_dirty; btn.innerHTML = `${icon('save', 14)} ${t('communication.save')}` }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,74 +113,74 @@ function renderMessages(el) {
|
||||
const sr = m.statusReactions || {}
|
||||
el.innerHTML = `
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">回复设置</div>
|
||||
<div class="config-section-title">${t('communication.replySettings')}</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">回复前缀</label>
|
||||
<input class="form-input" id="msg-responsePrefix" value="${esc(m.responsePrefix || '')}" placeholder="如 [{model}] 或 auto">
|
||||
<div class="form-hint">每条 AI 回复开头自动加的前缀。支持 {model}、{provider}、{thinkingLevel} 等变量。设为 auto 则显示 Agent 名称</div>
|
||||
<label class="form-label">${t('communication.replyPrefix')}</label>
|
||||
<input class="form-input" id="msg-responsePrefix" value="${esc(m.responsePrefix || '')}" placeholder="${t('communication.replyPrefixPlaceholder')}">
|
||||
<div class="form-hint">${t('communication.replyPrefixHint')}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">确认反应 Emoji</label>
|
||||
<input class="form-input" id="msg-ackReaction" value="${esc(m.ackReaction || '')}" placeholder="如 👀 或留空禁用" style="max-width:200px">
|
||||
<div class="form-hint">收到消息时自动添加的 emoji 反应(确认已收到)</div>
|
||||
<label class="form-label">${t('communication.ackReaction')}</label>
|
||||
<input class="form-input" id="msg-ackReaction" value="${esc(m.ackReaction || '')}" placeholder="${t('communication.ackReactionPlaceholder')}" style="max-width:200px">
|
||||
<div class="form-hint">${t('communication.ackReactionHint')}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">确认反应范围</label>
|
||||
<label class="form-label">${t('communication.ackScope')}</label>
|
||||
<select class="form-input" id="msg-ackReactionScope" style="max-width:300px">
|
||||
<option value="group-mentions" ${(m.ackReactionScope || 'group-mentions') === 'group-mentions' ? 'selected' : ''}>群聊 @提及时</option>
|
||||
<option value="group-all" ${m.ackReactionScope === 'group-all' ? 'selected' : ''}>群聊所有消息</option>
|
||||
<option value="direct" ${m.ackReactionScope === 'direct' ? 'selected' : ''}>仅私聊</option>
|
||||
<option value="all" ${m.ackReactionScope === 'all' ? 'selected' : ''}>所有消息</option>
|
||||
<option value="off" ${m.ackReactionScope === 'off' ? 'selected' : ''}>关闭</option>
|
||||
<option value="group-mentions" ${(m.ackReactionScope || 'group-mentions') === 'group-mentions' ? 'selected' : ''}>${t('communication.ackScopeGroupMentions')}</option>
|
||||
<option value="group-all" ${m.ackReactionScope === 'group-all' ? 'selected' : ''}>${t('communication.ackScopeGroupAll')}</option>
|
||||
<option value="direct" ${m.ackReactionScope === 'direct' ? 'selected' : ''}>${t('communication.ackScopeDirect')}</option>
|
||||
<option value="all" ${m.ackReactionScope === 'all' ? 'selected' : ''}>${t('communication.ackScopeAll')}</option>
|
||||
<option value="off" ${m.ackReactionScope === 'off' ? 'selected' : ''}>${t('communication.ackScopeOff')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="display:flex;align-items:center;justify-content:space-between">
|
||||
<div>
|
||||
<label class="form-label" style="margin:0">回复后移除确认反应</label>
|
||||
<div class="form-hint" style="margin:0">回复发送成功后自动删除之前的确认 emoji</div>
|
||||
<label class="form-label" style="margin:0">${t('communication.removeAckAfterReply')}</label>
|
||||
<div class="form-hint" style="margin:0">${t('communication.removeAckAfterReplyHint')}</div>
|
||||
</div>
|
||||
<label class="toggle-switch"><input type="checkbox" id="msg-removeAckAfterReply" ${m.removeAckAfterReply ? 'checked' : ''}><span class="toggle-slider"></span></label>
|
||||
</div>
|
||||
<div class="form-group" style="display:flex;align-items:center;justify-content:space-between">
|
||||
<div>
|
||||
<label class="form-label" style="margin:0">隐藏工具错误</label>
|
||||
<div class="form-hint" style="margin:0">不向用户显示 ⚠️ 工具执行错误</div>
|
||||
<label class="form-label" style="margin:0">${t('communication.suppressToolErrors')}</label>
|
||||
<div class="form-hint" style="margin:0">${t('communication.suppressToolErrorsHint')}</div>
|
||||
</div>
|
||||
<label class="toggle-switch"><input type="checkbox" id="msg-suppressToolErrors" ${m.suppressToolErrors ? 'checked' : ''}><span class="toggle-slider"></span></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">状态反应 Emoji</div>
|
||||
<div class="config-section-title">${t('communication.statusReactions')}</div>
|
||||
<div class="form-group" style="display:flex;align-items:center;justify-content:space-between">
|
||||
<div>
|
||||
<label class="form-label" style="margin:0">启用状态反应</label>
|
||||
<div class="form-hint" style="margin:0">在消息渠道中用 emoji 表示 AI 当前状态(思考中、执行工具、完成等)</div>
|
||||
<label class="form-label" style="margin:0">${t('communication.enableStatusReactions')}</label>
|
||||
<div class="form-hint" style="margin:0">${t('communication.enableStatusReactionsHint')}</div>
|
||||
</div>
|
||||
<label class="toggle-switch"><input type="checkbox" id="msg-sr-enabled" ${sr.enabled ? 'checked' : ''}><span class="toggle-slider"></span></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">消息队列</div>
|
||||
<div class="config-section-title">${t('communication.messageQueue')}</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">防抖延迟(毫秒)</label>
|
||||
<input class="form-input" id="msg-debounceMs" type="number" value="${m.inbound?.debounceMs || m.queue?.debounceMs || ''}" placeholder="默认无延迟" style="max-width:200px">
|
||||
<div class="form-hint">合并快速连续消息的等待时间(毫秒),避免 AI 对每条消息逐一回复</div>
|
||||
<label class="form-label">${t('communication.debounceMs')}</label>
|
||||
<input class="form-input" id="msg-debounceMs" type="number" value="${m.inbound?.debounceMs || m.queue?.debounceMs || ''}" placeholder="" style="max-width:200px">
|
||||
<div class="form-hint">${t('communication.debounceMsHint')}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">队列上限</label>
|
||||
<input class="form-input" id="msg-queueCap" type="number" value="${m.queue?.cap || ''}" placeholder="默认无限制" style="max-width:200px">
|
||||
<div class="form-hint">等待处理的消息队列最大长度</div>
|
||||
<label class="form-label">${t('communication.queueCap')}</label>
|
||||
<input class="form-input" id="msg-queueCap" type="number" value="${m.queue?.cap || ''}" placeholder="" style="max-width:200px">
|
||||
<div class="form-hint">${t('communication.queueCapHint')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">群聊设置</div>
|
||||
<div class="config-section-title">${t('communication.groupChat')}</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">群聊历史条数</label>
|
||||
<input class="form-input" id="msg-groupHistoryLimit" type="number" value="${m.groupChat?.historyLimit || ''}" placeholder="默认自动" style="max-width:200px">
|
||||
<div class="form-hint">群聊中回溯多少条历史消息作为上下文</div>
|
||||
<label class="form-label">${t('communication.groupHistoryLimit')}</label>
|
||||
<input class="form-input" id="msg-groupHistoryLimit" type="number" value="${m.groupChat?.historyLimit || ''}" placeholder="" style="max-width:200px">
|
||||
<div class="form-hint">${t('communication.groupHistoryLimitHint')}</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
@@ -230,14 +231,14 @@ function renderBroadcast(el) {
|
||||
const b = _config?.broadcast || {}
|
||||
el.innerHTML = `
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">广播策略</div>
|
||||
<div class="config-section-title">${t('communication.broadcastStrategy')}</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">广播处理方式</label>
|
||||
<label class="form-label">${t('communication.broadcastMode')}</label>
|
||||
<select class="form-input" id="bc-strategy" style="max-width:300px">
|
||||
<option value="parallel" ${(b.strategy || 'parallel') === 'parallel' ? 'selected' : ''}>并行(parallel)— 同时发送给所有目标</option>
|
||||
<option value="sequential" ${b.strategy === 'sequential' ? 'selected' : ''}>顺序(sequential)— 逐个发送,严格有序</option>
|
||||
<option value="parallel" ${(b.strategy || 'parallel') === 'parallel' ? 'selected' : ''}>${t('communication.broadcastParallel')}</option>
|
||||
<option value="sequential" ${b.strategy === 'sequential' ? 'selected' : ''}>${t('communication.broadcastSequential')}</option>
|
||||
</select>
|
||||
<div class="form-hint">当消息需要广播给多个 Agent 时的处理策略。并行更快,顺序更可控</div>
|
||||
<div class="form-hint">${t('communication.broadcastHint')}</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
@@ -261,23 +262,23 @@ function renderCommands(el) {
|
||||
const cmd = _config?.commands || {}
|
||||
el.innerHTML = `
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">斜杠命令</div>
|
||||
${toggleRow('cmd-text', '文本命令解析', '允许通过 / 前缀在聊天中执行命令', cmd.text !== false)}
|
||||
${toggleRow('cmd-bash', 'Bash 命令', '允许用 ! 前缀或 /bash 在聊天中执行 Shell 命令(危险)', !!cmd.bash)}
|
||||
${toggleRow('cmd-config', '/config 命令', '允许在聊天中查看/修改配置', !!cmd.config)}
|
||||
${toggleRow('cmd-debug', '/debug 命令', '允许在聊天中查看调试信息', !!cmd.debug)}
|
||||
${toggleRow('cmd-restart', '重启命令', '允许通过命令重启 Gateway', cmd.restart !== false)}
|
||||
<div class="config-section-title">${t('communication.slashCommands')}</div>
|
||||
${toggleRow('cmd-text', t('communication.cmdText'), t('communication.cmdTextHint'), cmd.text !== false)}
|
||||
${toggleRow('cmd-bash', t('communication.cmdBash'), t('communication.cmdBashHint'), !!cmd.bash)}
|
||||
${toggleRow('cmd-config', t('communication.cmdConfig'), t('communication.cmdConfigHint'), !!cmd.config)}
|
||||
${toggleRow('cmd-debug', t('communication.cmdDebug'), t('communication.cmdDebugHint'), !!cmd.debug)}
|
||||
${toggleRow('cmd-restart', t('communication.cmdRestart'), t('communication.cmdRestartHint'), cmd.restart !== false)}
|
||||
</div>
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">原生命令注册</div>
|
||||
<div class="config-section-title">${t('communication.nativeCommands')}</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">原生命令</label>
|
||||
<label class="form-label">${t('communication.nativeLabel')}</label>
|
||||
<select class="form-input" id="cmd-native" style="max-width:200px">
|
||||
<option value="auto" ${(cmd.native === 'auto' || cmd.native === undefined) ? 'selected' : ''}>自动</option>
|
||||
<option value="true" ${cmd.native === true ? 'selected' : ''}>启用</option>
|
||||
<option value="false" ${cmd.native === false ? 'selected' : ''}>禁用</option>
|
||||
<option value="auto" ${(cmd.native === 'auto' || cmd.native === undefined) ? 'selected' : ''}>${t('communication.nativeAuto')}</option>
|
||||
<option value="true" ${cmd.native === true ? 'selected' : ''}>${t('communication.nativeEnabled')}</option>
|
||||
<option value="false" ${cmd.native === false ? 'selected' : ''}>${t('communication.nativeDisabled')}</option>
|
||||
</select>
|
||||
<div class="form-hint">在支持的渠道(Telegram、Discord)自动注册原生命令菜单</div>
|
||||
<div class="form-hint">${t('communication.nativeHint')}</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
@@ -306,26 +307,26 @@ function renderHooks(el) {
|
||||
const h = _config?.hooks || {}
|
||||
el.innerHTML = `
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">Webhook 设置</div>
|
||||
${toggleRow('hooks-enabled', '启用 Webhook', '允许外部服务通过 HTTP 触发 AI 执行', !!h.enabled)}
|
||||
<div class="config-section-title">${t('communication.webhookSettings')}</div>
|
||||
${toggleRow('hooks-enabled', t('communication.webhookEnabled'), t('communication.webhookEnabledHint'), !!h.enabled)}
|
||||
<div class="form-group">
|
||||
<label class="form-label">Webhook 路径</label>
|
||||
<input class="form-input" id="hooks-path" value="${esc(h.path || '')}" placeholder="/hooks(默认)" style="max-width:300px">
|
||||
<div class="form-hint">Gateway 上暴露的 Webhook 接收路径</div>
|
||||
<label class="form-label">${t('communication.webhookPath')}</label>
|
||||
<input class="form-input" id="hooks-path" value="${esc(h.path || '')}" placeholder="/hooks" style="max-width:300px">
|
||||
<div class="form-hint">${t('communication.webhookPathHint')}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">认证 Token</label>
|
||||
<input class="form-input" id="hooks-token" type="password" value="${esc(h.token || '')}" placeholder="可选,用于验证 Webhook 请求">
|
||||
<div class="form-hint">外部请求需在 Header 中携带此 Token 才能触发 Webhook</div>
|
||||
<label class="form-label">${t('communication.webhookToken')}</label>
|
||||
<input class="form-input" id="hooks-token" type="password" value="${esc(h.token || '')}" placeholder="">
|
||||
<div class="form-hint">${t('communication.webhookTokenHint')}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">默认 Session Key</label>
|
||||
<input class="form-input" id="hooks-defaultSessionKey" value="${esc(h.defaultSessionKey || '')}" placeholder="自动生成 hook:<uuid>">
|
||||
<div class="form-hint">Webhook 触发的 Agent 会话标识。留空则每次自动生成</div>
|
||||
<label class="form-label">${t('communication.webhookSessionKey')}</label>
|
||||
<input class="form-input" id="hooks-defaultSessionKey" value="${esc(h.defaultSessionKey || '')}" placeholder="hook:<uuid>">
|
||||
<div class="form-hint">${t('communication.webhookSessionKeyHint')}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">请求体大小限制(字节)</label>
|
||||
<input class="form-input" id="hooks-maxBodyBytes" type="number" value="${h.maxBodyBytes || ''}" placeholder="默认无限制" style="max-width:200px">
|
||||
<label class="form-label">${t('communication.webhookMaxBody')}</label>
|
||||
<input class="form-input" id="hooks-maxBodyBytes" type="number" value="${h.maxBodyBytes || ''}" placeholder="${t('communication.noLimit')}" style="max-width:200px">
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
@@ -355,18 +356,18 @@ function renderApprovals(el) {
|
||||
const a = _config?.approvals?.exec || {}
|
||||
el.innerHTML = `
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">执行审批转发</div>
|
||||
<div class="form-hint" style="margin-bottom:var(--space-md)">当 AI 请求执行命令时,将审批请求转发到消息渠道,方便在手机上审批</div>
|
||||
${toggleRow('approvals-enabled', '启用审批转发', '将执行审批请求转发到配置的消息渠道', !!a.enabled)}
|
||||
<div class="config-section-title">${t('communication.approvalsTitle')}</div>
|
||||
<div class="form-hint" style="margin-bottom:var(--space-md)">${t('communication.approvalsDesc')}</div>
|
||||
${toggleRow('approvals-enabled', t('communication.approvalsEnabled'), t('communication.approvalsEnabledHint'), !!a.enabled)}
|
||||
<div class="form-group">
|
||||
<label class="form-label">转发模式</label>
|
||||
<label class="form-label">${t('communication.approvalsMode')}</label>
|
||||
<select class="form-input" id="approvals-mode" style="max-width:300px">
|
||||
<option value="session" ${(a.mode || 'session') === 'session' ? 'selected' : ''}>原会话(session)— 发到发起请求的会话</option>
|
||||
<option value="targets" ${a.mode === 'targets' ? 'selected' : ''}>指定目标(targets)— 发到配置的目标渠道</option>
|
||||
<option value="both" ${a.mode === 'both' ? 'selected' : ''}>两者都发(both)</option>
|
||||
<option value="session" ${(a.mode || 'session') === 'session' ? 'selected' : ''}>${t('communication.approvalsModeSession')}</option>
|
||||
<option value="targets" ${a.mode === 'targets' ? 'selected' : ''}>${t('communication.approvalsModeTargets')}</option>
|
||||
<option value="both" ${a.mode === 'both' ? 'selected' : ''}>${t('communication.approvalsModeBoth')}</option>
|
||||
</select>
|
||||
</div>
|
||||
${toggleRow('approvals-forwardExec', '转发执行请求', '将 exec 审批请求转发到渠道(默认关闭,低风险场景可开启)', !!a.enabled)}
|
||||
${toggleRow('approvals-forwardExec', t('communication.approvalsForwardExec'), t('communication.approvalsForwardExecHint'), !!a.enabled)}
|
||||
</div>
|
||||
`
|
||||
el.querySelectorAll('input, select').forEach(inp => {
|
||||
|
||||
@@ -9,20 +9,23 @@ import { icon } from '../lib/icons.js'
|
||||
import { onGatewayChange } from '../lib/app-state.js'
|
||||
import { wsClient } from '../lib/ws-client.js'
|
||||
import { api, invalidate } from '../lib/tauri-api.js'
|
||||
import { t } from '../lib/i18n.js'
|
||||
|
||||
let _unsub = null
|
||||
|
||||
// ── Cron 表达式快捷预设 ──
|
||||
|
||||
const CRON_SHORTCUTS = [
|
||||
{ expr: '*/5 * * * *', text: '每 5 分钟' },
|
||||
{ expr: '*/15 * * * *', text: '每 15 分钟' },
|
||||
{ expr: '0 * * * *', text: '每小时整点' },
|
||||
{ expr: '0 9 * * *', text: '每天 9:00' },
|
||||
{ expr: '0 18 * * *', text: '每天 18:00' },
|
||||
{ expr: '0 9 * * 1', text: '每周一 9:00' },
|
||||
{ expr: '0 9 1 * *', text: '每月 1 号 9:00' },
|
||||
]
|
||||
function CRON_SHORTCUTS() {
|
||||
return [
|
||||
{ expr: '*/5 * * * *', text: t('cron.cronEvery5min') },
|
||||
{ expr: '*/15 * * * *', text: t('cron.cronEvery15min') },
|
||||
{ expr: '0 * * * *', text: t('cron.cronHourly') },
|
||||
{ expr: '0 9 * * *', text: t('cron.cronDaily9') },
|
||||
{ expr: '0 18 * * *', text: t('cron.cronDaily18') },
|
||||
{ expr: '0 9 * * 1', text: t('cron.cronMonday9') },
|
||||
{ expr: '0 9 1 * *', text: t('cron.cronMonthly1') },
|
||||
]
|
||||
}
|
||||
|
||||
// ── 页面生命周期 ──
|
||||
|
||||
@@ -32,22 +35,22 @@ export async function render() {
|
||||
|
||||
page.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">定时任务</h1>
|
||||
<p class="page-desc">创建计划任务,让 AI 按设定时间自动执行指令</p>
|
||||
<h1 class="page-title">${t('cron.title')}</h1>
|
||||
<p class="page-desc">${t('cron.desc')}</p>
|
||||
</div>
|
||||
<div id="cron-gw-hint" style="display:none;margin-bottom:var(--space-md)">
|
||||
<div class="config-section" style="border-left:3px solid var(--warning);padding:12px 16px">
|
||||
<div style="display:flex;align-items:center;gap:8px;color:var(--text-secondary);font-size:var(--font-size-sm)">
|
||||
${icon('alert-circle', 16)}
|
||||
<span>定时任务通过 Gateway 管理。请先启动 Gateway 后使用此功能。</span>
|
||||
<a href="#/services" class="btn btn-sm btn-secondary" style="margin-left:auto;font-size:11px">服务管理</a>
|
||||
<span>${t('cron.gwHint')}</span>
|
||||
<a href="#/services" class="btn btn-sm btn-secondary" style="margin-left:auto;font-size:11px">${t('cron.goServices')}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="cron-stats" class="stat-cards" style="margin-bottom:var(--space-lg)"></div>
|
||||
<div class="config-actions" style="margin-bottom:var(--space-md)">
|
||||
<button class="btn btn-primary btn-sm" id="btn-new-task">+ 创建任务</button>
|
||||
<button class="btn btn-secondary btn-sm" id="btn-refresh-tasks">刷新</button>
|
||||
<button class="btn btn-primary btn-sm" id="btn-new-task">${t('cron.newTask')}</button>
|
||||
<button class="btn btn-secondary btn-sm" id="btn-refresh-tasks">${t('cron.refresh')}</button>
|
||||
</div>
|
||||
<div id="cron-list"></div>
|
||||
`
|
||||
@@ -86,7 +89,7 @@ async function fixInvalidCronConfig() {
|
||||
delete config.cron.jobs
|
||||
if (Object.keys(config.cron).length === 0) delete config.cron
|
||||
await api.writeOpenclawConfig(config)
|
||||
toast('已自动修复配置(移除无效的 cron.jobs)', 'info')
|
||||
toast(t('cron.fixedConfig'), 'info')
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
@@ -122,7 +125,7 @@ async function fetchJobs(page, state) {
|
||||
|
||||
state.jobs = jobs.map(j => ({
|
||||
id: j.id,
|
||||
name: j.name || j.id || '未命名',
|
||||
name: j.name || j.id || t('cron.unknown'),
|
||||
description: j.description || '',
|
||||
message: j.payload?.message || j.payload?.text || '',
|
||||
payloadKind: j.payload?.kind || 'agentTurn',
|
||||
@@ -134,7 +137,7 @@ async function fetchJobs(page, state) {
|
||||
lastError: j.state?.lastError || null,
|
||||
}))
|
||||
} catch (e) {
|
||||
toast('获取任务列表失败: ' + e, 'error')
|
||||
toast(t('cron.fetchFailed') + ': ' + e, 'error')
|
||||
state.jobs = []
|
||||
}
|
||||
|
||||
@@ -154,19 +157,19 @@ function renderStats(page, state) {
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-header"><span class="stat-card-label">总任务</span></div>
|
||||
<div class="stat-card-header"><span class="stat-card-label">${t('cron.totalTasks')}</span></div>
|
||||
<div class="stat-card-value">${total}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-header"><span class="stat-card-label">运行中</span></div>
|
||||
<div class="stat-card-header"><span class="stat-card-label">${t('cron.running')}</span></div>
|
||||
<div class="stat-card-value" style="color:var(--success)">${active}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-header"><span class="stat-card-label">已暂停</span></div>
|
||||
<div class="stat-card-header"><span class="stat-card-label">${t('cron.paused')}</span></div>
|
||||
<div class="stat-card-value" style="color:var(--text-tertiary)">${paused}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-header"><span class="stat-card-label">近期失败</span></div>
|
||||
<div class="stat-card-header"><span class="stat-card-label">${t('cron.recentFailed')}</span></div>
|
||||
<div class="stat-card-value" style="color:${failed ? 'var(--error)' : 'var(--text-tertiary)'}">${failed}</div>
|
||||
</div>
|
||||
`
|
||||
@@ -189,8 +192,8 @@ function renderList(page, state) {
|
||||
el.innerHTML = `
|
||||
<div style="text-align:center;padding:40px 0;color:var(--text-tertiary)">
|
||||
<div style="margin-bottom:12px;color:var(--text-tertiary)">${icon('clock', 48)}</div>
|
||||
<div style="font-size:var(--font-size-md);margin-bottom:6px">暂无定时任务</div>
|
||||
<div style="font-size:var(--font-size-sm)">点击「+ 创建任务」添加你的第一个计划任务</div>
|
||||
<div style="font-size:var(--font-size-md);margin-bottom:6px">${t('cron.noTasks')}</div>
|
||||
<div style="font-size:var(--font-size-sm)">${t('cron.noTasksHint')}</div>
|
||||
</div>
|
||||
`
|
||||
return
|
||||
@@ -211,7 +214,7 @@ function renderList(page, state) {
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">
|
||||
<span style="font-weight:600">${escapeHtml(job.name)}</span>
|
||||
<span class="cron-badge ${job.enabled ? 'active' : 'paused'}">${job.enabled ? '运行中' : '已暂停'}</span>
|
||||
<span class="cron-badge ${job.enabled ? 'active' : 'paused'}">${job.enabled ? t('cron.statusRunning') : t('cron.statusPaused')}</span>
|
||||
${lastRunHtml}
|
||||
</div>
|
||||
<div style="font-size:var(--font-size-sm);color:var(--text-tertiary);margin-bottom:6px">
|
||||
@@ -227,7 +230,7 @@ function renderList(page, state) {
|
||||
` : ''}
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;flex-shrink:0">
|
||||
<button class="btn btn-sm btn-secondary" data-action="trigger" title="立即执行">${icon('play', 14)}</button>
|
||||
<button class="btn btn-sm btn-secondary" data-action="trigger" title="${t('cron.triggerSuccess')}">${icon('play', 14)}</button>
|
||||
<button class="btn btn-sm btn-secondary" data-action="toggle">${job.enabled ? icon('pause', 14) : icon('play', 14)}</button>
|
||||
<button class="btn btn-sm btn-secondary" data-action="edit">${icon('edit', 14)}</button>
|
||||
<button class="btn btn-sm btn-danger" data-action="delete">${icon('trash', 14)}</button>
|
||||
@@ -248,9 +251,9 @@ function renderList(page, state) {
|
||||
btn.disabled = true
|
||||
try {
|
||||
await wsClient.request('cron.run', { jobId: jid })
|
||||
toast('任务已触发执行', 'success')
|
||||
toast(t('cron.triggerSuccess'), 'success')
|
||||
setTimeout(() => fetchJobs(page, state), 2000)
|
||||
} catch (err) { toast('触发失败: ' + err, 'error') }
|
||||
} catch (err) { toast(t('cron.triggerFailed') + ': ' + err, 'error') }
|
||||
finally { btn.disabled = false }
|
||||
}
|
||||
|
||||
@@ -260,23 +263,23 @@ function renderList(page, state) {
|
||||
btn.innerHTML = icon('refresh-cw', 14)
|
||||
try {
|
||||
await wsClient.request('cron.update', { jobId: jid, patch: { enabled: !job.enabled } })
|
||||
toast(job.enabled ? '已暂停' : '已启用', 'info')
|
||||
toast(job.enabled ? t('cron.togglePaused') : t('cron.toggleEnabled'), 'info')
|
||||
await fetchJobs(page, state)
|
||||
} catch (err) { toast('操作失败: ' + err, 'error'); btn.disabled = false; btn.innerHTML = job.enabled ? icon('pause', 14) : icon('play', 14) }
|
||||
} catch (err) { toast(t('cron.toggleFailed') + ': ' + err, 'error'); btn.disabled = false; btn.innerHTML = job.enabled ? icon('pause', 14) : icon('play', 14) }
|
||||
}
|
||||
|
||||
card.querySelector('[data-action="edit"]').onclick = () => openTaskDialog(job, page, state)
|
||||
|
||||
card.querySelector('[data-action="delete"]').onclick = async function() {
|
||||
const btn = this
|
||||
const yes = await showConfirm(`确定删除任务「${job.name}」?`)
|
||||
const yes = await showConfirm(t('cron.confirmDelete', { name: job.name }))
|
||||
if (!yes) return
|
||||
if (btn) btn.disabled = true
|
||||
try {
|
||||
await wsClient.request('cron.remove', { jobId: jid })
|
||||
toast('已删除', 'info')
|
||||
toast(t('cron.deleted'), 'info')
|
||||
await fetchJobs(page, state)
|
||||
} catch (err) { toast('删除失败: ' + err, 'error'); if (btn) btn.disabled = false }
|
||||
} catch (err) { toast(t('cron.deleteFailed') + ': ' + err, 'error'); if (btn) btn.disabled = false }
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -285,49 +288,49 @@ function renderList(page, state) {
|
||||
|
||||
async function openTaskDialog(job, page, state) {
|
||||
if (!isGatewayUp()) {
|
||||
toast('Gateway 未连接,无法管理定时任务。请先启动 Gateway', 'warning')
|
||||
toast(t('cron.gwNotConnected'), 'warning')
|
||||
return
|
||||
}
|
||||
const isEdit = !!job
|
||||
const initSchedule = extractCronExpr(job?.schedule) || '0 9 * * *'
|
||||
const formId = 'cron-form-' + Date.now()
|
||||
|
||||
const shortcutsHtml = CRON_SHORTCUTS.map(s => {
|
||||
const shortcutsHtml = CRON_SHORTCUTS().map(s => {
|
||||
const selected = s.expr === initSchedule ? 'selected' : ''
|
||||
return `<button type="button" class="btn btn-sm ${selected ? 'btn-primary' : 'btn-secondary'} cron-shortcut" data-expr="${s.expr}">${s.text}</button>`
|
||||
}).join('')
|
||||
|
||||
// 先用默认选项,弹窗后异步加载 Agent 列表
|
||||
const agentOptionsHtml = `<option value="" ${!job?.agentId ? 'selected' : ''}>默认 Agent</option>${job?.agentId ? `<option value="${escapeAttr(job.agentId)}" selected>${escapeHtml(job.agentId)}</option>` : ''}`
|
||||
const agentOptionsHtml = `<option value="" ${!job?.agentId ? 'selected' : ''}>${t('cron.taskAgentDefault')}</option>${job?.agentId ? `<option value="${escapeAttr(job.agentId)}" selected>${escapeHtml(job.agentId)}</option>` : ''}`
|
||||
|
||||
const content = `
|
||||
<form id="${formId}" style="display:flex;flex-direction:column;gap:var(--space-md)">
|
||||
<div class="form-group">
|
||||
<label class="form-label">任务名称 *</label>
|
||||
<input class="form-input" name="name" value="${escapeAttr(job?.name || '')}" placeholder="如:每日摘要推送" autofocus>
|
||||
<label class="form-label">${t('cron.taskName')}</label>
|
||||
<input class="form-input" name="name" value="${escapeAttr(job?.name || '')}" placeholder="${t('cron.taskNamePlaceholder')}" autofocus>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">执行指令 *</label>
|
||||
<textarea class="form-input" name="message" rows="3" placeholder="AI 将在触发时执行这段指令">${escapeHtml(job?.message || '')}</textarea>
|
||||
<label class="form-label">${t('cron.taskMessage')}</label>
|
||||
<textarea class="form-input" name="message" rows="3" placeholder="${t('cron.taskMessagePlaceholder')}">${escapeHtml(job?.message || '')}</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">指定 Agent</label>
|
||||
<label class="form-label">${t('cron.taskAgent')}</label>
|
||||
<select class="form-input" name="agentId">${agentOptionsHtml}</select>
|
||||
<div class="form-hint">不选则使用默认 Agent 执行</div>
|
||||
<div class="form-hint">${t('cron.taskAgentHint')}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">投递渠道</label>
|
||||
<select class="form-input" name="deliveryChannel"><option value="">无(主会话)</option></select>
|
||||
<div class="form-hint">配置了多个消息渠道时必须指定,否则任务会报错</div>
|
||||
<label class="form-label">${t('cron.taskDelivery')}</label>
|
||||
<select class="form-input" name="deliveryChannel"><option value="">${t('cron.taskDeliveryNone')}</option></select>
|
||||
<div class="form-hint">${t('cron.taskDeliveryHint')}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">执行周期</label>
|
||||
<label class="form-label">${t('cron.taskSchedule')}</label>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px">${shortcutsHtml}</div>
|
||||
<input class="form-input" name="schedule" value="${escapeAttr(initSchedule)}" placeholder="Cron 表达式,如 0 9 * * *">
|
||||
<input class="form-input" name="schedule" value="${escapeAttr(initSchedule)}" placeholder="${t('cron.taskSchedulePlaceholder')}">
|
||||
<div class="form-hint" id="cron-preview">${describeCron(initSchedule)}</div>
|
||||
</div>
|
||||
<div class="form-group" style="display:flex;align-items:center;justify-content:space-between">
|
||||
<label class="form-label" style="margin:0">创建后立即启用</label>
|
||||
<label class="form-label" style="margin:0">${t('cron.taskEnableNow')}</label>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" name="enabled" ${job?.enabled !== false ? 'checked' : ''}>
|
||||
<span class="toggle-slider"></span>
|
||||
@@ -337,10 +340,10 @@ async function openTaskDialog(job, page, state) {
|
||||
`
|
||||
|
||||
const modal = showContentModal({
|
||||
title: isEdit ? '编辑任务' : '创建定时任务',
|
||||
title: isEdit ? t('cron.editTitle') : t('cron.createTitle'),
|
||||
content,
|
||||
buttons: [
|
||||
{ label: isEdit ? '保存修改' : '创建', className: 'btn btn-primary', id: 'btn-cron-save' },
|
||||
{ label: isEdit ? t('cron.saveEdit') : t('cron.saveCreate'), className: 'btn btn-primary', id: 'btn-cron-save' },
|
||||
],
|
||||
width: 500,
|
||||
})
|
||||
@@ -353,7 +356,7 @@ async function openTaskDialog(job, page, state) {
|
||||
const select = modal.querySelector('select[name="deliveryChannel"]')
|
||||
if (!select) return
|
||||
const current = job?.delivery?.channel || ''
|
||||
select.innerHTML = `<option value="">无(主会话)</option>` + channelIds.map(ch =>
|
||||
select.innerHTML = `<option value="">${t('cron.taskDeliveryNone')}</option>` + channelIds.map(ch =>
|
||||
`<option value="${escapeAttr(ch)}" ${ch === current ? 'selected' : ''}>${escapeHtml(ch)}</option>`
|
||||
).join('')
|
||||
}).catch(() => {})
|
||||
@@ -365,7 +368,7 @@ async function openTaskDialog(job, page, state) {
|
||||
const select = modal.querySelector('select[name="agentId"]')
|
||||
if (!select) return
|
||||
const currentVal = select.value
|
||||
select.innerHTML = `<option value="">默认 Agent</option>` + agents.map(a =>
|
||||
select.innerHTML = `<option value="">${t('cron.taskAgentDefault')}</option>` + agents.map(a =>
|
||||
`<option value="${escapeAttr(a.id)}" ${a.id === (job?.agentId || currentVal) ? 'selected' : ''}>${escapeHtml(a.name || a.id)}</option>`
|
||||
).join('')
|
||||
}).catch(() => {})
|
||||
@@ -408,13 +411,13 @@ async function openTaskDialog(job, page, state) {
|
||||
const agentId = modal.querySelector('select[name="agentId"]').value || undefined
|
||||
const enabled = modal.querySelector('input[name="enabled"]').checked
|
||||
|
||||
if (!name) { toast('请输入任务名称', 'warning'); return }
|
||||
if (!message) { toast('请输入执行指令', 'warning'); return }
|
||||
if (!schedule) { toast('请设置执行周期', 'warning'); return }
|
||||
if (!name) { toast(t('cron.nameRequired'), 'warning'); return }
|
||||
if (!message) { toast(t('cron.messageRequired'), 'warning'); return }
|
||||
if (!schedule) { toast(t('cron.scheduleRequired'), 'warning'); return }
|
||||
|
||||
const saveBtn = modal.querySelector('#btn-cron-save')
|
||||
saveBtn.disabled = true
|
||||
saveBtn.textContent = '保存中...'
|
||||
saveBtn.textContent = t('cron.saving')
|
||||
|
||||
try {
|
||||
if (isEdit) {
|
||||
@@ -427,7 +430,7 @@ async function openTaskDialog(job, page, state) {
|
||||
patch.delivery = { mode: 'push', to: deliveryChannel, channel: deliveryChannel }
|
||||
}
|
||||
await wsClient.request('cron.update', { jobId: job.id, patch })
|
||||
toast('任务已更新', 'success')
|
||||
toast(t('cron.updated'), 'success')
|
||||
} else {
|
||||
const params = {
|
||||
name,
|
||||
@@ -441,14 +444,14 @@ async function openTaskDialog(job, page, state) {
|
||||
params.delivery = { mode: 'push', to: deliveryChannel, channel: deliveryChannel }
|
||||
}
|
||||
await wsClient.request('cron.add', params)
|
||||
toast('任务已创建', 'success')
|
||||
toast(t('cron.created'), 'success')
|
||||
}
|
||||
modal.close?.() || modal.remove?.()
|
||||
await fetchJobs(page, state)
|
||||
} catch (e) {
|
||||
toast('保存失败: ' + e, 'error')
|
||||
toast(t('cron.saveFailed') + ': ' + e, 'error')
|
||||
saveBtn.disabled = false
|
||||
saveBtn.textContent = isEdit ? '保存修改' : '创建'
|
||||
saveBtn.textContent = isEdit ? t('cron.saveEdit') : t('cron.saveCreate')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -467,38 +470,38 @@ function extractCronExpr(schedule) {
|
||||
/** 将 cron 表达式转为可读文字 */
|
||||
function describeCron(raw) {
|
||||
const expr = typeof raw === 'string' ? raw : extractCronExpr(raw)
|
||||
if (!expr) return '未知周期'
|
||||
if (!expr) return t('cron.unknownPeriod')
|
||||
|
||||
const hit = CRON_SHORTCUTS.find(s => s.expr === expr)
|
||||
const hit = CRON_SHORTCUTS().find(s => s.expr === expr)
|
||||
if (hit) return hit.text
|
||||
|
||||
const parts = expr.split(' ')
|
||||
if (parts.length !== 5) return expr
|
||||
|
||||
const [min, hr, dom, , dow] = parts
|
||||
if (min === '*' && hr === '*') return '每分钟'
|
||||
if (min.startsWith('*/')) return `每 ${min.slice(2)} 分钟`
|
||||
if (hr === '*' && min === '0') return '每小时整点'
|
||||
if (dow !== '*' && dom === '*') return `每周 ${dow} 的 ${hr}:${min.padStart(2, '0')}`
|
||||
if (dom !== '*') return `每月 ${dom} 号 ${hr}:${min.padStart(2, '0')}`
|
||||
if (hr !== '*') return `每天 ${hr}:${min.padStart(2, '0')}`
|
||||
if (min === '*' && hr === '*') return t('cron.everyMinute')
|
||||
if (min.startsWith('*/')) return t('cron.everyNMin', { n: min.slice(2) })
|
||||
if (hr === '*' && min === '0') return t('cron.hourlyOnTheHour')
|
||||
if (dow !== '*' && dom === '*') return `${dow} ${hr}:${min.padStart(2, '0')}`
|
||||
if (dom !== '*') return `${dom} ${hr}:${min.padStart(2, '0')}`
|
||||
if (hr !== '*') return `${hr}:${min.padStart(2, '0')}`
|
||||
|
||||
return expr
|
||||
}
|
||||
|
||||
/** 将 Gateway 返回的 CronSchedule 对象也处理成可读文字 */
|
||||
function describeCronFull(schedule) {
|
||||
if (!schedule) return '未知'
|
||||
if (!schedule) return t('cron.unknown')
|
||||
if (typeof schedule === 'string') return describeCron(schedule)
|
||||
if (typeof schedule === 'object') {
|
||||
if (schedule.kind === 'every' && schedule.everyMs) {
|
||||
const ms = schedule.everyMs
|
||||
if (ms < 60000) return `每 ${Math.round(ms / 1000)} 秒`
|
||||
if (ms < 3600000) return `每 ${Math.round(ms / 60000)} 分钟`
|
||||
return `每 ${Math.round(ms / 3600000)} 小时`
|
||||
if (ms < 60000) return t('cron.everyNSec', { n: Math.round(ms / 1000) })
|
||||
if (ms < 3600000) return t('cron.everyNMin', { n: Math.round(ms / 60000) })
|
||||
return t('cron.everyNHour', { n: Math.round(ms / 3600000) })
|
||||
}
|
||||
if (schedule.kind === 'at' && schedule.at) {
|
||||
try { return '一次性: ' + new Date(schedule.at).toLocaleString() } catch { return schedule.at }
|
||||
try { return t('cron.oneTime') + ': ' + new Date(schedule.at).toLocaleString() } catch { return schedule.at }
|
||||
}
|
||||
if (schedule.kind === 'cron' && schedule.expr) return describeCron(schedule.expr)
|
||||
}
|
||||
@@ -508,12 +511,12 @@ function describeCronFull(schedule) {
|
||||
/** 相对时间描述 */
|
||||
function relativeTime(ts) {
|
||||
if (!ts) return ''
|
||||
const t = typeof ts === 'number' ? ts : new Date(ts).getTime()
|
||||
const diff = Date.now() - t
|
||||
if (diff < 60000) return '刚刚'
|
||||
if (diff < 3600000) return Math.floor(diff / 60000) + ' 分钟前'
|
||||
if (diff < 86400000) return Math.floor(diff / 3600000) + ' 小时前'
|
||||
return Math.floor(diff / 86400000) + ' 天前'
|
||||
const ms = typeof ts === 'number' ? ts : new Date(ts).getTime()
|
||||
const diff = Date.now() - ms
|
||||
if (diff < 60000) return t('cron.justNow')
|
||||
if (diff < 3600000) return t('cron.minutesAgo', { n: Math.floor(diff / 60000) })
|
||||
if (diff < 86400000) return t('cron.hoursAgo', { n: Math.floor(diff / 3600000) })
|
||||
return t('cron.daysAgo', { n: Math.floor(diff / 86400000) })
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
|
||||
@@ -46,7 +46,7 @@ export async function render() {
|
||||
console.error('[dashboard] loadDashboardData 异常:', e)
|
||||
const cardsEl = page.querySelector('#stat-cards')
|
||||
if (cardsEl && cardsEl.querySelector('.loading-placeholder')) {
|
||||
cardsEl.innerHTML = `<div class="stat-card" style="grid-column:1/-1;text-align:center;color:var(--text-secondary)"><div>加载失败: ${escapeHtml(String(e?.message || e))}</div><button class="btn btn-sm btn-secondary" style="margin-top:8px" onclick="this.closest('.page')&&this.closest('.page').__retryLoad?.()">重试</button></div>`
|
||||
cardsEl.innerHTML = `<div class="stat-card" style="grid-column:1/-1;text-align:center;color:var(--text-secondary)"><div>${t('common.loadFailed')}: ${escapeHtml(String(e?.message || e))}</div><button class="btn btn-sm btn-secondary" style="margin-top:8px" onclick="this.closest('.page')&&this.closest('.page').__retryLoad?.()">${t('dashboard.retry')}</button></div>`
|
||||
}
|
||||
})
|
||||
page.__retryLoad = () => loadDashboardData(page).catch(() => {})
|
||||
@@ -93,8 +93,8 @@ async function loadDashboardData(page, fullRefresh = false) {
|
||||
const services = servicesRes.status === 'fulfilled' ? servicesRes.value : []
|
||||
const version = (versionRes.status === 'fulfilled' && versionRes.value) ? versionRes.value : {}
|
||||
const config = configRes.status === 'fulfilled' ? configRes.value : null
|
||||
if (servicesRes.status === 'rejected') toast('服务状态加载失败', 'error')
|
||||
if (versionRes.status === 'rejected') toast('版本信息加载失败', 'error')
|
||||
if (servicesRes.status === 'rejected') toast(t('dashboard.servicesLoadFail'), 'error')
|
||||
if (versionRes.status === 'rejected') toast(t('dashboard.versionLoadFail'), 'error')
|
||||
|
||||
// 自愈:补全关键默认值(先重新读取最新配置再 patch,避免用缓存覆盖其他页面的写入)
|
||||
if (config) {
|
||||
@@ -222,12 +222,12 @@ function renderOverview(page, services, mcpConfig, backups, config, agents, stat
|
||||
}
|
||||
|
||||
const latestBackup = backups.length > 0 ? backups.sort((a,b) => b.created_at - a.created_at)[0] : null
|
||||
const lastUpdate = config?.meta?.lastTouchedVersion || '未知'
|
||||
const lastUpdate = config?.meta?.lastTouchedVersion || t('common.unknown')
|
||||
const runtimeVer = statusSummary?.runtimeVersion || null
|
||||
const sessions = statusSummary?.sessions || null
|
||||
|
||||
const gwPort = config?.gateway?.port || 18789
|
||||
const primaryModel = config?.agents?.defaults?.model?.primary || '未设置'
|
||||
const primaryModel = config?.agents?.defaults?.model?.primary || t('dashboard.notSet')
|
||||
|
||||
containerEl.innerHTML = `
|
||||
<div class="dashboard-overview">
|
||||
@@ -238,13 +238,13 @@ function renderOverview(page, services, mcpConfig, backups, config, agents, stat
|
||||
</div>
|
||||
<div class="overview-card-body">
|
||||
<div class="overview-card-title">Gateway</div>
|
||||
<div class="overview-card-value" style="color:${gw?.running ? 'var(--success)' : 'var(--error)'}">${gw?.running ? '运行中' : '已停止'}</div>
|
||||
<div class="overview-card-meta">端口 ${gwPort} ${gw?.pid ? '· PID ' + gw.pid : ''}</div>
|
||||
<div class="overview-card-value" style="color:${gw?.running ? 'var(--success)' : 'var(--error)'}">${gw?.running ? t('common.running') : t('common.stopped')}</div>
|
||||
<div class="overview-card-meta">${t('dashboard.port')} ${gwPort} ${gw?.pid ? '· PID ' + gw.pid : ''}</div>
|
||||
</div>
|
||||
<div class="overview-card-actions">
|
||||
${gw?.running
|
||||
? '<button class="btn btn-danger btn-xs" data-action="stop-gw">停止</button><button class="btn btn-secondary btn-xs" data-action="restart-gw">重启</button>'
|
||||
: '<button class="btn btn-primary btn-xs" data-action="start-gw">启动</button>'
|
||||
? '<button class="btn btn-danger btn-xs" data-action="stop-gw">' + t('dashboard.stopBtn') + '</button><button class="btn btn-secondary btn-xs" data-action="restart-gw">' + t('dashboard.restartBtn') + '</button>'
|
||||
: '<button class="btn btn-primary btn-xs" data-action="start-gw">' + t('dashboard.startBtn') + '</button>'
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -254,9 +254,9 @@ function renderOverview(page, services, mcpConfig, backups, config, agents, stat
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"/></svg>
|
||||
</div>
|
||||
<div class="overview-card-body">
|
||||
<div class="overview-card-title">主模型</div>
|
||||
<div class="overview-card-title">${t('dashboard.primaryModel')}</div>
|
||||
<div class="overview-card-value" style="font-size:var(--font-size-sm)">${primaryModel}</div>
|
||||
<div class="overview-card-meta">并发上限 ${config?.agents?.defaults?.maxConcurrent || 4}</div>
|
||||
<div class="overview-card-meta">${t('dashboard.maxConcurrent')} ${config?.agents?.defaults?.maxConcurrent || 4}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -265,9 +265,9 @@ function renderOverview(page, services, mcpConfig, backups, config, agents, stat
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>
|
||||
</div>
|
||||
<div class="overview-card-body">
|
||||
<div class="overview-card-title">MCP 工具</div>
|
||||
<div class="overview-card-value">${mcpCount} 个</div>
|
||||
<div class="overview-card-meta">已挂载扩展</div>
|
||||
<div class="overview-card-title">${t('dashboard.mcpTools')}</div>
|
||||
<div class="overview-card-value">${mcpCount}</div>
|
||||
<div class="overview-card-meta">${t('dashboard.mountedExtensions')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -276,9 +276,9 @@ function renderOverview(page, services, mcpConfig, backups, config, agents, stat
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||
</div>
|
||||
<div class="overview-card-body">
|
||||
<div class="overview-card-title">最近备份</div>
|
||||
<div class="overview-card-value" style="font-size:var(--font-size-sm)">${latestBackup ? formatDate(latestBackup.created_at) : '从无备份'}</div>
|
||||
<div class="overview-card-meta">${backups.length} 个备份文件</div>
|
||||
<div class="overview-card-title">${t('dashboard.recentBackup')}</div>
|
||||
<div class="overview-card-value" style="font-size:var(--font-size-sm)">${latestBackup ? formatDate(latestBackup.created_at) : t('dashboard.noBackup')}</div>
|
||||
<div class="overview-card-meta">${t('dashboard.backupCount', { count: backups.length })}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -287,9 +287,9 @@ function renderOverview(page, services, mcpConfig, backups, config, agents, stat
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/></svg>
|
||||
</div>
|
||||
<div class="overview-card-body">
|
||||
<div class="overview-card-title">Agent 舰队</div>
|
||||
<div class="overview-card-value">${agents.length} 个</div>
|
||||
<div class="overview-card-meta">${agents.filter(a => a.workspace).length} 个独立工作区</div>
|
||||
<div class="overview-card-title">${t('dashboard.agentFleet')}</div>
|
||||
<div class="overview-card-value">${agents.length}</div>
|
||||
<div class="overview-card-meta">${t('dashboard.workspaceCount', { count: agents.filter(a => a.workspace).length })}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -298,7 +298,7 @@ function renderOverview(page, services, mcpConfig, backups, config, agents, stat
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
</div>
|
||||
<div class="overview-card-body">
|
||||
<div class="overview-card-title">运行时版本</div>
|
||||
<div class="overview-card-title">${t('dashboard.runtimeVersion')}</div>
|
||||
<div class="overview-card-value" style="font-size:var(--font-size-sm)">${runtimeVer || lastUpdate}</div>
|
||||
<div class="overview-card-meta">${runtimeVer ? 'OpenClaw Runtime' : 'openclaw.json'}</div>
|
||||
</div>
|
||||
@@ -337,14 +337,14 @@ function renderSessionStatus(sessions) {
|
||||
<div class="session-bar-wrap">
|
||||
<div class="session-bar" style="width:${Math.min(pct, 100)}%;background:${barColor}"></div>
|
||||
</div>
|
||||
<div class="session-row-meta">${tokens} / ${ctx} · 剩余 ${remaining} · ${pct}%</div>
|
||||
<div class="session-row-meta">${tokens} / ${ctx} · ${t('dashboard.remaining')} ${remaining} · ${pct}%</div>
|
||||
</div>`
|
||||
})
|
||||
const defaultModel = sessions.defaults?.model || '—'
|
||||
const defaultCtx = sessions.defaults?.contextTokens ? `${Math.round(sessions.defaults.contextTokens / 1000)}k` : '—'
|
||||
return `
|
||||
<div class="config-section" style="margin-top:16px">
|
||||
<div class="config-section-title">活跃会话 <span style="font-weight:normal;color:var(--text-tertiary);font-size:var(--font-size-xs)">${sessions.count || 0} 个 · 默认模型 ${escapeHtml(defaultModel)} · 上下文 ${defaultCtx}</span></div>
|
||||
<div class="config-section-title">${t('dashboard.activeSessions')} <span style="font-weight:normal;color:var(--text-tertiary);font-size:var(--font-size-xs)">${sessions.count || 0} · ${t('dashboard.defaultModel')} ${escapeHtml(defaultModel)} · ${t('dashboard.context')} ${defaultCtx}</span></div>
|
||||
<div class="session-list">${rows.join('')}</div>
|
||||
</div>`
|
||||
}
|
||||
@@ -352,7 +352,7 @@ function renderSessionStatus(sessions) {
|
||||
function renderLogs(page, logs) {
|
||||
const logsEl = page.querySelector('#recent-logs')
|
||||
if (!logs) {
|
||||
logsEl.innerHTML = '<div style="color:var(--text-tertiary);padding:12px">暂无日志</div>'
|
||||
logsEl.innerHTML = '<div style="color:var(--text-tertiary);padding:12px">' + t('dashboard.noLogs') + '</div>'
|
||||
return
|
||||
}
|
||||
const lines = logs.trim().split('\n')
|
||||
@@ -392,7 +392,7 @@ function bindActions(page) {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
} catch (e2) {
|
||||
toast('打开 Control UI 失败: ' + (e2.message || e2), 'error')
|
||||
toast(t('dashboard.openControlUIFail') + ': ' + (e2.message || e2), 'error')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -403,45 +403,45 @@ function bindActions(page) {
|
||||
const action = actionBtn.dataset.action
|
||||
|
||||
if (action === 'start-gw') {
|
||||
actionBtn.disabled = true; actionBtn.textContent = '启动中...'
|
||||
actionBtn.disabled = true; actionBtn.textContent = t('dashboard.starting')
|
||||
try {
|
||||
await api.startService('ai.openclaw.gateway')
|
||||
toast('Gateway 启动指令已发送', 'success')
|
||||
toast(t('dashboard.gwStartSent'), 'success')
|
||||
setTimeout(() => loadDashboardData(page), 2000)
|
||||
} catch (err) { toast('启动失败: ' + err, 'error') }
|
||||
finally { actionBtn.disabled = false; actionBtn.textContent = '启动' }
|
||||
} catch (err) { toast(t('dashboard.startFail') + ': ' + err, 'error') }
|
||||
finally { actionBtn.disabled = false; actionBtn.textContent = t('dashboard.startBtn') }
|
||||
}
|
||||
if (action === 'stop-gw') {
|
||||
actionBtn.disabled = true; actionBtn.textContent = '停止中...'
|
||||
actionBtn.disabled = true; actionBtn.textContent = t('dashboard.stopping')
|
||||
try {
|
||||
await api.stopService('ai.openclaw.gateway')
|
||||
toast('Gateway 已停止', 'success')
|
||||
toast(t('dashboard.gwStopped'), 'success')
|
||||
setTimeout(() => loadDashboardData(page), 1500)
|
||||
} catch (err) { toast('停止失败: ' + err, 'error') }
|
||||
finally { actionBtn.disabled = false; actionBtn.textContent = '停止' }
|
||||
} catch (err) { toast(t('dashboard.stopFail') + ': ' + err, 'error') }
|
||||
finally { actionBtn.disabled = false; actionBtn.textContent = t('dashboard.stopBtn') }
|
||||
}
|
||||
if (action === 'restart-gw') {
|
||||
actionBtn.disabled = true; actionBtn.textContent = '重启中...'
|
||||
actionBtn.disabled = true; actionBtn.textContent = t('dashboard.restarting')
|
||||
try {
|
||||
await api.restartService('ai.openclaw.gateway')
|
||||
toast('Gateway 重启指令已发送', 'success')
|
||||
toast(t('dashboard.gwRestartSent'), 'success')
|
||||
setTimeout(() => loadDashboardData(page), 3000)
|
||||
} catch (err) { toast('重启失败: ' + err, 'error') }
|
||||
finally { actionBtn.disabled = false; actionBtn.textContent = '重启' }
|
||||
} catch (err) { toast(t('dashboard.restartFail') + ': ' + err, 'error') }
|
||||
finally { actionBtn.disabled = false; actionBtn.textContent = t('dashboard.restartBtn') }
|
||||
}
|
||||
})
|
||||
|
||||
btnRestart?.addEventListener('click', async () => {
|
||||
btnRestart.disabled = true
|
||||
btnRestart.classList.add('btn-loading')
|
||||
btnRestart.textContent = '重启中...'
|
||||
btnRestart.textContent = t('dashboard.restarting')
|
||||
try {
|
||||
await api.restartService('ai.openclaw.gateway')
|
||||
} catch (e) {
|
||||
toast('重启失败: ' + e, 'error')
|
||||
toast(t('dashboard.restartFail') + ': ' + e, 'error')
|
||||
btnRestart.disabled = false
|
||||
btnRestart.classList.remove('btn-loading')
|
||||
btnRestart.textContent = '重启 Gateway'
|
||||
btnRestart.textContent = t('dashboard.restartGw')
|
||||
return
|
||||
}
|
||||
// 轮询等待实际重启完成
|
||||
@@ -451,59 +451,59 @@ function bindActions(page) {
|
||||
const s = await api.getServicesStatus()
|
||||
const gw = s?.find?.(x => x.label === 'ai.openclaw.gateway') || s?.[0]
|
||||
if (gw?.running) {
|
||||
toast(`Gateway 已重启 (PID: ${gw.pid})`, 'success')
|
||||
toast(t('dashboard.gwRestarted', { pid: gw.pid }), 'success')
|
||||
btnRestart.disabled = false
|
||||
btnRestart.classList.remove('btn-loading')
|
||||
btnRestart.textContent = '重启 Gateway'
|
||||
btnRestart.textContent = t('dashboard.restartGw')
|
||||
loadDashboardData(page)
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
const sec = Math.floor((Date.now() - t0) / 1000)
|
||||
btnRestart.textContent = `重启中... ${sec}s`
|
||||
btnRestart.textContent = t('dashboard.restarting') + ` ${sec}s`
|
||||
await new Promise(r => setTimeout(r, 1500))
|
||||
}
|
||||
toast('重启超时,Gateway 可能仍在启动中', 'warning')
|
||||
toast(t('dashboard.restartTimeout'), 'warning')
|
||||
btnRestart.disabled = false
|
||||
btnRestart.classList.remove('btn-loading')
|
||||
btnRestart.textContent = '重启 Gateway'
|
||||
btnRestart.textContent = t('dashboard.restartGw')
|
||||
loadDashboardData(page)
|
||||
})
|
||||
|
||||
btnUpdate?.addEventListener('click', async () => {
|
||||
btnUpdate.disabled = true
|
||||
btnUpdate.textContent = '检查中...'
|
||||
btnUpdate.textContent = t('dashboard.checking')
|
||||
try {
|
||||
const info = await api.getVersionInfo()
|
||||
if (info.ahead_of_recommended && info.recommended) {
|
||||
toast(`当前本地版本 ${info.current || ''} 高于推荐稳定版 ${info.recommended},可能存在兼容风险`, 'warning')
|
||||
toast(t('dashboard.versionAheadWarn', { current: info.current || '', recommended: info.recommended }), 'warning')
|
||||
} else if (info.update_available && info.recommended) {
|
||||
toast(`发现推荐稳定版: ${info.recommended}`, 'info')
|
||||
toast(t('dashboard.updateAvailable', { version: info.recommended }), 'info')
|
||||
} else if (info.latest_update_available && info.latest) {
|
||||
toast(`已对齐推荐稳定版,最新上游为 ${info.latest}`, 'info')
|
||||
toast(t('dashboard.alignedWithLatest', { version: info.latest }), 'info')
|
||||
} else {
|
||||
toast('已对齐推荐稳定版', 'success')
|
||||
toast(t('dashboard.upToDate'), 'success')
|
||||
}
|
||||
} catch (e) {
|
||||
toast('检查更新失败: ' + e, 'error')
|
||||
toast(t('dashboard.checkUpdateFail') + ': ' + e, 'error')
|
||||
} finally {
|
||||
btnUpdate.disabled = false
|
||||
btnUpdate.textContent = '检查更新'
|
||||
btnUpdate.textContent = t('dashboard.checkUpdate')
|
||||
}
|
||||
})
|
||||
|
||||
btnCreateBackup?.addEventListener('click', async () => {
|
||||
btnCreateBackup.disabled = true
|
||||
btnCreateBackup.innerHTML = '备份中...'
|
||||
btnCreateBackup.innerHTML = t('dashboard.backingUp')
|
||||
try {
|
||||
const res = await api.createBackup()
|
||||
toast(`已备份: ${res.name}`, 'success')
|
||||
toast(t('dashboard.backupDone', { name: res.name }), 'success')
|
||||
setTimeout(() => loadDashboardData(page), 500)
|
||||
} catch (e) {
|
||||
toast('备份失败: ' + e, 'error')
|
||||
toast(t('dashboard.backupFail') + ': ' + e, 'error')
|
||||
} finally {
|
||||
btnCreateBackup.disabled = false
|
||||
btnCreateBackup.textContent = '创建备份'
|
||||
btnCreateBackup.textContent = t('dashboard.createBackup')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { api } from '../lib/tauri-api.js'
|
||||
import { toast } from '../components/toast.js'
|
||||
import { statusIcon } from '../lib/icons.js'
|
||||
import { t } from '../lib/i18n.js'
|
||||
|
||||
// HTML 转义,防止 XSS
|
||||
function escapeHtml(str) {
|
||||
@@ -22,17 +23,17 @@ export async function render() {
|
||||
|
||||
page.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">扩展工具</h1>
|
||||
<p class="page-desc">管理 cftunnel 内网穿透和 ClawApp 移动客户端</p>
|
||||
<h1 class="page-title">${t('ext.title')}</h1>
|
||||
<p class="page-desc">${t('ext.desc')}</p>
|
||||
</div>
|
||||
<div id="cftunnel-card" class="config-section">
|
||||
<div class="config-section-title">cftunnel 内网穿透</div>
|
||||
<div class="form-hint" style="margin-bottom:var(--space-md)">通过 Cloudflare Tunnel 将本地服务暴露到公网,无需公网 IP 和端口映射。</div>
|
||||
<div class="config-section-title">${t('ext.cftunnelTitle')}</div>
|
||||
<div class="form-hint" style="margin-bottom:var(--space-md)">${t('ext.cftunnelDesc')}</div>
|
||||
<div id="cftunnel-content"><div class="stat-card loading-placeholder" style="height:64px"></div></div>
|
||||
</div>
|
||||
<div id="clawapp-card" class="config-section">
|
||||
<div class="config-section-title">ClawApp 移动客户端</div>
|
||||
<div class="form-hint" style="margin-bottom:var(--space-md)">H5 移动聊天客户端,通过代理服务端连接 Gateway。支持本地和外网访问。</div>
|
||||
<div class="config-section-title">${t('ext.clawappTitle')}</div>
|
||||
<div class="form-hint" style="margin-bottom:var(--space-md)">${t('ext.clawappDesc')}</div>
|
||||
<div id="clawapp-content"><div class="stat-card loading-placeholder" style="height:64px"></div></div>
|
||||
</div>
|
||||
`
|
||||
@@ -57,17 +58,17 @@ async function loadCftunnel(page) {
|
||||
const status = await api.getCftunnelStatus()
|
||||
renderCftunnel(el, status)
|
||||
} catch (e) {
|
||||
el.innerHTML = `<div style="color:var(--error)">加载失败: ${e}</div>`
|
||||
el.innerHTML = `<div style="color:var(--error)">${t('common.loadFailed')}: ${e}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
function renderCftunnel(el, s) {
|
||||
if (!s.installed) {
|
||||
el.innerHTML = `
|
||||
<div style="color:var(--text-tertiary);margin-bottom:var(--space-md)">cftunnel 未安装</div>
|
||||
<div style="color:var(--text-tertiary);margin-bottom:var(--space-md)">${t('ext.cftunnelNotInstalled')}</div>
|
||||
<div style="display:flex;gap:var(--space-sm);align-items:center">
|
||||
<button class="btn btn-primary btn-sm" data-action="install-cftunnel">一键安装</button>
|
||||
<a class="btn btn-secondary btn-sm" href="https://github.com/qingchencloud/cftunnel" target="_blank" rel="noopener">查看文档</a>
|
||||
<button class="btn btn-primary btn-sm" data-action="install-cftunnel">${t('ext.installBtn')}</button>
|
||||
<a class="btn btn-secondary btn-sm" href="https://github.com/qingchencloud/cftunnel" target="_blank" rel="noopener">${t('ext.viewDocs')}</a>
|
||||
</div>
|
||||
<div id="install-progress-area"></div>
|
||||
`
|
||||
@@ -81,25 +82,25 @@ function renderCftunnel(el, s) {
|
||||
<div class="stat-cards" style="margin-bottom:var(--space-md)">
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-header">
|
||||
<span class="stat-card-label">状态</span>
|
||||
<span class="stat-card-label">${t('ext.status')}</span>
|
||||
<span class="status-dot ${running ? 'running' : 'stopped'}"></span>
|
||||
</div>
|
||||
<div class="stat-card-value">${running ? '运行中' : '已停止'}</div>
|
||||
<div class="stat-card-value">${running ? t('ext.running') : t('ext.stopped')}</div>
|
||||
<div class="stat-card-meta">${s.tunnel_name || ''}${s.pid ? ' (PID: ' + s.pid + ')' : ''}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-header"><span class="stat-card-label">版本</span></div>
|
||||
<div class="stat-card-value" style="font-size:var(--font-size-md)">${s.version || '未知'}</div>
|
||||
<div class="stat-card-meta">${routes.length} 条路由</div>
|
||||
<div class="stat-card-header"><span class="stat-card-label">${t('ext.version')}</span></div>
|
||||
<div class="stat-card-value" style="font-size:var(--font-size-md)">${s.version || t('ext.unknown')}</div>
|
||||
<div class="stat-card-meta">${routes.length} ${t('ext.routes')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:var(--space-sm);margin-bottom:var(--space-md)">
|
||||
${running
|
||||
? '<button class="btn btn-danger btn-sm" data-action="cftunnel-down">停止隧道</button>'
|
||||
: '<button class="btn btn-primary btn-sm" data-action="cftunnel-up">启动隧道</button>'
|
||||
? '<button class="btn btn-danger btn-sm" data-action="cftunnel-down">' + t('ext.stopTunnel') + '</button>'
|
||||
: '<button class="btn btn-primary btn-sm" data-action="cftunnel-up">' + t('ext.startTunnel') + '</button>'
|
||||
}
|
||||
<button class="btn btn-secondary btn-sm" data-action="cftunnel-logs">查看日志</button>
|
||||
<button class="btn btn-secondary btn-sm" data-action="cftunnel-refresh">刷新</button>
|
||||
<button class="btn btn-secondary btn-sm" data-action="cftunnel-logs">${t('ext.viewLogs')}</button>
|
||||
<button class="btn btn-secondary btn-sm" data-action="cftunnel-refresh">${t('ext.refresh')}</button>
|
||||
</div>
|
||||
${renderRoutes(routes)}
|
||||
<div id="cftunnel-logs-area"></div>
|
||||
@@ -107,7 +108,7 @@ function renderCftunnel(el, s) {
|
||||
}
|
||||
|
||||
function renderRoutes(routes) {
|
||||
if (!routes.length) return '<div style="color:var(--text-tertiary);padding:var(--space-md) 0">暂无路由</div>'
|
||||
if (!routes.length) return '<div style="color:var(--text-tertiary);padding:var(--space-md) 0">' + t('ext.noRoutes') + '</div>'
|
||||
return `
|
||||
<div class="tunnel-routes">
|
||||
${routes.map(r => `
|
||||
@@ -116,7 +117,7 @@ function renderRoutes(routes) {
|
||||
<span class="tunnel-route-name">${escapeHtml(r.name)}</span>
|
||||
<span class="tunnel-route-badge">
|
||||
<span class="status-dot running" style="width:6px;height:6px"></span>
|
||||
活跃
|
||||
${t('ext.active')}
|
||||
</span>
|
||||
</div>
|
||||
<div class="tunnel-route-domain">
|
||||
@@ -133,7 +134,7 @@ function renderRoutes(routes) {
|
||||
<line x1="6" y1="6" x2="6.01" y2="6"></line>
|
||||
<line x1="6" y1="18" x2="6.01" y2="18"></line>
|
||||
</svg>
|
||||
<span>本地服务:</span>
|
||||
<span>${t('ext.localService')}:</span>
|
||||
<code>${escapeHtml(r.service)}</code>
|
||||
</div>
|
||||
</div>
|
||||
@@ -150,17 +151,17 @@ async function loadClawapp(page) {
|
||||
const status = await api.getClawappStatus()
|
||||
renderClawapp(el, status)
|
||||
} catch (e) {
|
||||
el.innerHTML = `<div style="color:var(--error)">加载失败: ${e}</div>`
|
||||
el.innerHTML = `<div style="color:var(--error)">${t('common.loadFailed')}: ${e}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
function renderClawapp(el, s) {
|
||||
if (!s.installed) {
|
||||
el.innerHTML = `
|
||||
<div style="color:var(--text-tertiary);margin-bottom:var(--space-md)">ClawApp 未安装</div>
|
||||
<div style="color:var(--text-tertiary);margin-bottom:var(--space-md)">${t('ext.clawappNotInstalled')}</div>
|
||||
<div style="display:flex;gap:var(--space-sm);align-items:center">
|
||||
<button class="btn btn-primary btn-sm" data-action="install-clawapp">一键安装</button>
|
||||
<a class="btn btn-secondary btn-sm" href="https://github.com/qingchencloud/clawapp" target="_blank" rel="noopener">查看文档</a>
|
||||
<button class="btn btn-primary btn-sm" data-action="install-clawapp">${t('ext.installBtn')}</button>
|
||||
<a class="btn btn-secondary btn-sm" href="https://github.com/qingchencloud/clawapp" target="_blank" rel="noopener">${t('ext.viewDocs')}</a>
|
||||
</div>
|
||||
<div id="install-clawapp-progress-area"></div>
|
||||
`
|
||||
@@ -172,22 +173,22 @@ function renderClawapp(el, s) {
|
||||
<div class="stat-cards" style="margin-bottom:var(--space-md)">
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-header">
|
||||
<span class="stat-card-label">状态</span>
|
||||
<span class="stat-card-label">${t('ext.status')}</span>
|
||||
<span class="status-dot ${running ? 'running' : 'stopped'}"></span>
|
||||
</div>
|
||||
<div class="stat-card-value">${running ? '运行中' : '已停止'}</div>
|
||||
<div class="stat-card-meta">${s.pid ? 'PID: ' + s.pid : ''}${s.port ? ' 端口: ' + s.port : ''}</div>
|
||||
<div class="stat-card-value">${running ? t('ext.running') : t('ext.stopped')}</div>
|
||||
<div class="stat-card-meta">${s.pid ? 'PID: ' + s.pid : ''}${s.port ? ' ' + t('ext.port') + ': ' + s.port : ''}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-header"><span class="stat-card-label">访问地址</span></div>
|
||||
<div class="stat-card-header"><span class="stat-card-label">${t('ext.accessUrl')}</span></div>
|
||||
<div class="stat-card-value" style="font-size:var(--font-size-sm)">${s.url || 'http://localhost:3210'}</div>
|
||||
<div class="stat-card-meta">外网: chat.qrj.ai</div>
|
||||
<div class="stat-card-meta">${t('ext.publicUrl')}: chat.qrj.ai</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:var(--space-sm)">
|
||||
<a class="btn btn-primary btn-sm" href="${s.url || 'http://localhost:3210'}" target="_blank" rel="noopener">打开 ClawApp</a>
|
||||
<a class="btn btn-secondary btn-sm" href="https://chat.qrj.ai" target="_blank" rel="noopener">打开外网地址</a>
|
||||
<button class="btn btn-secondary btn-sm" data-action="clawapp-refresh">刷新</button>
|
||||
<a class="btn btn-primary btn-sm" href="${s.url || 'http://localhost:3210'}" target="_blank" rel="noopener">${t('ext.openClawapp')}</a>
|
||||
<a class="btn btn-secondary btn-sm" href="https://chat.qrj.ai" target="_blank" rel="noopener">${t('ext.openPublicUrl')}</a>
|
||||
<button class="btn btn-secondary btn-sm" data-action="clawapp-refresh">${t('ext.refresh')}</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
@@ -227,16 +228,16 @@ function bindEvents(page) {
|
||||
}
|
||||
|
||||
async function handleCftunnelAction(page, action) {
|
||||
const label = action === 'up' ? '启动' : '停止'
|
||||
const label = action === 'up' ? t('ext.start') : t('ext.stop')
|
||||
const btn = page.querySelector(`[data-action="cftunnel-${action === 'up' ? 'up' : 'down'}"]`)
|
||||
if (btn) { btn.classList.add('btn-loading'); btn.disabled = true; btn.textContent = `${label}中...` }
|
||||
if (btn) { btn.classList.add('btn-loading'); btn.disabled = true; btn.textContent = `${label}...` }
|
||||
try {
|
||||
await api.cftunnelAction(action)
|
||||
toast(`隧道已${label}`, 'success')
|
||||
toast(t('ext.tunnelActionDone', { action: label }), 'success')
|
||||
await loadCftunnel(page)
|
||||
} catch (e) {
|
||||
toast(`${label}失败: ${e}`, 'error')
|
||||
if (btn) { btn.classList.remove('btn-loading'); btn.disabled = false; btn.textContent = `${label}隧道` }
|
||||
toast(t('ext.tunnelActionFail', { action: label }) + ': ' + e, 'error')
|
||||
if (btn) { btn.classList.remove('btn-loading'); btn.disabled = false; btn.textContent = label }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,14 +254,14 @@ async function handleCftunnelLogs(page) {
|
||||
area.innerHTML = `
|
||||
<div style="margin-top:var(--space-md)">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--space-sm)">
|
||||
<span style="font-weight:600;font-size:var(--font-size-sm)">最近日志</span>
|
||||
<button class="btn btn-secondary btn-sm" data-action="cftunnel-logs">收起</button>
|
||||
<span style="font-weight:600;font-size:var(--font-size-sm)">${t('ext.recentLogs')}</span>
|
||||
<button class="btn btn-secondary btn-sm" data-action="cftunnel-logs">${t('ext.collapse')}</button>
|
||||
</div>
|
||||
<pre class="log-viewer">${escapeHtml(logs) || '暂无日志'}</pre>
|
||||
<pre class="log-viewer">${escapeHtml(logs) || t('ext.noLogs')}</pre>
|
||||
</div>
|
||||
`
|
||||
} catch (e) {
|
||||
area.innerHTML = `<div style="color:var(--error);margin-top:var(--space-sm)">读取日志失败: ${e}</div>`
|
||||
area.innerHTML = `<div style="color:var(--error);margin-top:var(--space-sm)">${t('ext.readLogsFailed')}: ${e}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,7 +276,7 @@ async function handleInstallCftunnel(page) {
|
||||
<div class="upgrade-progress-bar">
|
||||
<div class="upgrade-progress-fill" id="install-progress-fill" style="width:0%"></div>
|
||||
</div>
|
||||
<div class="upgrade-progress-text" id="install-progress-text">准备安装...</div>
|
||||
<div class="upgrade-progress-text" id="install-progress-text">${t('ext.preparing')}</div>
|
||||
</div>
|
||||
<div class="upgrade-log-box" id="install-log-box"></div>
|
||||
</div>
|
||||
@@ -297,31 +298,31 @@ async function handleInstallCftunnel(page) {
|
||||
unlistenProgress = await listen('install-progress', (e) => {
|
||||
const progress = e.payload
|
||||
progressFill.style.width = progress + '%'
|
||||
progressText.textContent = `安装中... ${progress}%`
|
||||
progressText.textContent = t('ext.installing') + ` ${progress}%`
|
||||
})
|
||||
} catch { /* Web 模式无 Tauri event */ }
|
||||
} catch { /* Web mode no Tauri event */ }
|
||||
} else {
|
||||
logBox.textContent += 'Web 模式:安装日志不可用,请等待完成...\n'
|
||||
logBox.textContent += t('ext.webModeNoLogs') + '\n'
|
||||
}
|
||||
|
||||
await api.installCftunnel()
|
||||
|
||||
progressFill.classList.add('done')
|
||||
progressText.innerHTML = `${statusIcon('ok', 14)} 安装完成`
|
||||
toast('cftunnel 安装成功', 'success')
|
||||
progressText.innerHTML = `${statusIcon('ok', 14)} ${t('ext.installDone')}`
|
||||
toast(t('ext.installSuccess', { name: 'cftunnel' }), 'success')
|
||||
|
||||
// 3 秒后刷新状态
|
||||
setTimeout(() => loadCftunnel(page), 3000)
|
||||
} catch (e) {
|
||||
progressFill.classList.add('error')
|
||||
progressText.innerHTML = `${statusIcon('err', 14)} 安装失败`
|
||||
logBox.textContent += '\n错误: ' + e
|
||||
toast('安装失败: ' + e, 'error')
|
||||
progressText.innerHTML = `${statusIcon('err', 14)} ${t('ext.installFailed')}`
|
||||
logBox.textContent += '\n' + t('ext.error') + ': ' + e
|
||||
toast(t('ext.installFailed') + ': ' + e, 'error')
|
||||
if (window.__openAIDrawerWithError) {
|
||||
window.__openAIDrawerWithError({
|
||||
title: '安装 cftunnel 失败',
|
||||
title: t('ext.installFailedTitle', { name: 'cftunnel' }),
|
||||
error: logBox.textContent,
|
||||
scene: '安装 cftunnel 内网穿透工具',
|
||||
scene: t('ext.installScene', { name: 'cftunnel' }),
|
||||
hint: String(e),
|
||||
})
|
||||
}
|
||||
@@ -341,7 +342,7 @@ async function handleInstallClawapp(page) {
|
||||
<div class="upgrade-progress-bar">
|
||||
<div class="upgrade-progress-fill" id="install-clawapp-progress-fill" style="width:0%"></div>
|
||||
</div>
|
||||
<div class="upgrade-progress-text" id="install-clawapp-progress-text">准备安装...</div>
|
||||
<div class="upgrade-progress-text" id="install-clawapp-progress-text">${t('ext.preparing')}</div>
|
||||
</div>
|
||||
<div class="upgrade-log-box" id="install-clawapp-log-box"></div>
|
||||
</div>
|
||||
@@ -363,30 +364,30 @@ async function handleInstallClawapp(page) {
|
||||
unlistenProgress = await listen('install-progress', (e) => {
|
||||
const progress = e.payload
|
||||
progressFill.style.width = progress + '%'
|
||||
progressText.textContent = `安装中... ${progress}%`
|
||||
progressText.textContent = t('ext.installing') + ` ${progress}%`
|
||||
})
|
||||
} catch { /* Web 模式无 Tauri event */ }
|
||||
} catch { /* Web mode no Tauri event */ }
|
||||
} else {
|
||||
logBox.textContent += 'Web 模式:安装日志不可用,请等待完成...\n'
|
||||
logBox.textContent += t('ext.webModeNoLogs') + '\n'
|
||||
}
|
||||
|
||||
await api.installClawapp()
|
||||
|
||||
progressFill.classList.add('done')
|
||||
progressText.innerHTML = `${statusIcon('ok', 14)} 安装完成`
|
||||
toast('ClawApp 安装成功', 'success')
|
||||
progressText.innerHTML = `${statusIcon('ok', 14)} ${t('ext.installDone')}`
|
||||
toast(t('ext.installSuccess', { name: 'ClawApp' }), 'success')
|
||||
|
||||
setTimeout(() => loadClawapp(page), 3000)
|
||||
} catch (e) {
|
||||
progressFill.classList.add('error')
|
||||
progressText.innerHTML = `${statusIcon('err', 14)} 安装失败`
|
||||
logBox.textContent += '\n错误: ' + e
|
||||
toast('安装失败: ' + e, 'error')
|
||||
progressText.innerHTML = `${statusIcon('err', 14)} ${t('ext.installFailed')}`
|
||||
logBox.textContent += '\n' + t('ext.error') + ': ' + e
|
||||
toast(t('ext.installFailed') + ': ' + e, 'error')
|
||||
if (window.__openAIDrawerWithError) {
|
||||
window.__openAIDrawerWithError({
|
||||
title: '安装 ClawApp 失败',
|
||||
title: t('ext.installFailedTitle', { name: 'ClawApp' }),
|
||||
error: logBox.textContent,
|
||||
scene: '安装 ClawApp 手机客户端',
|
||||
scene: t('ext.installScene', { name: 'ClawApp' }),
|
||||
hint: String(e),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { api } from '../lib/tauri-api.js'
|
||||
import { toast } from '../components/toast.js'
|
||||
import { tryShowEngagement } from '../components/engagement.js'
|
||||
import { t } from '../lib/i18n.js'
|
||||
|
||||
// 兼容新版 SecretRef:token 可能是 string 或 { $env: "VAR" } / { $ref: "x/y" }
|
||||
function _tokenDisplayStr(token) {
|
||||
@@ -26,8 +27,8 @@ export async function render() {
|
||||
|
||||
page.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Gateway 配置</h1>
|
||||
<p class="page-desc">Gateway 是 AI 模型的统一入口,所有应用通过它来调用模型服务</p>
|
||||
<h1 class="page-title">${t('gateway.title')}</h1>
|
||||
<p class="page-desc">${t('gateway.desc')}</p>
|
||||
</div>
|
||||
<div id="gateway-config">
|
||||
<div class="config-section"><div class="stat-card loading-placeholder" style="height:80px"></div></div>
|
||||
@@ -37,9 +38,9 @@ export async function render() {
|
||||
<div class="gw-save-bar">
|
||||
<button class="btn btn-primary" id="btn-save-gw">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/></svg>
|
||||
保存并生效
|
||||
${t('gateway.saveApply')}
|
||||
</button>
|
||||
<span class="gw-save-hint">修改后点击保存,Gateway 会自动重载</span>
|
||||
<span class="gw-save-hint">${t('gateway.saveHint')}</span>
|
||||
</div>
|
||||
`
|
||||
|
||||
@@ -50,13 +51,13 @@ export async function render() {
|
||||
const btn = page.querySelector('#btn-save-gw')
|
||||
btn.disabled = true
|
||||
btn.classList.add('btn-loading')
|
||||
btn.textContent = '保存中...'
|
||||
btn.textContent = t('gateway.saving')
|
||||
try {
|
||||
await saveConfig(page, state)
|
||||
} finally {
|
||||
btn.disabled = false
|
||||
btn.classList.remove('btn-loading')
|
||||
btn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/></svg> 保存并生效`
|
||||
btn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/></svg> ${t('gateway.saveApply')}`
|
||||
}
|
||||
}
|
||||
return page
|
||||
@@ -69,8 +70,8 @@ async function loadConfig(page, state) {
|
||||
state._origToken = state.config?.gateway?.auth?.token ?? null
|
||||
renderConfig(page, state)
|
||||
} catch (e) {
|
||||
el.innerHTML = '<div style="color:var(--error);padding:20px">加载配置失败: ' + e + '</div>'
|
||||
toast('加载配置失败: ' + e, 'error')
|
||||
el.innerHTML = '<div style="color:var(--error);padding:20px">' + t('gateway.loadFailed') + ': ' + e + '</div>'
|
||||
toast(t('gateway.loadFailed') + ': ' + e, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,19 +84,19 @@ function renderConfig(page, state) {
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg>
|
||||
服务端口
|
||||
${t('gateway.portTitle')}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">端口号</label>
|
||||
<label class="form-label">${t('gateway.portLabel')}</label>
|
||||
<input class="form-input" id="gw-port" type="number" value="${gw.port || 18789}" min="1024" max="65535" style="max-width:200px">
|
||||
<div class="form-hint">应用通过这个端口连接 Gateway,默认 18789,一般不需要改</div>
|
||||
<div class="form-hint">${t('gateway.portHint')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/></svg>
|
||||
谁能访问
|
||||
${t('gateway.accessTitle')}
|
||||
</div>
|
||||
<div class="gw-option-cards">
|
||||
<label class="gw-option-card ${(gw.bind === 'lan' || gw.bind === 'all') ? '' : 'selected'}" data-bind="loopback">
|
||||
@@ -104,8 +105,8 @@ function renderConfig(page, state) {
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||||
</div>
|
||||
<div class="gw-option-text">
|
||||
<div class="gw-option-title">仅本机使用</div>
|
||||
<div class="gw-option-desc">只有这台电脑上的应用能访问,最安全</div>
|
||||
<div class="gw-option-title">${t('gateway.localOnly')}</div>
|
||||
<div class="gw-option-desc">${t('gateway.localOnlyDesc')}</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="gw-option-card ${(gw.bind === 'lan' || gw.bind === 'all') ? 'selected' : ''}" data-bind="lan">
|
||||
@@ -114,8 +115,8 @@ function renderConfig(page, state) {
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="6" width="7" height="10" rx="1"/><rect x="9" y="3" width="6" height="14" rx="1"/><rect x="16" y="6" width="7" height="10" rx="1"/><line x1="8" y1="12" x2="9" y2="12"/><line x1="15" y1="12" x2="16" y2="12"/></svg>
|
||||
</div>
|
||||
<div class="gw-option-text">
|
||||
<div class="gw-option-title">局域网共享</div>
|
||||
<div class="gw-option-desc">同一网络下的手机、平板等设备也能用</div>
|
||||
<div class="gw-option-title">${t('gateway.lanShare')}</div>
|
||||
<div class="gw-option-desc">${t('gateway.lanShareDesc')}</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
@@ -124,10 +125,10 @@ function renderConfig(page, state) {
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
|
||||
安全认证
|
||||
${t('gateway.authTitle')}
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:var(--space-md)">
|
||||
<label class="form-label">认证方式</label>
|
||||
<label class="form-label">${t('gateway.authMode')}</label>
|
||||
<div class="gw-option-cards">
|
||||
<label class="gw-option-card ${gw.auth?.mode === 'password' ? '' : 'selected'}" data-auth="token">
|
||||
<input type="radio" name="gw-auth-mode" value="token" ${gw.auth?.mode === 'password' ? '' : 'checked'} hidden>
|
||||
@@ -135,8 +136,8 @@ function renderConfig(page, state) {
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 11-7.778 7.778 5.5 5.5 0 017.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>
|
||||
</div>
|
||||
<div class="gw-option-text">
|
||||
<div class="gw-option-title">Token 密钥</div>
|
||||
<div class="gw-option-desc">标准认证方式,适合本地和局域网使用</div>
|
||||
<div class="gw-option-title">${t('gateway.authToken')}</div>
|
||||
<div class="gw-option-desc">${t('gateway.authTokenDesc')}</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="gw-option-card ${gw.auth?.mode === 'password' ? 'selected' : ''}" data-auth="password">
|
||||
@@ -145,37 +146,37 @@ function renderConfig(page, state) {
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
|
||||
</div>
|
||||
<div class="gw-option-text">
|
||||
<div class="gw-option-title">密码认证</div>
|
||||
<div class="gw-option-desc">Tailscale Funnel 等外网暴露场景必须使用此模式</div>
|
||||
<div class="gw-option-title">${t('gateway.authPassword')}</div>
|
||||
<div class="gw-option-desc">${t('gateway.authPasswordDesc')}</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" id="gw-auth-token-group" style="${gw.auth?.mode === 'password' ? 'display:none' : ''}">
|
||||
<label class="form-label">访问密钥(Token)</label>
|
||||
<label class="form-label">${t('gateway.tokenLabel')}</label>
|
||||
<div style="display:flex;gap:8px">
|
||||
<input class="form-input" id="gw-token" type="password" value="${_tokenDisplayStr(gw.auth?.token || gw.authToken)}" placeholder="不设置则任何人都能调用" style="flex:1" ${_isSecretRef(gw.auth?.token) ? 'readonly' : ''}>
|
||||
<button class="btn btn-sm btn-secondary" id="btn-toggle-token">显示</button>
|
||||
<input class="form-input" id="gw-token" type="password" value="${_tokenDisplayStr(gw.auth?.token || gw.authToken)}" placeholder="${t('gateway.tokenPlaceholder')}" style="flex:1" ${_isSecretRef(gw.auth?.token) ? 'readonly' : ''}>
|
||||
<button class="btn btn-sm btn-secondary" id="btn-toggle-token">${t('gateway.show')}</button>
|
||||
</div>
|
||||
<div class="form-hint">${_isSecretRef(gw.auth?.token) ? '当前 Token 通过环境变量/引用配置,如需改为明文请清空后输入' : '设置后,应用调用时需要带上这个密钥才能通过。如果选了「局域网共享」,强烈建议设置'}</div>
|
||||
<div class="form-hint">${_isSecretRef(gw.auth?.token) ? t('gateway.tokenHintRef') : t('gateway.tokenHintNormal')}</div>
|
||||
</div>
|
||||
<div class="form-group" id="gw-auth-password-group" style="${gw.auth?.mode === 'password' ? '' : 'display:none'}">
|
||||
<label class="form-label">密码</label>
|
||||
<label class="form-label">${t('gateway.passwordLabel')}</label>
|
||||
<div style="display:flex;gap:8px">
|
||||
<input class="form-input" id="gw-password" type="password" value="${gw.auth?.password || ''}" placeholder="设置 Gateway 访问密码" style="flex:1">
|
||||
<button class="btn btn-sm btn-secondary" id="btn-toggle-password">显示</button>
|
||||
<input class="form-input" id="gw-password" type="password" value="${gw.auth?.password || ''}" placeholder="${t('gateway.passwordPlaceholder')}" style="flex:1">
|
||||
<button class="btn btn-sm btn-secondary" id="btn-toggle-password">${t('gateway.show')}</button>
|
||||
</div>
|
||||
<div class="form-hint">通过 Tailscale Funnel 暴露 Gateway 时,必须使用密码认证模式</div>
|
||||
<div class="form-hint">${t('gateway.passwordHint')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z"/></svg>
|
||||
Agent 工具权限
|
||||
${t('gateway.toolsTitle')}
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:var(--space-md)">
|
||||
<label class="form-label">工具调用权限</label>
|
||||
<label class="form-label">${t('gateway.toolsPermission')}</label>
|
||||
<div class="gw-option-cards">
|
||||
<label class="gw-option-card ${(gw.tools?.profile || 'full') === 'full' ? 'selected' : ''}" data-tools-profile="full">
|
||||
<input type="radio" name="gw-tools-profile" value="full" ${(gw.tools?.profile || 'full') === 'full' ? 'checked' : ''} hidden>
|
||||
@@ -183,8 +184,8 @@ function renderConfig(page, state) {
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
||||
</div>
|
||||
<div class="gw-option-text">
|
||||
<div class="gw-option-title">完整权限</div>
|
||||
<div class="gw-option-desc">Agent 可使用所有工具(推荐)</div>
|
||||
<div class="gw-option-title">${t('gateway.toolsFull')}</div>
|
||||
<div class="gw-option-desc">${t('gateway.toolsFullDesc')}</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="gw-option-card ${gw.tools?.profile === 'limited' ? 'selected' : ''}" data-tools-profile="limited">
|
||||
@@ -193,8 +194,8 @@ function renderConfig(page, state) {
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>
|
||||
</div>
|
||||
<div class="gw-option-text">
|
||||
<div class="gw-option-title">受限模式</div>
|
||||
<div class="gw-option-desc">仅允许安全工具,禁用文件/命令操作</div>
|
||||
<div class="gw-option-title">${t('gateway.toolsLimited')}</div>
|
||||
<div class="gw-option-desc">${t('gateway.toolsLimitedDesc')}</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="gw-option-card ${gw.tools?.profile === 'none' ? 'selected' : ''}" data-tools-profile="none">
|
||||
@@ -203,37 +204,37 @@ function renderConfig(page, state) {
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
|
||||
</div>
|
||||
<div class="gw-option-text">
|
||||
<div class="gw-option-title">禁用工具</div>
|
||||
<div class="gw-option-desc">Agent 只能对话,不能调用任何工具</div>
|
||||
<div class="gw-option-title">${t('gateway.toolsNone')}</div>
|
||||
<div class="gw-option-desc">${t('gateway.toolsNoneDesc')}</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">会话可见性</label>
|
||||
<label class="form-label">${t('gateway.sessionsLabel')}</label>
|
||||
<select class="form-input" id="gw-sessions-visibility" style="width:auto;min-width:180px">
|
||||
<option value="all" ${(gw.tools?.sessions?.visibility || 'all') === 'all' ? 'selected' : ''}>所有会话可见</option>
|
||||
<option value="own" ${gw.tools?.sessions?.visibility === 'own' ? 'selected' : ''}>仅自己的会话</option>
|
||||
<option value="none" ${gw.tools?.sessions?.visibility === 'none' ? 'selected' : ''}>不可见</option>
|
||||
<option value="all" ${(gw.tools?.sessions?.visibility || 'all') === 'all' ? 'selected' : ''}>${t('gateway.sessionsAll')}</option>
|
||||
<option value="own" ${gw.tools?.sessions?.visibility === 'own' ? 'selected' : ''}>${t('gateway.sessionsOwn')}</option>
|
||||
<option value="none" ${gw.tools?.sessions?.visibility === 'none' ? 'selected' : ''}>${t('gateway.sessionsNone')}</option>
|
||||
</select>
|
||||
<div class="form-hint">控制 Agent 是否能查看其他会话的上下文</div>
|
||||
<div class="form-hint">${t('gateway.sessionsHint')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gw-advanced-toggle" id="gw-advanced-toggle">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
高级选项
|
||||
${t('gateway.advancedToggle')}
|
||||
</div>
|
||||
<div class="gw-advanced-panel" id="gw-advanced-panel" style="display:none">
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 11-7.778 7.778 5.5 5.5 0 017.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>
|
||||
Tailscale 组网
|
||||
${t('gateway.tailscaleTitle')}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Tailscale 地址</label>
|
||||
<input class="form-input" id="gw-tailscale" value="${gw.tailscale?.address || ''}" placeholder="例如 100.x.x.x:18789">
|
||||
<div class="form-hint">如果你用 Tailscale 虚拟局域网,填上地址后远程设备就能通过它访问 Gateway。不用可以留空</div>
|
||||
<label class="form-label">${t('gateway.tailscaleLabel')}</label>
|
||||
<input class="form-input" id="gw-tailscale" value="${gw.tailscale?.address || ''}" placeholder="${t('gateway.tailscalePlaceholder')}">
|
||||
<div class="form-hint">${t('gateway.tailscaleHint')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -251,10 +252,10 @@ function bindConfigEvents(el) {
|
||||
const input = el.querySelector('#' + inputId)
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text'
|
||||
btn.textContent = '隐藏'
|
||||
btn.textContent = t('gateway.hide')
|
||||
} else {
|
||||
input.type = 'password'
|
||||
btn.textContent = '显示'
|
||||
btn.textContent = t('gateway.show')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -330,15 +331,15 @@ async function saveConfig(page, state) {
|
||||
|
||||
try {
|
||||
await api.writeOpenclawConfig(state.config)
|
||||
toast('配置已保存,正在重载 Gateway...', 'info')
|
||||
toast(t('gateway.configSaved'), 'info')
|
||||
try {
|
||||
await api.reloadGateway()
|
||||
toast('Gateway 已重载,新配置已生效', 'success')
|
||||
toast(t('gateway.reloaded'), 'success')
|
||||
setTimeout(tryShowEngagement, 3000)
|
||||
} catch (e) {
|
||||
toast('配置已保存,但重载失败: ' + e, 'warning')
|
||||
toast(t('gateway.savedButReloadFailed') + ': ' + e, 'warning')
|
||||
}
|
||||
} catch (e) {
|
||||
toast('保存失败: ' + e, 'error')
|
||||
toast(t('gateway.saveFailed') + ': ' + e, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
*/
|
||||
import { api } from '../lib/tauri-api.js'
|
||||
import { toast } from '../components/toast.js'
|
||||
import { t } from '../lib/i18n.js'
|
||||
|
||||
const LOG_TABS = [
|
||||
{ key: 'gateway', label: 'Gateway 日志' },
|
||||
{ key: 'gateway-err', label: 'Gateway 错误' },
|
||||
{ key: 'guardian', label: '守护进程' },
|
||||
{ key: 'guardian-backup', label: '备份日志' },
|
||||
{ key: 'config-audit', label: '审计日志' },
|
||||
{ key: 'gateway', label: () => t('logs.tabGateway') },
|
||||
{ key: 'gateway-err', label: () => t('logs.tabGatewayErr') },
|
||||
{ key: 'guardian', label: () => t('logs.tabGuardian') },
|
||||
{ key: 'guardian-backup', label: () => t('logs.tabBackup') },
|
||||
{ key: 'config-audit', label: () => t('logs.tabAudit') },
|
||||
]
|
||||
|
||||
let _searchTimer = null
|
||||
@@ -20,17 +21,17 @@ export async function render() {
|
||||
|
||||
page.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">日志查看</h1>
|
||||
<p class="page-desc">查看 OpenClaw 各服务日志</p>
|
||||
<h1 class="page-title">${t('logs.title')}</h1>
|
||||
<p class="page-desc">${t('logs.desc')}</p>
|
||||
</div>
|
||||
<div class="tab-bar">
|
||||
${LOG_TABS.map((t, i) => `<div class="tab${i === 0 ? ' active' : ''}" data-tab="${t.key}">${t.label}</div>`).join('')}
|
||||
${LOG_TABS.map((item, i) => `<div class="tab${i === 0 ? ' active' : ''}" data-tab="${item.key}">${item.label()}</div>`).join('')}
|
||||
</div>
|
||||
<div class="log-toolbar">
|
||||
<input type="text" class="form-input" id="log-search" placeholder="搜索日志..." style="max-width:300px">
|
||||
<button class="btn btn-secondary btn-sm" id="btn-refresh">刷新</button>
|
||||
<input type="text" class="form-input" id="log-search" placeholder="${t('logs.searchPlaceholder')}" style="max-width:300px">
|
||||
<button class="btn btn-secondary btn-sm" id="btn-refresh">${t('logs.refresh')}</button>
|
||||
<label style="display:flex;align-items:center;gap:6px;font-size:var(--font-size-sm);color:var(--text-secondary)">
|
||||
<input type="checkbox" id="log-autoscroll" checked> 自动滚动
|
||||
<input type="checkbox" id="log-autoscroll" checked> ${t('logs.autoScroll')}
|
||||
</label>
|
||||
</div>
|
||||
<div class="log-viewer" id="log-content" style="height:calc(100vh - 280px)"><div class="stat-card loading-placeholder" style="height:16px;margin:8px 0"></div><div class="stat-card loading-placeholder" style="height:16px;margin:8px 0"></div><div class="stat-card loading-placeholder" style="height:16px;margin:8px 0"></div><div class="stat-card loading-placeholder" style="height:16px;margin:8px 0"></div></div>
|
||||
@@ -41,7 +42,7 @@ export async function render() {
|
||||
// Tab 切换
|
||||
page.querySelectorAll('.tab').forEach(tab => {
|
||||
tab.onclick = () => {
|
||||
page.querySelectorAll('.tab').forEach(t => t.classList.remove('active'))
|
||||
page.querySelectorAll('.tab').forEach(el => el.classList.remove('active'))
|
||||
tab.classList.add('active')
|
||||
currentTab = tab.dataset.tab
|
||||
page.querySelector('#log-search').value = ''
|
||||
@@ -77,12 +78,12 @@ async function loadLog(page, logName) {
|
||||
const el = page.querySelector('#log-content')
|
||||
const refreshBtn = page.querySelector('#btn-refresh')
|
||||
// 显示加载状态
|
||||
el.innerHTML = '<div class="log-loading"><div class="service-spinner"></div><span style="color:var(--text-tertiary);margin-left:8px">加载日志中...</span></div>'
|
||||
el.innerHTML = '<div class="log-loading"><div class="service-spinner"></div><span style="color:var(--text-tertiary);margin-left:8px">' + t('logs.loading') + '</span></div>'
|
||||
if (refreshBtn) { refreshBtn.classList.add('btn-loading'); refreshBtn.disabled = true }
|
||||
try {
|
||||
const content = await api.readLogTail(logName, 200)
|
||||
if (!content || !content.trim()) {
|
||||
el.innerHTML = '<div style="color:var(--text-tertiary)">暂无日志</div>'
|
||||
el.innerHTML = '<div style="color:var(--text-tertiary)">' + t('logs.empty') + '</div>'
|
||||
return
|
||||
}
|
||||
const lines = content.trim().split('\n')
|
||||
@@ -91,8 +92,8 @@ async function loadLog(page, logName) {
|
||||
el.scrollTop = el.scrollHeight
|
||||
}
|
||||
} catch (e) {
|
||||
el.innerHTML = '<div style="color:var(--error);padding:12px">加载日志失败: ' + e + '</div>'
|
||||
toast('加载日志失败: ' + e, 'error')
|
||||
el.innerHTML = '<div style="color:var(--error);padding:12px">' + t('logs.loadFailed') + ': ' + e + '</div>'
|
||||
toast(t('logs.loadFailed') + ': ' + e, 'error')
|
||||
} finally {
|
||||
if (refreshBtn) { refreshBtn.classList.remove('btn-loading'); refreshBtn.disabled = false }
|
||||
}
|
||||
@@ -103,13 +104,13 @@ async function searchLog(page, logName, query) {
|
||||
try {
|
||||
const results = await api.searchLog(logName, query)
|
||||
if (!results || !results.length) {
|
||||
el.innerHTML = '<div style="color:var(--text-tertiary)">未找到匹配结果</div>'
|
||||
el.innerHTML = '<div style="color:var(--text-tertiary)">' + t('logs.noResults') + '</div>'
|
||||
return
|
||||
}
|
||||
el.innerHTML = results.map(l => `<div class="log-line">${highlightMatch(escapeHtml(l), query)}</div>`).join('')
|
||||
} catch (e) {
|
||||
el.innerHTML = '<div style="color:var(--error);padding:12px">搜索失败: ' + e + '</div>'
|
||||
toast('搜索失败: ' + e, 'error')
|
||||
el.innerHTML = '<div style="color:var(--error);padding:12px">' + t('logs.searchFailed') + ': ' + e + '</div>'
|
||||
toast(t('logs.searchFailed') + ': ' + e, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,15 @@
|
||||
import { api } from '../lib/tauri-api.js'
|
||||
import { toast } from '../components/toast.js'
|
||||
import { showModal } from '../components/modal.js'
|
||||
import { t } from '../lib/i18n.js'
|
||||
|
||||
const CATEGORIES = [
|
||||
{ key: 'memory', label: '工作记忆', desc: '当前活跃的工作上下文、决策记录和进度追踪' },
|
||||
{ key: 'archive', label: '记忆归档', desc: '已归档的历史记忆文件,按时间周期整理' },
|
||||
{ key: 'core', label: '核心文件', desc: 'Agent 核心配置文件,如 AGENTS.md、CLAUDE.md 等' },
|
||||
]
|
||||
function CATEGORIES() {
|
||||
return [
|
||||
{ key: 'memory', label: t('memory.catMemory'), desc: t('memory.catMemoryDesc') },
|
||||
{ key: 'archive', label: t('memory.catArchive'), desc: t('memory.catArchiveDesc') },
|
||||
{ key: 'core', label: t('memory.catCore'), desc: t('memory.catCoreDesc') },
|
||||
]
|
||||
}
|
||||
|
||||
export async function render() {
|
||||
const page = document.createElement('div')
|
||||
@@ -17,37 +20,37 @@ export async function render() {
|
||||
|
||||
page.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">记忆文件</h1>
|
||||
<h1 class="page-title">${t('memory.title')}</h1>
|
||||
<div class="page-actions" style="display:flex;align-items:center;gap:var(--space-sm)">
|
||||
<label style="font-size:var(--font-size-sm);color:var(--text-tertiary)">Agent:</label>
|
||||
<label style="font-size:var(--font-size-sm);color:var(--text-tertiary)">${t('memory.agentLabel')}</label>
|
||||
<select class="form-input" id="agent-select" style="width:auto;min-width:140px"><option value="main">main</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-bar">
|
||||
${CATEGORIES.map((c, i) => `<div class="tab${i === 0 ? ' active' : ''}" data-tab="${c.key}">${c.label}</div>`).join('')}
|
||||
${CATEGORIES().map((c, i) => `<div class="tab${i === 0 ? ' active' : ''}" data-tab="${c.key}">${c.label}</div>`).join('')}
|
||||
</div>
|
||||
<div class="form-hint" id="category-desc" style="margin-bottom:var(--space-md)">${CATEGORIES[0].desc}</div>
|
||||
<div class="form-hint" id="category-desc" style="margin-bottom:var(--space-md)">${CATEGORIES()[0].desc}</div>
|
||||
<div class="memory-layout">
|
||||
<div class="memory-sidebar">
|
||||
<div style="padding:0 var(--space-sm) var(--space-sm);display:flex;gap:4px">
|
||||
<button class="btn btn-sm btn-secondary" id="btn-new-file" style="flex:1">+ 新建</button>
|
||||
<button class="btn btn-sm btn-danger" id="btn-del-file" disabled style="flex:1">删除</button>
|
||||
<button class="btn btn-sm btn-secondary" id="btn-new-file" style="flex:1">${t('memory.newFile')}</button>
|
||||
<button class="btn btn-sm btn-danger" id="btn-del-file" disabled style="flex:1">${t('memory.deleteFile')}</button>
|
||||
</div>
|
||||
<div style="padding:0 var(--space-sm) var(--space-sm)">
|
||||
<button class="btn btn-sm btn-secondary" id="btn-export-zip" style="width:100%">打包下载全部</button>
|
||||
<button class="btn btn-sm btn-secondary" id="btn-export-zip" style="width:100%">${t('memory.exportZip')}</button>
|
||||
</div>
|
||||
<div id="file-tree"><div class="stat-card loading-placeholder" style="height:32px;margin:8px"></div><div class="stat-card loading-placeholder" style="height:32px;margin:8px"></div><div class="stat-card loading-placeholder" style="height:32px;margin:8px"></div></div>
|
||||
</div>
|
||||
<div class="memory-editor">
|
||||
<div class="editor-toolbar">
|
||||
<span id="current-file" style="font-size:var(--font-size-sm);color:var(--text-tertiary)">选择文件查看</span>
|
||||
<span id="current-file" style="font-size:var(--font-size-sm);color:var(--text-tertiary)">${t('memory.selectFile')}</span>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn btn-sm btn-secondary" id="btn-download" disabled>下载</button>
|
||||
<button class="btn btn-sm btn-secondary" id="btn-preview" disabled>预览</button>
|
||||
<button class="btn btn-sm btn-primary" id="btn-save-file" disabled>保存</button>
|
||||
<button class="btn btn-sm btn-secondary" id="btn-download" disabled>${t('memory.download')}</button>
|
||||
<button class="btn btn-sm btn-secondary" id="btn-preview" disabled>${t('memory.preview')}</button>
|
||||
<button class="btn btn-sm btn-primary" id="btn-save-file" disabled>${t('memory.save')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea class="editor-area" id="file-editor" placeholder="选择左侧文件进行编辑..." disabled></textarea>
|
||||
<textarea class="editor-area" id="file-editor" placeholder="${t('memory.editorPlaceholder')}" disabled></textarea>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
@@ -86,7 +89,7 @@ export async function render() {
|
||||
tab.classList.add('active')
|
||||
state.category = tab.dataset.tab
|
||||
state.currentPath = null
|
||||
const cat = CATEGORIES.find(c => c.key === state.category)
|
||||
const cat = CATEGORIES().find(c => c.key === state.category)
|
||||
page.querySelector('#category-desc').textContent = cat?.desc || ''
|
||||
resetEditor(page)
|
||||
// 显示加载动画
|
||||
@@ -105,16 +108,16 @@ export async function render() {
|
||||
// 新建文件
|
||||
page.querySelector('#btn-new-file').onclick = () => {
|
||||
showModal({
|
||||
title: '新建记忆文件',
|
||||
fields: [{ name: 'filename', label: '文件名', placeholder: '如 notes.md', hint: '建议使用 .md 格式,文件将保存到当前分类目录下' }],
|
||||
title: t('memory.newFileTitle'),
|
||||
fields: [{ name: 'filename', label: t('memory.newFileLabel'), placeholder: t('memory.newFilePlaceholder'), hint: t('memory.newFileHint') }],
|
||||
onConfirm: async ({ filename }) => {
|
||||
if (!filename) return
|
||||
try {
|
||||
await api.writeMemoryFile(filename, `# ${filename}\n\n`, state.category, state.agentId)
|
||||
toast(`已创建 ${filename}`, 'success')
|
||||
toast(t('memory.created', { name: filename }), 'success')
|
||||
loadFiles(page, state)
|
||||
} catch (e) {
|
||||
toast('创建失败: ' + e, 'error')
|
||||
toast(t('memory.createFailed') + ': ' + e, 'error')
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -125,16 +128,16 @@ export async function render() {
|
||||
if (!state.currentPath) return
|
||||
const name = state.currentPath.split('/').pop()
|
||||
const { showConfirm } = await import('../components/modal.js')
|
||||
const yes = await showConfirm(`确定删除 ${name}?`)
|
||||
const yes = await showConfirm(t('memory.confirmDelete', { name }))
|
||||
if (!yes) return
|
||||
try {
|
||||
await api.deleteMemoryFile(state.currentPath, state.agentId)
|
||||
toast(`已删除 ${name}`, 'success')
|
||||
toast(t('memory.deleted', { name }), 'success')
|
||||
state.currentPath = null
|
||||
resetEditor(page)
|
||||
loadFiles(page, state)
|
||||
} catch (e) {
|
||||
toast('删除失败: ' + e, 'error')
|
||||
toast(t('memory.deleteFailed') + ': ' + e, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,13 +157,13 @@ async function loadFiles(page, state) {
|
||||
try {
|
||||
const files = await api.listMemoryFiles(state.category, state.agentId)
|
||||
if (!files || !files.length) {
|
||||
tree.innerHTML = '<div style="color:var(--text-tertiary);padding:12px">暂无文件</div>'
|
||||
tree.innerHTML = `<div style="color:var(--text-tertiary);padding:12px">${t('memory.noFiles')}</div>`
|
||||
return
|
||||
}
|
||||
renderFileTree(page, state, files)
|
||||
} catch (e) {
|
||||
tree.innerHTML = '<div style="color:var(--error);padding:12px">加载失败: ' + e + '</div>'
|
||||
toast('加载文件列表失败: ' + e, 'error')
|
||||
tree.innerHTML = `<div style="color:var(--error);padding:12px">${t('memory.loadFailed')}: ${e}</div>`
|
||||
toast(t('memory.loadListFailed') + ': ' + e, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,14 +194,14 @@ async function loadFileContent(page, state) {
|
||||
const btnDl = page.querySelector('#btn-download')
|
||||
|
||||
editor.disabled = true
|
||||
editor.value = '加载中...'
|
||||
editor.value = t('memory.loading')
|
||||
label.textContent = state.currentPath
|
||||
|
||||
// 退出预览模式
|
||||
editor.style.display = ''
|
||||
const previewEl = page.querySelector('#md-preview')
|
||||
if (previewEl) previewEl.remove()
|
||||
btnPreview.textContent = '预览'
|
||||
btnPreview.textContent = t('memory.preview')
|
||||
|
||||
try {
|
||||
const content = await api.readMemoryFile(state.currentPath, state.agentId)
|
||||
@@ -209,8 +212,8 @@ async function loadFileContent(page, state) {
|
||||
btnDel.disabled = false
|
||||
btnDl.disabled = false
|
||||
} catch (e) {
|
||||
editor.value = '读取失败: ' + e
|
||||
toast('读取文件失败: ' + e, 'error')
|
||||
editor.value = t('memory.readFailed') + ': ' + e
|
||||
toast(t('memory.readFileFailed') + ': ' + e, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,10 +224,10 @@ function resetEditor(page) {
|
||||
editor.style.display = ''
|
||||
const previewEl = page.querySelector('#md-preview')
|
||||
if (previewEl) previewEl.remove()
|
||||
page.querySelector('#current-file').textContent = '选择文件查看'
|
||||
page.querySelector('#current-file').textContent = t('memory.selectFile')
|
||||
page.querySelector('#btn-save-file').disabled = true
|
||||
page.querySelector('#btn-preview').disabled = true
|
||||
page.querySelector('#btn-preview').textContent = '预览'
|
||||
page.querySelector('#btn-preview').textContent = t('memory.preview')
|
||||
page.querySelector('#btn-del-file').disabled = true
|
||||
page.querySelector('#btn-download').disabled = true
|
||||
}
|
||||
@@ -234,9 +237,9 @@ async function saveFile(page, state) {
|
||||
const content = page.querySelector('#file-editor').value
|
||||
try {
|
||||
await api.writeMemoryFile(state.currentPath, content, null, state.agentId)
|
||||
toast('文件已保存', 'success')
|
||||
toast(t('memory.fileSaved'), 'success')
|
||||
} catch (e) {
|
||||
toast('保存失败: ' + e, 'error')
|
||||
toast(t('memory.saveFailed') + ': ' + e, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,7 +252,7 @@ function togglePreview(page) {
|
||||
// 退出预览
|
||||
previewEl.remove()
|
||||
editor.style.display = ''
|
||||
btn.textContent = '预览'
|
||||
btn.textContent = t('memory.preview')
|
||||
} else {
|
||||
// 进入预览
|
||||
const md = editor.value
|
||||
@@ -259,7 +262,7 @@ function togglePreview(page) {
|
||||
previewEl.innerHTML = renderMarkdown(md)
|
||||
editor.style.display = 'none'
|
||||
editor.parentElement.appendChild(previewEl)
|
||||
btn.textContent = '编辑'
|
||||
btn.textContent = t('memory.edit')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,27 +299,27 @@ async function downloadCurrentFile(page, state) {
|
||||
const content = page.querySelector('#file-editor').value
|
||||
const filename = state.currentPath.split('/').pop()
|
||||
triggerDownload(filename, content)
|
||||
toast(`已下载 ${filename}`, 'success')
|
||||
toast(t('memory.downloaded', { name: filename }), 'success')
|
||||
} catch (e) {
|
||||
toast('下载失败: ' + e, 'error')
|
||||
toast(t('memory.downloadFailed') + ': ' + e, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function exportZip(state) {
|
||||
try {
|
||||
const zipPath = await api.exportMemoryZip(state.category, state.agentId)
|
||||
const label = CATEGORIES.find(c => c.key === state.category)?.label || state.category
|
||||
const label = CATEGORIES().find(c => c.key === state.category)?.label || state.category
|
||||
// 尝试用 Tauri shell open 打开文件所在目录
|
||||
try {
|
||||
const { open } = await import('@tauri-apps/plugin-shell')
|
||||
const dir = zipPath.substring(0, zipPath.lastIndexOf('/')) || zipPath
|
||||
await open(dir)
|
||||
toast(`已导出: ${label} → ${zipPath}`, 'success')
|
||||
toast(t('memory.exported', { label, path: zipPath }), 'success')
|
||||
} catch {
|
||||
// fallback:仅显示路径
|
||||
toast(`已导出: ${label} → ${zipPath}`, 'success')
|
||||
toast(t('memory.exported', { label, path: zipPath }), 'success')
|
||||
}
|
||||
} catch (e) {
|
||||
toast('打包下载失败: ' + e, 'error')
|
||||
toast(t('memory.exportFailed') + ': ' + e, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { toast } from '../components/toast.js'
|
||||
import { showModal, showConfirm } from '../components/modal.js'
|
||||
import { icon, statusIcon } from '../lib/icons.js'
|
||||
import { API_TYPES, PROVIDER_PRESETS, QTCOOL, MODEL_PRESETS, fetchQtcoolModels } from '../lib/model-presets.js'
|
||||
import { t } from '../lib/i18n.js'
|
||||
|
||||
export async function render() {
|
||||
const page = document.createElement('div')
|
||||
@@ -14,42 +15,41 @@ export async function render() {
|
||||
|
||||
page.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">模型配置</h1>
|
||||
<p class="page-desc">添加 AI 模型服务商,配置可用模型</p>
|
||||
<h1 class="page-title">${t('models.title')}</h1>
|
||||
<p class="page-desc">${t('models.desc')}</p>
|
||||
</div>
|
||||
<div class="config-actions">
|
||||
<button class="btn btn-primary btn-sm" id="btn-add-provider">+ 添加服务商</button>
|
||||
<button class="btn btn-secondary btn-sm" id="btn-undo" disabled>↩ 撤销</button>
|
||||
<button class="btn btn-primary btn-sm" id="btn-add-provider">${t('models.addProvider')}</button>
|
||||
<button class="btn btn-secondary btn-sm" id="btn-undo" disabled>${t('models.undo')}</button>
|
||||
</div>
|
||||
<div class="form-hint" style="margin-bottom:var(--space-md)">
|
||||
服务商是模型的来源(如 OpenAI、DeepSeek 等)。每个服务商下可添加多个模型。
|
||||
标记为「主模型」的将优先使用,其余作为备选自动切换。配置修改后自动保存。
|
||||
${t('models.providerHint')}
|
||||
</div>
|
||||
<div id="qtcool-promo" style="margin-bottom:var(--space-md);border-radius:var(--radius-lg);border:1px solid var(--border-primary);border-left:3px solid var(--primary);background:var(--bg-secondary);padding:16px 20px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:12px;margin-bottom:12px">
|
||||
<div style="flex:1;min-width:200px">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">
|
||||
<span style="font-weight:700;font-size:var(--font-size-base);color:var(--text-primary)">${icon('zap', 15)} 晴辰云</span>
|
||||
<span style="font-size:10px;background:var(--primary);color:#fff;padding:1px 7px;border-radius:8px">推荐</span>
|
||||
<span style="font-weight:700;font-size:var(--font-size-base);color:var(--text-primary)">${icon('zap', 15)} ${t('models.qtcoolName')}</span>
|
||||
<span style="font-size:10px;background:var(--primary);color:#fff;padding:1px 7px;border-radius:8px">${t('models.qtcoolRecommend')}</span>
|
||||
</div>
|
||||
<div style="font-size:var(--font-size-xs);color:var(--text-secondary);line-height:1.5">
|
||||
GPT-5 / Codex 全系列,低至官方价 2-3 折,不满意随时可退。
|
||||
<a href="${QTCOOL.site}" target="_blank" style="color:var(--primary);text-decoration:none">了解更多 →</a>
|
||||
${t('models.qtcoolDesc')}
|
||||
<a href="${QTCOOL.site}" target="_blank" style="color:var(--primary);text-decoration:none">${t('models.qtcoolMore')}</a>
|
||||
</div>
|
||||
</div>
|
||||
<a href="${QTCOOL.checkinUrl}" target="_blank" class="btn btn-primary btn-sm">${icon('gift', 12)} 每日签到领额度</a>
|
||||
<a href="${QTCOOL.checkinUrl}" target="_blank" class="btn btn-primary btn-sm">${icon('gift', 12)} ${t('models.qtcoolCheckin')}</a>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||
<input class="form-input" id="qtcool-apikey" placeholder="粘贴 API Key(签到后在用户后台获取)" style="font-size:12px;padding:6px 10px;flex:1;min-width:180px">
|
||||
<button class="btn btn-primary btn-sm" id="btn-qtcool-oneclick">${icon('plus', 14)} 获取模型列表</button>
|
||||
<input class="form-input" id="qtcool-apikey" placeholder="${t('models.qtcoolKeyPlaceholder')}" style="font-size:12px;padding:6px 10px;flex:1;min-width:180px">
|
||||
<button class="btn btn-primary btn-sm" id="btn-qtcool-oneclick">${icon('plus', 14)} ${t('models.qtcoolFetchModels')}</button>
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--text-tertiary);margin-top:6px">
|
||||
没有密钥?前往 <a href="${QTCOOL.checkinUrl}" target="_blank" style="color:var(--primary)">签到页</a> 每日签到即可领取免费额度,在 <a href="${QTCOOL.usageUrl}" target="_blank" style="color:var(--primary)">用户后台</a> 复制你的 Key
|
||||
${t('models.qtcoolNoKey')} <a href="${QTCOOL.checkinUrl}" target="_blank" style="color:var(--primary)">${t('models.qtcoolCheckinPage')}</a> ${t('models.qtcoolCheckinHint')} <a href="${QTCOOL.usageUrl}" target="_blank" style="color:var(--primary)">${t('models.qtcoolDashboard')}</a> ${t('models.qtcoolCopyKey')}
|
||||
</div>
|
||||
</div>
|
||||
<div id="default-model-bar"></div>
|
||||
<div style="margin-bottom:var(--space-md)">
|
||||
<input class="form-input" id="model-search" placeholder="搜索模型(按 ID 或名称过滤)" style="max-width:360px">
|
||||
<input class="form-input" id="model-search" placeholder="${t('models.searchPlaceholder')}" style="max-width:360px">
|
||||
</div>
|
||||
<div id="providers-list">
|
||||
<div class="config-section"><div class="stat-card loading-placeholder" style="height:120px"></div></div>
|
||||
@@ -82,13 +82,13 @@ async function loadConfig(page, state) {
|
||||
if (before !== after) {
|
||||
console.log('[models] 自动修复了服务商 baseUrl,正在保存...')
|
||||
await api.writeOpenclawConfig(state.config)
|
||||
toast('已自动修复模型接口地址(如 Ollama /v1)', 'info')
|
||||
toast(t('models.autoFixUrl'), 'info')
|
||||
}
|
||||
renderDefaultBar(page, state)
|
||||
renderProviders(page, state)
|
||||
} catch (e) {
|
||||
listEl.innerHTML = '<div style="color:var(--error);padding:20px">加载配置失败: ' + e + '</div>'
|
||||
toast('加载配置失败: ' + e, 'error')
|
||||
listEl.innerHTML = '<div style="color:var(--error);padding:20px">' + t('models.configLoadFailed') + ': ' + e + '</div>'
|
||||
toast(t('models.configLoadFailed') + ': ' + e, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ function collectAllModels(config) {
|
||||
}
|
||||
|
||||
function getApiTypeLabel(apiType) {
|
||||
return API_TYPES.find(t => t.value === apiType)?.label || apiType || '未知'
|
||||
return API_TYPES.find(at => at.value === apiType)?.label || apiType || t('common.unknown')
|
||||
}
|
||||
|
||||
// 渲染当前主模型状态栏
|
||||
@@ -121,18 +121,18 @@ function renderDefaultBar(page, state) {
|
||||
|
||||
bar.innerHTML = `
|
||||
<div class="config-section" style="margin-bottom:var(--space-lg)">
|
||||
<div class="config-section-title">当前生效配置</div>
|
||||
<div class="config-section-title">${t('models.currentConfig')}</div>
|
||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
|
||||
<div>
|
||||
<span style="font-size:var(--font-size-sm);color:var(--text-tertiary)">主模型:</span>
|
||||
<span style="font-family:var(--font-mono);font-size:var(--font-size-sm);color:${primary ? 'var(--success)' : 'var(--error)'}">${primary || '未配置'}</span>
|
||||
<span style="font-size:var(--font-size-sm);color:var(--text-tertiary)">${t('models.primaryModelLabel')}</span>
|
||||
<span style="font-family:var(--font-mono);font-size:var(--font-size-sm);color:${primary ? 'var(--success)' : 'var(--error)'}">${primary || t('models.notConfigured')}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style="font-size:var(--font-size-sm);color:var(--text-tertiary)">备选模型:</span>
|
||||
<span style="font-size:var(--font-size-sm);color:var(--text-secondary)">${fallbacks.length ? fallbacks.join(', ') : '无'}</span>
|
||||
<span style="font-size:var(--font-size-sm);color:var(--text-tertiary)">${t('models.fallbackModels')}</span>
|
||||
<span style="font-size:var(--font-size-sm);color:var(--text-secondary)">${fallbacks.length ? fallbacks.join(', ') : t('models.fallbackNone')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-hint" style="margin-top:6px">主模型不可用时,系统会自动切换到备选模型</div>
|
||||
<div class="form-hint" style="margin-top:6px">${t('models.fallbackHint')}</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
@@ -201,7 +201,7 @@ function renderProviders(page, state) {
|
||||
if (!keys.length) {
|
||||
listEl.innerHTML = `
|
||||
<div style="color:var(--text-tertiary);padding:20px;text-align:center">
|
||||
暂无服务商,点击「+ 添加服务商」开始配置
|
||||
${t('models.noProvider')}
|
||||
</div>`
|
||||
return
|
||||
}
|
||||
@@ -225,37 +225,37 @@ function renderProviders(page, state) {
|
||||
return `
|
||||
<div class="config-section" data-provider="${key}">
|
||||
<div class="config-section-title" style="display:flex;justify-content:space-between;align-items:center">
|
||||
<span style="cursor:pointer;user-select:none" data-action="toggle-provider"><span style="display:inline-block;width:16px;font-size:12px;color:var(--text-tertiary)">${chevron}</span>${key} <span style="font-size:var(--font-size-xs);color:var(--text-tertiary);font-weight:400">${getApiTypeLabel(p.api)} · ${models.length} 个模型</span></span>
|
||||
<span style="cursor:pointer;user-select:none" data-action="toggle-provider"><span style="display:inline-block;width:16px;font-size:12px;color:var(--text-tertiary)">${chevron}</span>${key} <span style="font-size:var(--font-size-xs);color:var(--text-tertiary);font-weight:400">${getApiTypeLabel(p.api)} · ${t('models.nModels', { count: models.length })}</span></span>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn btn-sm btn-secondary" data-action="edit-provider">编辑</button>
|
||||
<button class="btn btn-sm btn-secondary" data-action="add-model">+ 模型</button>
|
||||
<button class="btn btn-sm btn-secondary" data-action="fetch-models">获取列表</button>
|
||||
<button class="btn btn-sm btn-danger" data-action="delete-provider">删除</button>
|
||||
<button class="btn btn-sm btn-secondary" data-action="edit-provider">${t('models.editProvider')}</button>
|
||||
<button class="btn btn-sm btn-secondary" data-action="add-model">${t('models.addModel')}</button>
|
||||
<button class="btn btn-sm btn-secondary" data-action="fetch-models">${t('models.fetchList')}</button>
|
||||
<button class="btn btn-sm btn-danger" data-action="delete-provider">${t('models.deleteProvider')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="provider-body" style="${collapsed ? 'display:none' : ''}">
|
||||
${models.length >= 2 ? `
|
||||
<div style="display:flex;gap:6px;margin-bottom:var(--space-sm);align-items:center">
|
||||
<button class="btn btn-sm btn-secondary" data-action="batch-test">批量测试</button>
|
||||
<button class="btn btn-sm btn-secondary" data-action="select-all">全选</button>
|
||||
<button class="btn btn-sm btn-danger" data-action="batch-delete">批量删除</button>
|
||||
<button class="btn btn-sm btn-secondary" data-action="batch-test">${t('models.batchTest')}</button>
|
||||
<button class="btn btn-sm btn-secondary" data-action="select-all">${t('models.selectAll')}</button>
|
||||
<button class="btn btn-sm btn-danger" data-action="batch-delete">${t('models.batchDelete')}</button>
|
||||
<div style="margin-left:auto;display:flex;gap:6px;align-items:center">
|
||||
<span style="font-size:var(--font-size-xs);color:var(--text-tertiary)">排序:</span>
|
||||
<span style="font-size:var(--font-size-xs);color:var(--text-tertiary)">${t('models.sort')}</span>
|
||||
<select class="form-input" data-action="sort-models" style="padding:4px 8px;font-size:var(--font-size-xs);width:auto">
|
||||
<option value="default">默认顺序 (拖拽调整)</option>
|
||||
<option value="name-asc">名称 A-Z (固化到底层)</option>
|
||||
<option value="name-desc">名称 Z-A (固化到底层)</option>
|
||||
<option value="latency-asc">延迟 低→高 (固化到底层)</option>
|
||||
<option value="latency-desc">延迟 高→低 (固化到底层)</option>
|
||||
<option value="context-asc">上下文 小→大 (固化到底层)</option>
|
||||
<option value="context-desc">上下文 大→小 (固化到底层)</option>
|
||||
<option value="default">${t('models.sortDefault')}</option>
|
||||
<option value="name-asc">${t('models.sortNameAsc')}</option>
|
||||
<option value="name-desc">${t('models.sortNameDesc')}</option>
|
||||
<option value="latency-asc">${t('models.sortLatencyAsc')}</option>
|
||||
<option value="latency-desc">${t('models.sortLatencyDesc')}</option>
|
||||
<option value="context-asc">${t('models.sortContextAsc')}</option>
|
||||
<option value="context-desc">${t('models.sortContextDesc')}</option>
|
||||
</select>
|
||||
<button class="btn btn-sm btn-secondary" data-action="apply-sort" style="display:none">保存当前排序</button>
|
||||
<button class="btn btn-sm btn-secondary" data-action="apply-sort" style="display:none">${t('models.applySortBtn')}</button>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
<div class="provider-models">
|
||||
${renderModelCards(key, sorted, primary, search)}
|
||||
${hiddenCount > 0 ? `<div style="font-size:var(--font-size-xs);color:var(--text-tertiary);padding:4px 0">已隐藏 ${hiddenCount} 个不匹配的模型</div>` : ''}
|
||||
${hiddenCount > 0 ? `<div style="font-size:var(--font-size-xs);color:var(--text-tertiary);padding:4px 0">${t('models.hiddenModels', { count: hiddenCount })}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -269,7 +269,7 @@ function renderProviders(page, state) {
|
||||
// 渲染模型卡片(支持搜索高亮和批量选择 checkbox)
|
||||
function renderModelCards(providerKey, models, primary, search) {
|
||||
if (!models.length) {
|
||||
return '<div style="color:var(--text-tertiary);font-size:var(--font-size-sm);padding:8px 0">暂无模型,点击「+ 模型」添加</div>'
|
||||
return `<div style="color:var(--text-tertiary);font-size:var(--font-size-sm);padding:8px 0">${t('models.noModel')}</div>`
|
||||
}
|
||||
return models.map((m) => {
|
||||
const id = typeof m === 'string' ? m : m.id
|
||||
@@ -280,11 +280,11 @@ function renderModelCards(providerKey, models, primary, search) {
|
||||
const bgColor = isPrimary ? 'var(--success-muted)' : 'var(--bg-tertiary)'
|
||||
const meta = []
|
||||
if (name !== id) meta.push(name)
|
||||
if (m.contextWindow) meta.push((m.contextWindow / 1000) + 'K 上下文')
|
||||
if (m.contextWindow) meta.push((m.contextWindow / 1000) + 'K ' + t('models.context'))
|
||||
// 测试状态标签:成功显示耗时,失败显示不可用
|
||||
let latencyTag = ''
|
||||
if (m.testStatus === 'fail') {
|
||||
latencyTag = `<span style="font-size:var(--font-size-xs);padding:1px 6px;border-radius:var(--radius-sm);background:var(--error-muted, #fee2e2);color:var(--error)" title="${(m.testError || '').replace(/"/g, '"')}">不可用</span>`
|
||||
latencyTag = `<span style="font-size:var(--font-size-xs);padding:1px 6px;border-radius:var(--radius-sm);background:var(--error-muted, #fee2e2);color:var(--error)" title="${(m.testError || '').replace(/"/g, '"')}">${t('models.unavailable')}</span>`
|
||||
} else if (m.latency != null) {
|
||||
const color = m.latency < 3000 ? 'success' : m.latency < 8000 ? 'warning' : 'error'
|
||||
const bg = color === 'success' ? 'var(--success-muted)' : color === 'warning' ? 'var(--warning-muted, #fef3c7)' : 'var(--error-muted, #fee2e2)'
|
||||
@@ -301,17 +301,17 @@ function renderModelCards(providerKey, models, primary, search) {
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<span style="font-family:var(--font-mono);font-size:var(--font-size-sm)">${id}</span>
|
||||
${isPrimary ? '<span style="font-size:var(--font-size-xs);background:var(--success);color:var(--text-inverse);padding:1px 6px;border-radius:var(--radius-sm)">主模型</span>' : ''}
|
||||
${m.reasoning ? '<span style="font-size:var(--font-size-xs);background:var(--accent-muted);color:var(--accent);padding:1px 6px;border-radius:var(--radius-sm)">推理</span>' : ''}
|
||||
${isPrimary ? `<span style="font-size:var(--font-size-xs);background:var(--success);color:var(--text-inverse);padding:1px 6px;border-radius:var(--radius-sm)">${t('models.primaryModel')}</span>` : ''}
|
||||
${m.reasoning ? `<span style="font-size:var(--font-size-xs);background:var(--accent-muted);color:var(--accent);padding:1px 6px;border-radius:var(--radius-sm)">${t('models.reasoning')}</span>` : ''}
|
||||
${latencyTag}
|
||||
</div>
|
||||
<div style="font-size:var(--font-size-xs);color:var(--text-tertiary);margin-top:2px">${meta.join(' · ') || ''}</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;flex-shrink:0">
|
||||
<button class="btn btn-sm btn-secondary" data-action="test-model">测试</button>
|
||||
${!isPrimary ? '<button class="btn btn-sm btn-secondary" data-action="set-primary">设为主模型</button>' : ''}
|
||||
<button class="btn btn-sm btn-secondary" data-action="edit-model">编辑</button>
|
||||
<button class="btn btn-sm btn-danger" data-action="delete-model">删除</button>
|
||||
<button class="btn btn-sm btn-secondary" data-action="test-model">${t('models.testBtn')}</button>
|
||||
${!isPrimary ? `<button class="btn btn-sm btn-secondary" data-action="set-primary">${t('models.setPrimary')}</button>` : ''}
|
||||
<button class="btn btn-sm btn-secondary" data-action="edit-model">${t('models.editModel')}</button>
|
||||
<button class="btn btn-sm btn-danger" data-action="delete-model">${t('models.deleteModel')}</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
@@ -321,10 +321,10 @@ function renderModelCards(providerKey, models, primary, search) {
|
||||
// 格式化测试时间为相对时间
|
||||
function formatTestTime(ts) {
|
||||
const diff = Date.now() - ts
|
||||
if (diff < 60000) return '刚刚测试'
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前测试`
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前测试`
|
||||
return `${Math.floor(diff / 86400000)} 天前测试`
|
||||
if (diff < 60000) return t('models.justTested')
|
||||
if (diff < 3600000) return t('models.minAgoTest', { n: Math.floor(diff / 60000) })
|
||||
if (diff < 86400000) return t('models.hourAgoTest', { n: Math.floor(diff / 3600000) })
|
||||
return t('models.dayAgoTest', { n: Math.floor(diff / 86400000) })
|
||||
}
|
||||
|
||||
// 根据 model-id 找到原始 index
|
||||
@@ -348,7 +348,7 @@ async function undo(page, state) {
|
||||
renderDefaultBar(page, state)
|
||||
updateUndoBtn(page, state)
|
||||
await doAutoSave(state)
|
||||
toast('已撤销', 'info')
|
||||
toast(t('models.undone'), 'info')
|
||||
}
|
||||
|
||||
// 自动保存(防抖 300ms)
|
||||
@@ -419,7 +419,7 @@ async function saveConfigOnly(state) {
|
||||
normalizeProviderUrls(state.config)
|
||||
await api.writeOpenclawConfig(state.config)
|
||||
} catch (e) {
|
||||
toast('保存失败: ' + e, 'error')
|
||||
toast(t('models.saveFailed') + ': ' + e, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,29 +431,29 @@ async function doAutoSave(state) {
|
||||
await api.writeOpenclawConfig(state.config)
|
||||
|
||||
// 重启 Gateway 使配置生效(Gateway 不支持 SIGHUP 热重载)
|
||||
toast('配置已保存,正在重启 Gateway...', 'info')
|
||||
toast(t('models.configSavedRestarting'), 'info')
|
||||
try {
|
||||
await api.restartGateway()
|
||||
toast('配置已生效,Gateway 已重启', 'success')
|
||||
toast(t('models.configEffective'), 'success')
|
||||
} catch (e) {
|
||||
// 重启失败时提供手动重试按钮
|
||||
const restartBtn = document.createElement('button')
|
||||
restartBtn.className = 'btn btn-sm btn-primary'
|
||||
restartBtn.textContent = '重试'
|
||||
restartBtn.textContent = t('models.retryRestart')
|
||||
restartBtn.style.marginLeft = '8px'
|
||||
restartBtn.onclick = async () => {
|
||||
try {
|
||||
toast('正在重启 Gateway...', 'info')
|
||||
toast(t('models.restarting'), 'info')
|
||||
await api.restartGateway()
|
||||
toast('Gateway 重启成功', 'success')
|
||||
toast(t('models.restartOk'), 'success')
|
||||
} catch (e2) {
|
||||
toast('重启失败: ' + e2.message, 'error')
|
||||
toast(t('models.restartFailed') + ': ' + e2.message, 'error')
|
||||
}
|
||||
}
|
||||
toast('配置已保存,但 Gateway 重启失败: ' + e.message, 'warning', { action: restartBtn })
|
||||
toast(t('models.configSavedGwFailed') + ': ' + e.message, 'warning', { action: restartBtn })
|
||||
}
|
||||
} catch (e) {
|
||||
toast('自动保存失败: ' + e, 'error')
|
||||
toast(t('models.autoSaveFailed') + ': ' + e, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,7 +463,7 @@ function updateUndoBtn(page, state) {
|
||||
if (!btn) return
|
||||
const n = state.undoStack.length
|
||||
btn.disabled = !n
|
||||
btn.textContent = n ? `↩ 撤销 (${n})` : '↩ 撤销'
|
||||
btn.textContent = n ? t('models.undoN', { n }) : t('models.undo')
|
||||
}
|
||||
|
||||
// 渲染完成后,直接给每个 [data-action] 按钮绑定 onclick
|
||||
@@ -488,7 +488,7 @@ function bindProviderButtons(listEl, page, state) {
|
||||
state.sortBy = 'default'
|
||||
renderProviders(page, state)
|
||||
autoSave(state)
|
||||
toast('排序已保存', 'success')
|
||||
toast(t('models.sortSaved'), 'success')
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -636,7 +636,7 @@ async function handleAction(action, btn, card, section, providerKey, provider, p
|
||||
fetchRemoteModels(btn, page, state, providerKey)
|
||||
break
|
||||
case 'delete-provider': {
|
||||
const yes = await showConfirm(`确定删除「${providerKey}」及其所有模型?`)
|
||||
const yes = await showConfirm(t('models.confirmDeleteProvider', { name: providerKey }))
|
||||
if (!yes) return
|
||||
pushUndo(state)
|
||||
delete state.config.models.providers[providerKey]
|
||||
@@ -644,7 +644,7 @@ async function handleAction(action, btn, card, section, providerKey, provider, p
|
||||
renderDefaultBar(page, state)
|
||||
updateUndoBtn(page, state)
|
||||
autoSave(state)
|
||||
toast(`已删除 ${providerKey}`, 'info')
|
||||
toast(t('models.providerDeleted', { name: providerKey }), 'info')
|
||||
break
|
||||
}
|
||||
case 'select-all':
|
||||
@@ -659,7 +659,7 @@ async function handleAction(action, btn, card, section, providerKey, provider, p
|
||||
case 'delete-model': {
|
||||
if (!card) return
|
||||
const modelId = card.dataset.modelId
|
||||
const yes = await showConfirm(`确定删除模型「${modelId}」?`)
|
||||
const yes = await showConfirm(t('models.confirmDeleteModel', { name: modelId }))
|
||||
if (!yes) return
|
||||
pushUndo(state)
|
||||
const idx = findModelIdx(provider, modelId)
|
||||
@@ -668,7 +668,7 @@ async function handleAction(action, btn, card, section, providerKey, provider, p
|
||||
renderDefaultBar(page, state)
|
||||
updateUndoBtn(page, state)
|
||||
autoSave(state)
|
||||
toast(`已删除 ${modelId}`, 'info')
|
||||
toast(t('models.modelDeleted', { name: modelId }), 'info')
|
||||
break
|
||||
}
|
||||
case 'edit-model': {
|
||||
@@ -685,7 +685,7 @@ async function handleAction(action, btn, card, section, providerKey, provider, p
|
||||
renderDefaultBar(page, state)
|
||||
updateUndoBtn(page, state)
|
||||
autoSave(state)
|
||||
toast('已设为主模型', 'success')
|
||||
toast(t('models.setPrimaryDone'), 'success')
|
||||
break
|
||||
}
|
||||
case 'test-model': {
|
||||
@@ -722,7 +722,7 @@ function ensureValidPrimary(state) {
|
||||
// primary 指向已删除的模型,自动切到第一个
|
||||
const newPrimary = allModels[0].full
|
||||
setPrimary(state, newPrimary)
|
||||
toast(`主模型已自动切换为 ${newPrimary}`, 'info')
|
||||
toast(t('models.primaryAutoSwitch', { model: newPrimary }), 'info')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -759,22 +759,22 @@ function bindTopActions(page, state) {
|
||||
|
||||
// 晴辰云:获取模型列表 → 弹窗让用户选择要添加的模型
|
||||
page.querySelector('#btn-qtcool-oneclick').onclick = async () => {
|
||||
if (!state.config) { toast('配置未加载完成,请稍候', 'warning'); return }
|
||||
if (!state.config) { toast(t('models.configNotReady'), 'warning'); return }
|
||||
|
||||
const bannerKeyInput = page.querySelector('#qtcool-apikey')
|
||||
const bannerKey = bannerKeyInput ? bannerKeyInput.value.trim() : ''
|
||||
|
||||
const btn = page.querySelector('#btn-qtcool-oneclick')
|
||||
btn.textContent = '获取中...'
|
||||
btn.textContent = t('models.qtcoolFetching')
|
||||
btn.disabled = true
|
||||
|
||||
const models = await fetchQtcoolModels(bannerKey || undefined)
|
||||
|
||||
btn.innerHTML = `${icon('plus', 14)} 获取模型列表`
|
||||
btn.innerHTML = `${icon('plus', 14)} ${t('models.qtcoolFetchModels')}`
|
||||
btn.disabled = false
|
||||
|
||||
if (!models.length) {
|
||||
toast('无法获取模型列表,请检查网络或稍后重试', 'error')
|
||||
toast(t('models.fetchRemoteFailed'), 'error')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -787,29 +787,29 @@ function bindTopActions(page, state) {
|
||||
overlay.className = 'modal-overlay'
|
||||
overlay.innerHTML = `
|
||||
<div class="modal" style="max-height:80vh;overflow-y:auto">
|
||||
<div class="modal-title">选择要添加的模型</div>
|
||||
<div class="form-hint" style="margin-bottom:12px">从晴辰云获取到 ${models.length} 个可用模型,勾选需要的模型后点击添加。</div>
|
||||
<div class="modal-title">${t('models.qtcoolSelectTitle')}</div>
|
||||
<div class="form-hint" style="margin-bottom:12px">${t('models.qtcoolSelectHint', { count: models.length })}</div>
|
||||
${!existingProvider ? `<div style="margin-bottom:12px">
|
||||
<label class="form-label" style="font-size:var(--font-size-xs)">API Key <a href="${QTCOOL.checkinUrl}" target="_blank" style="color:var(--primary);font-weight:400">每日签到领免费额度 →</a></label>
|
||||
<input class="form-input" id="qtsel-apikey" placeholder="粘贴你的 API Key" style="font-size:12px">
|
||||
<label class="form-label" style="font-size:var(--font-size-xs)">${t('models.qtcoolKeyLabel')} <a href="${QTCOOL.checkinUrl}" target="_blank" style="color:var(--primary);font-weight:400">${t('models.qtcoolKeyCheckinLink')}</a></label>
|
||||
<input class="form-input" id="qtsel-apikey" placeholder="${t('models.qtcoolKeyPlaceholder2')}" style="font-size:12px">
|
||||
</div>` : ''}
|
||||
<div style="margin-bottom:12px;display:flex;gap:8px">
|
||||
<button class="btn btn-sm btn-secondary" id="qtsel-all">全选</button>
|
||||
<button class="btn btn-sm btn-secondary" id="qtsel-none">全不选</button>
|
||||
<button class="btn btn-sm btn-secondary" id="qtsel-all">${t('models.selectAll')}</button>
|
||||
<button class="btn btn-sm btn-secondary" id="qtsel-none">${t('models.selectNone')}</button>
|
||||
</div>
|
||||
<div id="qtmodel-list" style="display:flex;flex-direction:column;gap:6px;max-height:40vh;overflow-y:auto;padding-right:4px">
|
||||
${models.map(m => {
|
||||
const already = existingIds.has(m.id)
|
||||
return `<label style="display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:var(--radius-md);cursor:pointer;background:var(--bg-tertiary);opacity:${already ? '0.5' : '1'}">
|
||||
<input type="checkbox" value="${m.id}" ${already ? 'disabled title="已添加"' : 'checked'} style="accent-color:var(--primary)">
|
||||
<input type="checkbox" value="${m.id}" ${already ? `disabled title="${t('models.alreadyAdded')}"` : 'checked'} style="accent-color:var(--primary)">
|
||||
<span style="font-size:var(--font-size-sm);flex:1">${m.id}</span>
|
||||
${already ? '<span style="font-size:10px;color:var(--text-tertiary)">已有</span>' : ''}
|
||||
${already ? `<span style="font-size:10px;color:var(--text-tertiary)">${t('models.already')}</span>` : ''}
|
||||
</label>`
|
||||
}).join('')}
|
||||
</div>
|
||||
<div class="modal-actions" style="margin-top:16px">
|
||||
<button class="btn btn-primary" id="qtsel-confirm">${icon('plus', 14)} 添加选中模型</button>
|
||||
<button class="btn btn-secondary" id="qtsel-cancel">取消</button>
|
||||
<button class="btn btn-primary" id="qtsel-confirm">${icon('plus', 14)} ${t('models.qtcoolAddSelected')}</button>
|
||||
<button class="btn btn-secondary" id="qtsel-cancel">${t('common.cancel')}</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
@@ -826,13 +826,13 @@ function bindTopActions(page, state) {
|
||||
}
|
||||
overlay.querySelector('#qtsel-confirm').onclick = () => {
|
||||
const selected = [...overlay.querySelectorAll('#qtmodel-list input:checked:not(:disabled)')].map(cb => cb.value)
|
||||
if (!selected.length) { toast('未选择任何模型', 'info'); return }
|
||||
if (!selected.length) { toast(t('models.qtcoolNoneSelected'), 'info'); return }
|
||||
|
||||
// 新建服务商时需要 API Key
|
||||
const keyInput = overlay.querySelector('#qtsel-apikey')
|
||||
const apiKey = keyInput ? keyInput.value.trim() : ''
|
||||
if (!existingProvider && !apiKey) {
|
||||
toast('请输入 API Key(可通过每日签到免费获取)', 'warning')
|
||||
toast(t('models.qtcoolNoKeyWarn'), 'warning')
|
||||
keyInput?.focus()
|
||||
return
|
||||
}
|
||||
@@ -848,7 +848,7 @@ function bindTopActions(page, state) {
|
||||
for (const m of selectedModels) {
|
||||
if (!existingIds.has(m.id)) { existingProvider.models.push({ ...m }); added++ }
|
||||
}
|
||||
toast(added ? `已添加 ${added} 个模型` : '所选模型均已存在', added ? 'success' : 'info')
|
||||
toast(added ? t('models.qtcoolAdded', { count: added }) : t('models.qtcoolAllExist'), added ? 'success' : 'info')
|
||||
} else {
|
||||
state.config.models.providers[QTCOOL.providerKey] = {
|
||||
baseUrl: QTCOOL.baseUrl,
|
||||
@@ -862,7 +862,7 @@ function bindTopActions(page, state) {
|
||||
if (!state.config.agents.defaults.model) state.config.agents.defaults.model = {}
|
||||
state.config.agents.defaults.model.primary = QTCOOL.providerKey + '/' + selectedModels[0].id
|
||||
}
|
||||
toast(`已添加晴辰云(${selectedModels.length} 个模型)`, 'success')
|
||||
toast(t('models.qtcoolProviderAdded', { count: selectedModels.length }), 'success')
|
||||
}
|
||||
renderProviders(page, state)
|
||||
renderDefaultBar(page, state)
|
||||
@@ -883,38 +883,38 @@ function addProvider(page, state) {
|
||||
overlay.className = 'modal-overlay'
|
||||
overlay.innerHTML = `
|
||||
<div class="modal" style="max-height:85vh;overflow-y:auto">
|
||||
<div class="modal-title">添加服务商</div>
|
||||
<div class="modal-title">${t('models.addProviderTitle')}</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">快捷选择</label>
|
||||
<label class="form-label">${t('models.quickSelect')}</label>
|
||||
<div style="display:flex;flex-wrap:wrap">${presetsHtml}</div>
|
||||
<div class="form-hint">选择常用服务商自动填充,或手动填写下方信息</div>
|
||||
<div class="form-hint">${t('models.quickSelectHint')}</div>
|
||||
<div id="preset-detail" style="display:none;margin-top:8px;padding:10px 14px;background:var(--bg-tertiary);border-radius:var(--radius-md);font-size:var(--font-size-sm)"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">服务商名称</label>
|
||||
<input class="form-input" data-name="key" placeholder="如 openai, newapi">
|
||||
<div class="form-hint">自定义标识名,用于区分不同来源</div>
|
||||
<label class="form-label">${t('models.providerName')}</label>
|
||||
<input class="form-input" data-name="key" placeholder="${t('models.providerNamePlaceholder')}">
|
||||
<div class="form-hint">${t('models.providerNameHint')}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">接口地址</label>
|
||||
<input class="form-input" data-name="baseUrl" placeholder="https://api.openai.com/v1">
|
||||
<div class="form-hint">模型服务的 API 地址,通常以 /v1 结尾;Ollama 可直接填 http://127.0.0.1:11434</div>
|
||||
<label class="form-label">${t('models.baseUrl')}</label>
|
||||
<input class="form-input" data-name="baseUrl" placeholder="${t('models.baseUrlPlaceholder')}">
|
||||
<div class="form-hint">${t('models.baseUrlHint')}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">密钥 (API Key)</label>
|
||||
<input class="form-input" data-name="apiKey" placeholder="sk-...">
|
||||
<div class="form-hint">访问服务所需的密钥,留空表示无需认证</div>
|
||||
<label class="form-label">${t('models.apiKey')}</label>
|
||||
<input class="form-input" data-name="apiKey" placeholder="${t('models.apiKeyPlaceholder')}">
|
||||
<div class="form-hint">${t('models.apiKeyHint')}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">接口类型</label>
|
||||
<label class="form-label">${t('models.apiType')}</label>
|
||||
<select class="form-input" data-name="api">
|
||||
${API_TYPES.map(t => `<option value="${t.value}">${t.label}</option>`).join('')}
|
||||
${API_TYPES.map(at => `<option value="${at.value}">${at.label}</option>`).join('')}
|
||||
</select>
|
||||
<div class="form-hint">大多数中转站和 Ollama 选「OpenAI 兼容」即可</div>
|
||||
<div class="form-hint">${t('models.apiTypeHint')}</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary btn-sm" data-action="cancel">取消</button>
|
||||
<button class="btn btn-primary btn-sm" data-action="confirm">确定</button>
|
||||
<button class="btn btn-secondary btn-sm" data-action="cancel">${t('common.cancel')}</button>
|
||||
<button class="btn btn-primary btn-sm" data-action="confirm">${t('common.confirm')}</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
@@ -937,7 +937,7 @@ function addProvider(page, state) {
|
||||
if (detailEl) {
|
||||
if (preset.desc || preset.site) {
|
||||
let html = preset.desc ? `<div style="color:var(--text-secondary);line-height:1.6">${preset.desc}</div>` : ''
|
||||
if (preset.site) html += `<a href="${preset.site}" target="_blank" style="color:var(--accent);text-decoration:none;font-size:12px;margin-top:4px;display:inline-block">→ 访问 ${preset.label}官网</a>`
|
||||
if (preset.site) html += `<a href="${preset.site}" target="_blank" style="color:var(--accent);text-decoration:none;font-size:12px;margin-top:4px;display:inline-block">→ ${t('models.visitSite', { name: preset.label })}</a>`
|
||||
detailEl.innerHTML = html
|
||||
detailEl.style.display = 'block'
|
||||
} else {
|
||||
@@ -955,7 +955,7 @@ function addProvider(page, state) {
|
||||
const baseUrl = overlay.querySelector('[data-name="baseUrl"]').value.trim()
|
||||
const apiKey = overlay.querySelector('[data-name="apiKey"]').value.trim()
|
||||
const apiType = overlay.querySelector('[data-name="api"]').value
|
||||
if (!key) { toast('请填写服务商名称', 'warning'); return }
|
||||
if (!key) { toast(t('models.providerNameRequired'), 'warning'); return }
|
||||
pushUndo(state)
|
||||
if (!state.config.models) state.config.models = { mode: 'replace', providers: {} }
|
||||
if (!state.config.models.providers) state.config.models.providers = {}
|
||||
@@ -969,7 +969,7 @@ function addProvider(page, state) {
|
||||
renderProviders(page, state)
|
||||
updateUndoBtn(page, state)
|
||||
autoSave(state)
|
||||
toast(`已添加服务商: ${key}`, 'success')
|
||||
toast(t('models.providerAdded', { name: key }), 'success')
|
||||
}
|
||||
|
||||
overlay.querySelector('[data-name="key"]')?.focus()
|
||||
@@ -979,14 +979,14 @@ function addProvider(page, state) {
|
||||
function editProvider(page, state, providerKey) {
|
||||
const p = state.config.models.providers[providerKey]
|
||||
showModal({
|
||||
title: `编辑服务商: ${providerKey}`,
|
||||
title: t('models.editProviderTitle', { name: providerKey }),
|
||||
fields: [
|
||||
{ name: 'baseUrl', label: '接口地址', value: p.baseUrl || '', hint: '模型服务的 API 地址,通常以 /v1 结尾;Ollama 可直接填 http://127.0.0.1:11434' },
|
||||
{ name: 'apiKey', label: '密钥 (API Key)', value: p.apiKey || '', hint: '修改后自动保存生效' },
|
||||
{ name: 'baseUrl', label: t('models.baseUrl'), value: p.baseUrl || '', hint: t('models.baseUrlHint') },
|
||||
{ name: 'apiKey', label: t('models.apiKey'), value: p.apiKey || '', hint: t('models.apiKeyEditHint') },
|
||||
{
|
||||
name: 'api', label: '接口类型', type: 'select', value: p.api || 'openai-completions',
|
||||
name: 'api', label: t('models.apiType'), type: 'select', value: p.api || 'openai-completions',
|
||||
options: API_TYPES,
|
||||
hint: '大多数中转站和 Ollama 选「OpenAI 兼容」即可',
|
||||
hint: t('models.apiTypeHint'),
|
||||
},
|
||||
],
|
||||
onConfirm: ({ baseUrl, apiKey, api: apiType }) => {
|
||||
@@ -997,7 +997,7 @@ function editProvider(page, state, providerKey) {
|
||||
renderProviders(page, state)
|
||||
updateUndoBtn(page, state)
|
||||
autoSave(state)
|
||||
toast('服务商已更新', 'success')
|
||||
toast(t('models.providerUpdated'), 'success')
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1012,10 +1012,10 @@ function addModel(page, state, providerKey) {
|
||||
const available = presets.filter(p => !existingIds.includes(p.id))
|
||||
|
||||
const fields = [
|
||||
{ name: 'id', label: '模型 ID', placeholder: '如 gpt-4o', hint: '必须与服务商支持的模型名一致' },
|
||||
{ name: 'name', label: '显示名称(选填)', placeholder: '如 GPT-4o', hint: '方便识别的友好名称' },
|
||||
{ name: 'contextWindow', label: '上下文长度(选填)', placeholder: '如 128000', hint: '模型支持的最大 Token 数' },
|
||||
{ name: 'reasoning', label: '这是推理模型(如 o3、R1、QwQ 等)', type: 'checkbox', value: false, hint: '推理模型会使用特殊的调用方式' },
|
||||
{ name: 'id', label: t('models.modelId'), placeholder: t('models.modelIdPlaceholder'), hint: t('models.modelIdHint') },
|
||||
{ name: 'name', label: t('models.displayName'), placeholder: t('models.displayNamePlaceholder'), hint: t('models.displayNameHint') },
|
||||
{ name: 'contextWindow', label: t('models.contextLength'), placeholder: t('models.contextLengthPlaceholder'), hint: t('models.contextLengthHint') },
|
||||
{ name: 'reasoning', label: t('models.isReasoning'), type: 'checkbox', value: false, hint: t('models.reasoningHint') },
|
||||
]
|
||||
|
||||
if (available.length) {
|
||||
@@ -1024,25 +1024,25 @@ function addModel(page, state, providerKey) {
|
||||
overlay.className = 'modal-overlay'
|
||||
|
||||
const presetBtns = available.map(p =>
|
||||
`<button class="btn btn-sm btn-secondary preset-btn" data-mid="${p.id}" style="margin:0 6px 6px 0">${p.name}${p.reasoning ? ' (推理)' : ''}</button>`
|
||||
`<button class="btn btn-sm btn-secondary preset-btn" data-mid="${p.id}" style="margin:0 6px 6px 0">${p.name}${p.reasoning ? ` (${t('models.reasoning')})` : ''}</button>`
|
||||
).join('')
|
||||
|
||||
overlay.innerHTML = `
|
||||
<div class="modal">
|
||||
<div class="modal-title">添加模型到 ${providerKey}</div>
|
||||
<div class="modal-title">${t('models.addModelTitle', { provider: providerKey })}</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">快捷添加</label>
|
||||
<label class="form-label">${t('models.quickAdd')}</label>
|
||||
<div style="display:flex;flex-wrap:wrap">${presetBtns}</div>
|
||||
<div class="form-hint">点击直接添加常用模型,或手动填写下方信息</div>
|
||||
<div class="form-hint">${t('models.quickAddHint')}</div>
|
||||
</div>
|
||||
<hr style="border:none;border-top:1px solid var(--border-primary);margin:var(--space-sm) 0">
|
||||
<div class="form-group">
|
||||
<label class="form-label">手动添加</label>
|
||||
<label class="form-label">${t('models.manualAdd')}</label>
|
||||
</div>
|
||||
${buildFieldsHtml(fields)}
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary btn-sm" data-action="cancel">取消</button>
|
||||
<button class="btn btn-primary btn-sm" data-action="confirm">确定</button>
|
||||
<button class="btn btn-secondary btn-sm" data-action="cancel">${t('common.cancel')}</button>
|
||||
<button class="btn btn-primary btn-sm" data-action="confirm">${t('common.confirm')}</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
@@ -1070,13 +1070,13 @@ function addModel(page, state, providerKey) {
|
||||
renderDefaultBar(page, state)
|
||||
updateUndoBtn(page, state)
|
||||
autoSave(state)
|
||||
toast(`已添加模型: ${preset.name}`, 'success')
|
||||
toast(t('models.modelAdded', { name: preset.name }), 'success')
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// 无预设,直接弹普通 modal
|
||||
showModal({
|
||||
title: `添加模型到 ${providerKey}`,
|
||||
title: t('models.addModelTitle', { provider: providerKey }),
|
||||
fields,
|
||||
onConfirm: (vals) => {
|
||||
pushUndo(state)
|
||||
@@ -1128,7 +1128,7 @@ function bindModalEvents(overlay, fields, onConfirm) {
|
||||
|
||||
// 实际添加模型到 state
|
||||
function doAddModel(state, providerKey, vals) {
|
||||
if (!vals.id) { toast('请填写模型 ID', 'warning'); return }
|
||||
if (!vals.id) { toast(t('models.modelIdRequired'), 'warning'); return }
|
||||
const model = {
|
||||
id: vals.id.trim(),
|
||||
name: vals.name?.trim() || vals.id.trim(),
|
||||
@@ -1137,19 +1137,19 @@ function doAddModel(state, providerKey, vals) {
|
||||
}
|
||||
if (vals.contextWindow) model.contextWindow = parseInt(vals.contextWindow) || 0
|
||||
state.config.models.providers[providerKey].models.push(model)
|
||||
toast(`已添加模型: ${model.name}`, 'success')
|
||||
toast(t('models.modelAdded', { name: model.name }), 'success')
|
||||
}
|
||||
|
||||
// 编辑模型
|
||||
function editModel(page, state, providerKey, idx) {
|
||||
const m = state.config.models.providers[providerKey].models[idx]
|
||||
showModal({
|
||||
title: `编辑模型: ${m.id}`,
|
||||
title: t('models.editModelTitle', { name: m.id }),
|
||||
fields: [
|
||||
{ name: 'id', label: '模型 ID', value: m.id || '', hint: '必须与服务商支持的模型名一致' },
|
||||
{ name: 'name', label: '显示名称', value: m.name || '', hint: '方便识别的友好名称' },
|
||||
{ name: 'contextWindow', label: '上下文长度', value: String(m.contextWindow || ''), hint: '模型支持的最大 Token 数' },
|
||||
{ name: 'reasoning', label: '这是推理模型', type: 'checkbox', value: !!m.reasoning, hint: '推理模型会使用特殊的调用方式' },
|
||||
{ name: 'id', label: t('models.modelId'), value: m.id || '', hint: t('models.modelIdHint') },
|
||||
{ name: 'name', label: t('models.displayNameLabel'), value: m.name || '', hint: t('models.displayNameHint') },
|
||||
{ name: 'contextWindow', label: t('models.contextLengthLabel'), value: String(m.contextWindow || ''), hint: t('models.contextLengthHint') },
|
||||
{ name: 'reasoning', label: t('models.isReasoningLabel'), type: 'checkbox', value: !!m.reasoning, hint: t('models.reasoningHint') },
|
||||
],
|
||||
onConfirm: (vals) => {
|
||||
if (!vals.id) return
|
||||
@@ -1162,7 +1162,7 @@ function editModel(page, state, providerKey, idx) {
|
||||
renderDefaultBar(page, state)
|
||||
updateUndoBtn(page, state)
|
||||
autoSave(state)
|
||||
toast('模型已更新', 'success')
|
||||
toast(t('models.modelUpdated'), 'success')
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1180,9 +1180,9 @@ function handleSelectAll(section) {
|
||||
// 批量删除选中的模型
|
||||
async function handleBatchDelete(section, page, state, providerKey) {
|
||||
const checked = [...section.querySelectorAll('.model-checkbox:checked')]
|
||||
if (!checked.length) { toast('请先勾选要删除的模型', 'warning'); return }
|
||||
if (!checked.length) { toast(t('models.batchSelectHint'), 'warning'); return }
|
||||
const ids = checked.map(cb => cb.dataset.modelId)
|
||||
const yes = await showConfirm(`确定删除选中的 ${ids.length} 个模型?\n${ids.join(', ')}`)
|
||||
const yes = await showConfirm(t('models.confirmBatchDelete', { count: ids.length, ids: ids.join(', ') }))
|
||||
if (!yes) return
|
||||
pushUndo(state)
|
||||
const provider = state.config.models.providers[providerKey]
|
||||
@@ -1194,7 +1194,7 @@ async function handleBatchDelete(section, page, state, providerKey) {
|
||||
renderDefaultBar(page, state)
|
||||
updateUndoBtn(page, state)
|
||||
autoSave(state)
|
||||
toast(`已删除 ${ids.length} 个模型`, 'info')
|
||||
toast(t('models.batchDeleted', { count: ids.length }), 'info')
|
||||
}
|
||||
|
||||
// 批量测试:勾选的模型,没勾选则测试全部(记录耗时和状态)
|
||||
@@ -1202,7 +1202,7 @@ async function handleBatchTest(section, state, providerKey) {
|
||||
// 如果正在测试,点击则终止
|
||||
if (_batchTestAbort) {
|
||||
_batchTestAbort.abort = true
|
||||
toast('正在终止批量测试...', 'warning')
|
||||
toast(t('models.stoppingBatchTest'), 'warning')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1212,13 +1212,13 @@ async function handleBatchTest(section, state, providerKey) {
|
||||
? checked.map(cb => cb.dataset.modelId)
|
||||
: (provider.models || []).map(m => typeof m === 'string' ? m : m.id)
|
||||
|
||||
if (!ids.length) { toast('没有可测试的模型', 'warning'); return }
|
||||
if (!ids.length) { toast(t('models.noTestModels'), 'warning'); return }
|
||||
|
||||
const batchBtn = section.querySelector('[data-action="batch-test"]')
|
||||
const ctrl = { abort: false }
|
||||
_batchTestAbort = ctrl
|
||||
if (batchBtn) {
|
||||
batchBtn.textContent = '终止测试'
|
||||
batchBtn.textContent = t('models.stopBatchTest')
|
||||
batchBtn.classList.remove('btn-secondary')
|
||||
batchBtn.classList.add('btn-danger')
|
||||
}
|
||||
@@ -1272,7 +1272,7 @@ async function handleBatchTest(section, state, providerKey) {
|
||||
const newSection = page?.querySelector(`[data-provider="${providerKey}"]`)
|
||||
const newBtn = newSection?.querySelector('[data-action="batch-test"]')
|
||||
if (newBtn) {
|
||||
newBtn.textContent = '批量测试'
|
||||
newBtn.textContent = t('models.batchTest')
|
||||
newBtn.classList.remove('btn-danger')
|
||||
newBtn.classList.add('btn-secondary')
|
||||
}
|
||||
@@ -1280,9 +1280,9 @@ async function handleBatchTest(section, state, providerKey) {
|
||||
const aborted = ctrl.abort
|
||||
autoSave(state)
|
||||
if (aborted) {
|
||||
toast(`批量测试已终止:${ok} 成功,${fail} 失败,${ids.length - ok - fail} 跳过`, 'warning')
|
||||
toast(t('models.batchTestAborted', { ok, fail, skip: ids.length - ok - fail }), 'warning')
|
||||
} else {
|
||||
toast(`批量测试完成:${ok} 成功,${fail} 失败`, ok === ids.length ? 'success' : 'warning')
|
||||
toast(t('models.batchTestDone', { ok, fail }), ok === ids.length ? 'success' : 'warning')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1290,12 +1290,12 @@ async function handleBatchTest(section, state, providerKey) {
|
||||
async function fetchRemoteModels(btn, page, state, providerKey) {
|
||||
const provider = state.config.models.providers[providerKey]
|
||||
btn.disabled = true
|
||||
btn.textContent = '获取中...'
|
||||
btn.textContent = t('models.qtcoolFetching')
|
||||
|
||||
try {
|
||||
const remoteIds = await api.listRemoteModels(provider.baseUrl, provider.apiKey || '', provider.api || 'openai-completions')
|
||||
btn.disabled = false
|
||||
btn.textContent = '获取列表'
|
||||
btn.textContent = t('models.fetchList')
|
||||
|
||||
// 标记已添加的模型
|
||||
const existingIds = (provider.models || []).map(m => typeof m === 'string' ? m : m.id)
|
||||
@@ -1305,16 +1305,16 @@ async function fetchRemoteModels(btn, page, state, providerKey) {
|
||||
overlay.className = 'modal-overlay'
|
||||
overlay.innerHTML = `
|
||||
<div class="modal" style="max-height:80vh;display:flex;flex-direction:column">
|
||||
<div class="modal-title">远程模型列表 — ${providerKey} (${remoteIds.length} 个)</div>
|
||||
<div class="modal-title">${t('models.remoteListTitle', { provider: providerKey, count: remoteIds.length })}</div>
|
||||
<div style="margin-bottom:var(--space-sm);display:flex;gap:8px;align-items:center">
|
||||
<input class="form-input" id="remote-filter" placeholder="搜索模型..." style="flex:1">
|
||||
<button class="btn btn-sm btn-secondary" id="remote-toggle-all">全选</button>
|
||||
<input class="form-input" id="remote-filter" placeholder="${t('models.remoteSearch')}" style="flex:1">
|
||||
<button class="btn btn-sm btn-secondary" id="remote-toggle-all">${t('models.selectAll')}</button>
|
||||
</div>
|
||||
<div id="remote-model-list" style="flex:1;overflow-y:auto;max-height:50vh"></div>
|
||||
<div class="modal-actions" style="margin-top:var(--space-sm)">
|
||||
<span id="remote-selected-count" style="font-size:var(--font-size-xs);color:var(--text-tertiary);flex:1">已选 0 个</span>
|
||||
<button class="btn btn-secondary btn-sm" data-action="cancel">取消</button>
|
||||
<button class="btn btn-primary btn-sm" data-action="confirm">添加选中</button>
|
||||
<span id="remote-selected-count" style="font-size:var(--font-size-xs);color:var(--text-tertiary);flex:1">${t('models.remoteSelected', { count: 0 })}</span>
|
||||
<button class="btn btn-secondary btn-sm" data-action="cancel">${t('common.cancel')}</button>
|
||||
<button class="btn btn-primary btn-sm" data-action="confirm">${t('models.addSelected')}</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
@@ -1334,7 +1334,7 @@ async function fetchRemoteModels(btn, page, state, providerKey) {
|
||||
<label style="display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:var(--radius-sm);cursor:pointer;${exists ? 'opacity:0.5' : ''}">
|
||||
<input type="checkbox" class="remote-cb" data-id="${id}" ${exists ? 'disabled' : ''}>
|
||||
<span style="font-family:var(--font-mono);font-size:var(--font-size-sm)">${id}</span>
|
||||
${exists ? '<span style="font-size:var(--font-size-xs);color:var(--text-tertiary)">(已添加)</span>' : ''}
|
||||
${exists ? `<span style="font-size:var(--font-size-xs);color:var(--text-tertiary)">(${t('models.alreadyAdded')})</span>` : ''}
|
||||
</label>`
|
||||
}).join('')
|
||||
updateCount()
|
||||
@@ -1342,7 +1342,7 @@ async function fetchRemoteModels(btn, page, state, providerKey) {
|
||||
|
||||
function updateCount() {
|
||||
const n = listEl.querySelectorAll('.remote-cb:checked').length
|
||||
countEl.textContent = `已选 ${n} 个`
|
||||
countEl.textContent = t('models.remoteSelected', { count: n })
|
||||
}
|
||||
|
||||
renderRemoteList('')
|
||||
@@ -1360,7 +1360,7 @@ async function fetchRemoteModels(btn, page, state, providerKey) {
|
||||
overlay.querySelector('[data-action="cancel"]').onclick = () => overlay.remove()
|
||||
overlay.querySelector('[data-action="confirm"]').onclick = () => {
|
||||
const selected = [...listEl.querySelectorAll('.remote-cb:checked')].map(cb => cb.dataset.id)
|
||||
if (!selected.length) { toast('请至少选择一个模型', 'warning'); return }
|
||||
if (!selected.length) { toast(t('models.selectAtLeast'), 'warning'); return }
|
||||
pushUndo(state)
|
||||
for (const id of selected) {
|
||||
provider.models.push({ id, input: ['text', 'image'] })
|
||||
@@ -1370,14 +1370,14 @@ async function fetchRemoteModels(btn, page, state, providerKey) {
|
||||
renderDefaultBar(page, state)
|
||||
updateUndoBtn(page, state)
|
||||
autoSave(state)
|
||||
toast(`已添加 ${selected.length} 个模型`, 'success')
|
||||
toast(t('models.qtcoolAdded', { count: selected.length }), 'success')
|
||||
}
|
||||
|
||||
filterInput.focus()
|
||||
} catch (e) {
|
||||
btn.disabled = false
|
||||
btn.textContent = '获取列表'
|
||||
toast(`获取模型列表失败: ${e}`, 'error')
|
||||
btn.textContent = t('models.fetchList')
|
||||
toast(t('models.fetchFailed', { error: e }), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1389,7 +1389,7 @@ async function testModel(btn, state, providerKey, idx) {
|
||||
|
||||
btn.disabled = true
|
||||
const origText = btn.textContent
|
||||
btn.textContent = '测试中...'
|
||||
btn.textContent = t('models.testing')
|
||||
|
||||
const start = Date.now()
|
||||
try {
|
||||
@@ -1414,7 +1414,7 @@ async function testModel(btn, state, providerKey, idx) {
|
||||
toast(`${modelId} ${summary}`, 'warning', { duration: 6000 })
|
||||
}
|
||||
} else {
|
||||
toast(`${modelId} 连通正常 (${(elapsed / 1000).toFixed(1)}s): "${reply.slice(0, 50)}"`, 'success')
|
||||
toast(t('models.testOk', { model: modelId, time: (elapsed / 1000).toFixed(1), reply: reply.slice(0, 50) }), 'success')
|
||||
}
|
||||
} catch (e) {
|
||||
const elapsed = Date.now() - start
|
||||
@@ -1424,7 +1424,7 @@ async function testModel(btn, state, providerKey, idx) {
|
||||
model.testStatus = 'fail'
|
||||
model.testError = String(e).slice(0, 200)
|
||||
}
|
||||
toast(`${modelId} 不可用 (${(elapsed / 1000).toFixed(1)}s): ${e}`, 'error', { duration: 8000 })
|
||||
toast(t('models.testFail', { model: modelId, time: (elapsed / 1000).toFixed(1), error: e }), 'error', { duration: 8000 })
|
||||
} finally {
|
||||
btn.disabled = false
|
||||
btn.textContent = origText
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
import { toast } from '../components/toast.js'
|
||||
import { statusIcon } from '../lib/icons.js'
|
||||
import { t } from '../lib/i18n.js'
|
||||
|
||||
const isTauri = !!window.__TAURI_INTERNALS__
|
||||
let _tauriApi = null
|
||||
@@ -26,10 +27,10 @@ async function apiCall(cmd, args = {}) {
|
||||
return result
|
||||
}
|
||||
if (cmd === 'auth_change_password') {
|
||||
if (cfg.accessPassword && args.oldPassword !== cfg.accessPassword) throw new Error('当前密码错误')
|
||||
if (cfg.accessPassword && args.oldPassword !== cfg.accessPassword) throw new Error(t('security.wrongPassword'))
|
||||
const weakErr = checkPasswordStrengthLocal(args.newPassword)
|
||||
if (weakErr) throw new Error(weakErr)
|
||||
if (args.newPassword === cfg.accessPassword) throw new Error('新密码不能与旧密码相同')
|
||||
if (args.newPassword === cfg.accessPassword) throw new Error(t('security.pwSameAsOld'))
|
||||
cfg.accessPassword = args.newPassword
|
||||
delete cfg.mustChangePassword
|
||||
delete cfg.ignoreRisk
|
||||
@@ -63,27 +64,27 @@ async function apiCall(cmd, args = {}) {
|
||||
}
|
||||
|
||||
function checkPasswordStrengthLocal(pw) {
|
||||
if (!pw || pw.length < 6) return '密码至少 6 位'
|
||||
if (pw.length > 64) return '密码不能超过 64 位'
|
||||
if (/^\d+$/.test(pw)) return '密码不能是纯数字'
|
||||
if (!pw || pw.length < 6) return t('security.pwMin6')
|
||||
if (pw.length > 64) return t('security.pwMax64')
|
||||
if (/^\d+$/.test(pw)) return t('security.pwNoDigitOnly')
|
||||
const weak = ['123456', '654321', 'password', 'admin', 'qwerty', 'abc123', '111111', '000000', 'letmein', 'welcome', 'clawpanel', 'openclaw']
|
||||
if (weak.includes(pw.toLowerCase())) return '密码太常见,请换一个更安全的密码'
|
||||
if (weak.includes(pw.toLowerCase())) return t('security.pwTooCommon')
|
||||
return null
|
||||
}
|
||||
|
||||
function strengthLevel(pw) {
|
||||
if (!pw) return { level: 0, text: '', color: '' }
|
||||
if (pw.length < 6) return { level: 1, text: '太短', color: 'var(--error)' }
|
||||
if (/^\d+$/.test(pw)) return { level: 1, text: '纯数字太弱', color: 'var(--error)' }
|
||||
if (pw.length < 6) return { level: 1, text: t('security.strengthTooShort'), color: 'var(--error)' }
|
||||
if (/^\d+$/.test(pw)) return { level: 1, text: t('security.strengthDigitOnly'), color: 'var(--error)' }
|
||||
let score = 0
|
||||
if (pw.length >= 8) score++
|
||||
if (pw.length >= 12) score++
|
||||
if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) score++
|
||||
if (/\d/.test(pw)) score++
|
||||
if (/[^a-zA-Z0-9]/.test(pw)) score++
|
||||
if (score <= 1) return { level: 2, text: '一般', color: 'var(--warning)' }
|
||||
if (score <= 3) return { level: 3, text: '良好', color: 'var(--primary)' }
|
||||
return { level: 4, text: '强', color: 'var(--success)' }
|
||||
if (score <= 1) return { level: 2, text: t('security.strengthFair'), color: 'var(--warning)' }
|
||||
if (score <= 3) return { level: 3, text: t('security.strengthGood'), color: 'var(--primary)' }
|
||||
return { level: 4, text: t('security.strengthStrong'), color: 'var(--success)' }
|
||||
}
|
||||
|
||||
export async function render() {
|
||||
@@ -91,7 +92,7 @@ export async function render() {
|
||||
page.className = 'page'
|
||||
|
||||
page.innerHTML = `
|
||||
<div class="page-header"><h1>安全设置</h1></div>
|
||||
<div class="page-header"><h1>${t('security.title')}</h1></div>
|
||||
<div id="security-content">
|
||||
<div class="config-section loading-placeholder" style="height:120px"></div>
|
||||
</div>
|
||||
@@ -107,7 +108,7 @@ async function loadStatus(page) {
|
||||
const status = await apiCall('auth_status')
|
||||
renderContent(container, status)
|
||||
} catch (e) {
|
||||
container.innerHTML = `<div class="config-section"><p style="color:var(--error)">加载失败: ${e.message}</p></div>`
|
||||
container.innerHTML = `<div class="config-section"><p style="color:var(--error)">${t('security.loadFailed')}: ${e.message}</p></div>`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,21 +118,21 @@ function renderContent(container, status) {
|
||||
// 当前状态
|
||||
const stateIcon = status.hasPassword ? statusIcon('ok', 20) : statusIcon('warn', 20)
|
||||
const stateText = status.hasPassword
|
||||
? (status.mustChangePassword ? '使用默认密码(需修改)' : '已设置自定义密码')
|
||||
: (status.ignoreRisk ? '无视风险模式(无密码)' : '未设置密码')
|
||||
? (status.mustChangePassword ? t('security.stateDefault') : t('security.stateCustom'))
|
||||
: (status.ignoreRisk ? t('security.stateIgnoreRisk') : t('security.stateNone'))
|
||||
const stateColor = status.hasPassword && !status.mustChangePassword ? 'var(--success)' : 'var(--warning)'
|
||||
|
||||
html += `
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">访问密码状态</div>
|
||||
<div class="config-section-title">${t('security.passwordStatus')}</div>
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:12px 16px;background:var(--bg-tertiary);border-radius:var(--radius-sm);border-left:3px solid ${stateColor}">
|
||||
<span style="font-size:20px">${stateIcon}</span>
|
||||
<div>
|
||||
<div style="font-weight:600;color:var(--text-primary)">${stateText}</div>
|
||||
<div style="font-size:var(--font-size-xs);color:var(--text-tertiary);margin-top:2px">
|
||||
${status.hasPassword
|
||||
? (isTauri ? '每次打开应用需输入密码' : '远程访问需输入密码才能进入面板')
|
||||
: (isTauri ? '任何人打开应用即可使用' : '任何人都可以直接访问面板')}
|
||||
? (isTauri ? t('security.tauriHasPassword') : t('security.webHasPassword'))
|
||||
: (isTauri ? t('security.tauriNoPassword') : t('security.webNoPassword'))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -141,26 +142,26 @@ function renderContent(container, status) {
|
||||
// 修改密码区域
|
||||
html += `
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">${status.hasPassword ? '修改密码' : '设置密码'}</div>
|
||||
<div class="config-section-title">${status.hasPassword ? t('security.changePassword') : t('security.setPassword')}</div>
|
||||
<form id="form-change-pw" style="max-width:400px">
|
||||
${status.hasPassword ? `
|
||||
<div style="margin-bottom:12px">
|
||||
<label style="display:block;font-size:var(--font-size-xs);color:var(--text-tertiary);margin-bottom:4px">当前密码</label>
|
||||
<input type="password" id="sec-old-pw" class="form-input" placeholder="输入当前密码" autocomplete="current-password" style="width:100%"
|
||||
<label style="display:block;font-size:var(--font-size-xs);color:var(--text-tertiary);margin-bottom:4px">${t('security.currentPassword')}</label>
|
||||
<input type="password" id="sec-old-pw" class="form-input" placeholder="${t('security.currentPasswordPlaceholder')}" autocomplete="current-password" style="width:100%"
|
||||
${status.defaultPassword ? `value="${status.defaultPassword}"` : ''}>
|
||||
${status.defaultPassword ? '<div style="font-size:11px;color:var(--text-tertiary);margin-top:4px">已自动填充默认密码,直接设置新密码即可</div>' : ''}
|
||||
${status.defaultPassword ? `<div style="font-size:11px;color:var(--text-tertiary);margin-top:4px">${t('security.defaultFilled')}</div>` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
<div style="margin-bottom:12px">
|
||||
<label style="display:block;font-size:var(--font-size-xs);color:var(--text-tertiary);margin-bottom:4px">新密码</label>
|
||||
<input type="password" id="sec-new-pw" class="form-input" placeholder="至少 6 位,不能纯数字" autocomplete="new-password" style="width:100%">
|
||||
<label style="display:block;font-size:var(--font-size-xs);color:var(--text-tertiary);margin-bottom:4px">${t('security.newPassword')}</label>
|
||||
<input type="password" id="sec-new-pw" class="form-input" placeholder="${t('security.newPasswordPlaceholder')}" autocomplete="new-password" style="width:100%">
|
||||
<div id="pw-strength" style="margin-top:6px;display:flex;align-items:center;gap:8px;min-height:20px"></div>
|
||||
</div>
|
||||
<div style="margin-bottom:16px">
|
||||
<label style="display:block;font-size:var(--font-size-xs);color:var(--text-tertiary);margin-bottom:4px">确认新密码</label>
|
||||
<input type="password" id="sec-confirm-pw" class="form-input" placeholder="再次输入新密码" autocomplete="new-password" style="width:100%">
|
||||
<label style="display:block;font-size:var(--font-size-xs);color:var(--text-tertiary);margin-bottom:4px">${t('security.confirmPassword')}</label>
|
||||
<input type="password" id="sec-confirm-pw" class="form-input" placeholder="${t('security.confirmPasswordPlaceholder')}" autocomplete="new-password" style="width:100%">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">${status.hasPassword ? '确认修改' : '设置密码'}</button>
|
||||
<button type="submit" class="btn btn-primary btn-sm">${status.hasPassword ? t('security.confirmChange') : t('security.setPassword')}</button>
|
||||
<span id="change-pw-msg" style="margin-left:12px;font-size:var(--font-size-xs)"></span>
|
||||
</form>
|
||||
</div>
|
||||
@@ -171,15 +172,15 @@ function renderContent(container, status) {
|
||||
<div class="config-section">
|
||||
<div class="config-section-title" style="display:flex;align-items:center;gap:6px">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
||||
无视风险模式
|
||||
${t('security.ignoreRiskTitle')}
|
||||
</div>
|
||||
<div style="padding:12px 16px;background:${status.ignoreRisk ? 'rgba(239,68,68,0.08)' : 'var(--bg-tertiary)'};border-radius:var(--radius-sm);border:1px solid ${status.ignoreRisk ? 'rgba(239,68,68,0.2)' : 'var(--border-primary)'}">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px">
|
||||
<div>
|
||||
<div style="font-weight:500;color:var(--text-primary)">关闭密码保护</div>
|
||||
<div style="font-weight:500;color:var(--text-primary)">${t('security.ignoreRiskLabel')}</div>
|
||||
<div style="font-size:var(--font-size-xs);color:var(--text-secondary);margin-top:4px;line-height:1.5">
|
||||
开启后任何人都可以直接访问面板,无需输入密码。<br>
|
||||
<strong style="color:var(--error)">仅建议在受信任的内网环境中使用。</strong>
|
||||
${t('security.ignoreRiskDesc')}<br>
|
||||
<strong style="color:var(--error)">${t('security.ignoreRiskWarn')}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
@@ -189,13 +190,13 @@ function renderContent(container, status) {
|
||||
</div>
|
||||
</div>
|
||||
<div id="ignore-risk-confirm" style="display:none;margin-top:12px;padding:12px 16px;background:rgba(239,68,68,0.06);border-radius:var(--radius-sm);border:1px solid rgba(239,68,68,0.15)">
|
||||
<p style="font-size:var(--font-size-sm);color:var(--error);font-weight:600;margin-bottom:8px">确认关闭密码保护?</p>
|
||||
<p style="font-size:var(--font-size-sm);color:var(--error);font-weight:600;margin-bottom:8px">${t('security.ignoreRiskConfirmTitle')}</p>
|
||||
<p style="font-size:var(--font-size-xs);color:var(--text-secondary);margin-bottom:12px;line-height:1.5">
|
||||
关闭后,<strong>任何能访问此服务器 IP 和端口的人</strong>都可以直接进入管理面板,查看和修改你的 AI 配置。
|
||||
${t('security.ignoreRiskConfirmDesc')}
|
||||
</p>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn btn-sm" id="btn-confirm-ignore" style="background:var(--error);color:#fff;border:none">我了解风险,确认关闭</button>
|
||||
<button class="btn btn-secondary btn-sm" id="btn-cancel-ignore">取消</button>
|
||||
<button class="btn btn-sm" id="btn-confirm-ignore" style="background:var(--error);color:#fff;border:none">${t('security.ignoreRiskConfirmBtn')}</button>
|
||||
<button class="btn btn-secondary btn-sm" id="btn-cancel-ignore">${t('common.cancel')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -231,16 +232,16 @@ function bindSecurityEvents(container, status) {
|
||||
const msgEl = container.querySelector('#change-pw-msg')
|
||||
const btn = form.querySelector('button[type="submit"]')
|
||||
|
||||
if (newPw !== confirmPw) { msgEl.textContent = '两次输入的密码不一致'; msgEl.style.color = 'var(--error)'; return }
|
||||
if (newPw !== confirmPw) { msgEl.textContent = t('security.passwordMismatch'); msgEl.style.color = 'var(--error)'; return }
|
||||
|
||||
btn.disabled = true
|
||||
btn.textContent = '提交中...'
|
||||
btn.textContent = t('security.submitting')
|
||||
msgEl.textContent = ''
|
||||
try {
|
||||
await apiCall('auth_change_password', { oldPassword: oldPw, newPassword: newPw })
|
||||
msgEl.textContent = '密码修改成功'
|
||||
msgEl.textContent = t('security.passwordChanged')
|
||||
msgEl.style.color = 'var(--success)'
|
||||
toast('密码已更新', 'success')
|
||||
toast(t('security.passwordUpdated'), 'success')
|
||||
// 清除默认密码横幅
|
||||
sessionStorage.removeItem('clawpanel_must_change_pw')
|
||||
const banner = document.getElementById('pw-change-banner')
|
||||
@@ -250,7 +251,7 @@ function bindSecurityEvents(container, status) {
|
||||
msgEl.textContent = err.message
|
||||
msgEl.style.color = 'var(--error)'
|
||||
btn.disabled = false
|
||||
btn.textContent = status.hasPassword ? '确认修改' : '设置密码'
|
||||
btn.textContent = status.hasPassword ? t('security.confirmChange') : t('security.setPassword')
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -283,12 +284,12 @@ async function handleIgnoreRisk(container, enable) {
|
||||
try {
|
||||
await apiCall('auth_ignore_risk', { enable })
|
||||
if (enable) {
|
||||
toast('已开启无视风险模式,密码保护已关闭', 'warning')
|
||||
toast(t('security.ignoreRiskEnabled'), 'warning')
|
||||
} else {
|
||||
toast('无视风险模式已关闭,请设置新密码', 'info')
|
||||
toast(t('security.ignoreRiskDisabled'), 'info')
|
||||
}
|
||||
setTimeout(() => loadStatus(container.closest('.page')), 500)
|
||||
} catch (e) {
|
||||
toast('操作失败: ' + e.message, 'error')
|
||||
toast(t('security.operationFailed') + ': ' + e.message, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { showConfirm, showUpgradeModal } from '../components/modal.js'
|
||||
import { isMacPlatform, isInDocker, setUpgrading, setUserStopped, resetAutoRestart } from '../lib/app-state.js'
|
||||
import { diagnoseInstallError } from '../lib/error-diagnosis.js'
|
||||
import { icon, statusIcon } from '../lib/icons.js'
|
||||
import { t } from '../lib/i18n.js'
|
||||
|
||||
// HTML 转义,防止 XSS
|
||||
function escapeHtml(str) {
|
||||
@@ -25,27 +26,27 @@ export async function render() {
|
||||
|
||||
page.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">服务管理</h1>
|
||||
<p class="page-desc">管理 OpenClaw 服务、检查更新、配置备份</p>
|
||||
<h1 class="page-title">${t('services.title')}</h1>
|
||||
<p class="page-desc">${t('services.desc')}</p>
|
||||
</div>
|
||||
<div id="version-bar"><div class="stat-card loading-placeholder" style="height:80px;margin-bottom:var(--space-lg)"></div></div>
|
||||
<div id="services-list"><div class="stat-card loading-placeholder" style="height:64px"></div></div>
|
||||
<div class="config-section" id="config-editor-section" style="display:none">
|
||||
<div class="config-section-title">配置文件编辑</div>
|
||||
<div class="form-hint" style="margin-bottom:var(--space-sm)">直接编辑 <code>openclaw.json</code> 主配置文件。保存前会自动创建备份,修改后可能需要重启 Gateway 生效。</div>
|
||||
<div class="config-section-title">${t('services.configEditor')}</div>
|
||||
<div class="form-hint" style="margin-bottom:var(--space-sm)">${t('services.configEditorHint')}</div>
|
||||
<div style="display:flex;gap:8px;margin-bottom:var(--space-sm)">
|
||||
<button class="btn btn-primary btn-sm" data-action="save-config" disabled>保存并重启</button>
|
||||
<button class="btn btn-secondary btn-sm" data-action="save-config-only" disabled>仅保存</button>
|
||||
<button class="btn btn-secondary btn-sm" data-action="reload-config">重新加载</button>
|
||||
<button class="btn btn-primary btn-sm" data-action="save-config" disabled>${t('services.saveAndRestart')}</button>
|
||||
<button class="btn btn-secondary btn-sm" data-action="save-config-only" disabled>${t('services.saveOnly')}</button>
|
||||
<button class="btn btn-secondary btn-sm" data-action="reload-config">${t('services.reloadConfig')}</button>
|
||||
</div>
|
||||
<div id="config-editor-status" style="font-size:var(--font-size-xs);margin-bottom:6px;min-height:18px"></div>
|
||||
<textarea id="config-editor-area" class="form-input" style="font-family:var(--font-mono);font-size:12px;min-height:320px;resize:vertical;tab-size:2;white-space:pre;overflow-x:auto" spellcheck="false" disabled></textarea>
|
||||
</div>
|
||||
<div class="config-section" id="backup-section">
|
||||
<div class="config-section-title">配置备份</div>
|
||||
<div class="form-hint" style="margin-bottom:var(--space-sm)">备份范围:openclaw.json 主配置文件(含模型、Provider、Gateway 设置)。Agent 数据和记忆文件不在此备份范围内。</div>
|
||||
<div class="config-section-title">${t('services.configBackup')}</div>
|
||||
<div class="form-hint" style="margin-bottom:var(--space-sm)">${t('services.configBackupHint')}</div>
|
||||
<div id="backup-actions" style="margin-bottom:var(--space-md)">
|
||||
<button class="btn btn-primary btn-sm" data-action="create-backup">创建备份</button>
|
||||
<button class="btn btn-primary btn-sm" data-action="create-backup">${t('services.createBackup')}</button>
|
||||
</div>
|
||||
<div id="backup-list"><div class="stat-card loading-placeholder" style="height:48px"></div></div>
|
||||
</div>
|
||||
@@ -73,27 +74,27 @@ async function loadVersion(page) {
|
||||
const info = await api.getVersionInfo()
|
||||
lastVersionInfo = info
|
||||
detectedSource = info.source || 'chinese'
|
||||
const ver = info.current || '未知'
|
||||
const ver = info.current || t('common.unknown')
|
||||
const hasRecommended = !!info.recommended
|
||||
const aheadOfRecommended = !!info.current && hasRecommended && !!info.ahead_of_recommended
|
||||
const driftFromRecommended = !!info.current && hasRecommended && !info.is_recommended && !aheadOfRecommended
|
||||
const isChinese = detectedSource === 'chinese'
|
||||
const sourceTag = isChinese ? '汉化优化版' : '官方原版'
|
||||
const switchLabel = isChinese ? '切换到官方版' : '切换到汉化版'
|
||||
const sourceTag = isChinese ? t('services.chineseEdition') : t('services.officialEdition')
|
||||
const switchLabel = isChinese ? t('services.switchToOfficial') : t('services.switchToChinese')
|
||||
const switchTarget = isChinese ? 'official' : 'chinese'
|
||||
const policyNote = aheadOfRecommended
|
||||
? `检测到当前本地版本 ${ver} 高于面板推荐稳定版 ${info.recommended},继续使用可能存在兼容或稳定性风险,建议尽快回退到推荐版。`
|
||||
: '默认只建议当前面板已验证的推荐稳定版。如需尝试其它版本或最新特性,请到「关于」页手动切换版本并自行验证兼容性;若希望面板优先适配最新版,欢迎提交 issue。'
|
||||
? t('services.policyAhead', { ver, recommended: info.recommended })
|
||||
: t('services.policyDefault')
|
||||
|
||||
if (isInDocker()) {
|
||||
bar.innerHTML = `
|
||||
<div class="stat-cards" style="margin-bottom:var(--space-lg)">
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-header">
|
||||
<span class="stat-card-label">当前版本 · <span style="color:var(--accent)">Docker 部署</span></span>
|
||||
<span class="stat-card-label">${t('services.currentVersion')} · <span style="color:var(--accent)">${t('services.dockerDeploy')}</span></span>
|
||||
</div>
|
||||
<div class="stat-card-value">${ver}</div>
|
||||
<div class="stat-card-meta">${info.latest_update_available ? '最新上游: ' + info.latest + '(请拉取新镜像更新)' : '已是当前镜像版本'}</div>
|
||||
<div class="stat-card-meta">${info.latest_update_available ? t('services.latestUpstream', { version: info.latest }) + '(' + t('services.pullNewImage') + ')' : t('services.currentImageVer')}</div>
|
||||
${info.latest_update_available ? `<div style="margin-top:var(--space-sm)">
|
||||
<code style="font-size:var(--font-size-xs);background:var(--bg-tertiary);padding:4px 8px;border-radius:4px;user-select:all">docker pull ghcr.io/qingchencloud/openclaw:latest</code>
|
||||
</div>` : ''}
|
||||
@@ -105,17 +106,17 @@ async function loadVersion(page) {
|
||||
<div class="stat-cards" style="margin-bottom:var(--space-lg)">
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-header">
|
||||
<span class="stat-card-label">当前版本 · <span style="color:var(--accent)">${sourceTag}</span></span>
|
||||
<span class="stat-card-label">${t('services.currentVersion')} · <span style="color:var(--accent)">${sourceTag}</span></span>
|
||||
</div>
|
||||
<div class="stat-card-value">${ver}</div>
|
||||
<div class="stat-card-meta">
|
||||
${hasRecommended
|
||||
? (aheadOfRecommended ? `当前版本高于推荐稳定版: ${info.recommended}` : driftFromRecommended ? `推荐稳定版: ${info.recommended}` : `已对齐推荐稳定版: ${info.recommended}`)
|
||||
: '未获取到推荐稳定版'}
|
||||
${info.latest_update_available && info.latest ? ` · 最新上游: ${info.latest}` : ''}
|
||||
? (aheadOfRecommended ? t('services.aheadOfRecommended', { version: info.recommended }) : driftFromRecommended ? t('services.recommendedStable', { version: info.recommended }) : t('services.alignedRecommended', { version: info.recommended }))
|
||||
: t('services.noRecommended')}
|
||||
${info.latest_update_available && info.latest ? ' · ' + t('services.latestUpstream', { version: info.latest }) : ''}
|
||||
</div>
|
||||
<div style="display:flex;gap:var(--space-sm);margin-top:var(--space-sm);flex-wrap:wrap">
|
||||
${aheadOfRecommended ? '<button class="btn btn-primary btn-sm" data-action="upgrade">回退到推荐版</button>' : driftFromRecommended ? '<button class="btn btn-primary btn-sm" data-action="upgrade">切换到推荐版</button>' : ''}
|
||||
${aheadOfRecommended ? `<button class="btn btn-primary btn-sm" data-action="upgrade">${t('services.rollbackToRecommended')}</button>` : driftFromRecommended ? `<button class="btn btn-primary btn-sm" data-action="upgrade">${t('services.switchToRecommended')}</button>` : ''}
|
||||
<button class="btn btn-secondary btn-sm" data-action="switch-source" data-source="${switchTarget}">${switchLabel}</button>
|
||||
</div>
|
||||
<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary);line-height:1.6">
|
||||
@@ -126,7 +127,7 @@ async function loadVersion(page) {
|
||||
`
|
||||
}
|
||||
} catch (e) {
|
||||
bar.innerHTML = `<div class="stat-card" style="margin-bottom:var(--space-lg)"><div class="stat-card-label">版本信息加载失败</div></div>`
|
||||
bar.innerHTML = `<div class="stat-card" style="margin-bottom:var(--space-lg)"><div class="stat-card-label">${t('services.versionLoadFailed')}</div></div>`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +139,7 @@ async function loadServices(page) {
|
||||
const services = await api.getServicesStatus()
|
||||
renderServices(container, services)
|
||||
} catch (e) {
|
||||
container.innerHTML = `<div style="color:var(--error)">加载服务列表失败: ${escapeHtml(String(e))}</div>`
|
||||
container.innerHTML = `<div style="color:var(--error)">${t('services.serviceLoadFailed')}: ${escapeHtml(String(e))}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,7 +158,7 @@ function renderServices(container, services) {
|
||||
<div>
|
||||
<div class="service-name">${gw.label}</div>
|
||||
<div class="service-desc">${cliMissing
|
||||
? 'OpenClaw CLI 未安装'
|
||||
? t('services.cliNotInstalled')
|
||||
: (gw.description || '') + (gw.pid ? ' (PID: ' + gw.pid + ')' : '')
|
||||
}</div>
|
||||
</div>
|
||||
@@ -165,16 +166,16 @@ function renderServices(container, services) {
|
||||
<div class="service-actions">
|
||||
${cliMissing
|
||||
? `<div style="display:flex;flex-direction:column;gap:var(--space-xs);align-items:flex-end">
|
||||
<div style="color:var(--text-tertiary);font-size:var(--font-size-xs)">请先安装 OpenClaw CLI:</div>
|
||||
<div style="color:var(--text-tertiary);font-size:var(--font-size-xs)">${t('services.installCliHint')}</div>
|
||||
<code style="font-size:var(--font-size-xs);background:var(--bg-tertiary);padding:2px 8px;border-radius:4px;user-select:all">npm install -g @qingchencloud/openclaw-zh</code>
|
||||
<button class="btn btn-secondary btn-sm" data-action="refresh-services" style="margin-top:4px">刷新状态</button>
|
||||
<button class="btn btn-secondary btn-sm" data-action="refresh-services" style="margin-top:4px">${t('services.refreshStatus')}</button>
|
||||
</div>`
|
||||
: gw.running
|
||||
? `<button class="btn btn-secondary btn-sm" data-action="restart" data-label="${gw.label}">重启</button>
|
||||
<button class="btn btn-danger btn-sm" data-action="stop" data-label="${gw.label}">停止</button>
|
||||
${isMacPlatform() ? '<button class="btn btn-danger btn-sm" data-action="uninstall-gateway">卸载</button>' : ''}`
|
||||
: `<button class="btn btn-primary btn-sm" data-action="start" data-label="${gw.label}">启动</button>
|
||||
${isMacPlatform() ? '<button class="btn btn-primary btn-sm" data-action="install-gateway">安装</button><button class="btn btn-danger btn-sm" data-action="uninstall-gateway">卸载</button>' : ''}`
|
||||
? `<button class="btn btn-secondary btn-sm" data-action="restart" data-label="${gw.label}">${t('services.restart')}</button>
|
||||
<button class="btn btn-danger btn-sm" data-action="stop" data-label="${gw.label}">${t('services.stop')}</button>
|
||||
${isMacPlatform() ? `<button class="btn btn-danger btn-sm" data-action="uninstall-gateway">${t('services.uninstall')}</button>` : ''}`
|
||||
: `<button class="btn btn-primary btn-sm" data-action="start" data-label="${gw.label}">${t('services.start')}</button>
|
||||
${isMacPlatform() ? `<button class="btn btn-primary btn-sm" data-action="install-gateway">${t('services.install')}</button><button class="btn btn-danger btn-sm" data-action="uninstall-gateway">${t('services.uninstall')}</button>` : ''}`
|
||||
}
|
||||
</div>
|
||||
</div>`
|
||||
@@ -185,11 +186,11 @@ function renderServices(container, services) {
|
||||
<span class="status-dot stopped"></span>
|
||||
<div>
|
||||
<div class="service-name">ai.openclaw.gateway</div>
|
||||
<div class="service-desc">Gateway 服务未安装</div>
|
||||
<div class="service-desc">${t('services.gwNotInstalled')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="service-actions">
|
||||
<button class="btn btn-primary btn-sm" data-action="install-gateway">安装</button>
|
||||
<button class="btn btn-primary btn-sm" data-action="install-gateway">${t('services.install')}</button>
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
@@ -205,17 +206,17 @@ async function loadBackups(page) {
|
||||
const backups = await api.listBackups()
|
||||
renderBackups(list, backups)
|
||||
} catch (e) {
|
||||
list.innerHTML = `<div style="color:var(--error)">加载备份列表失败: ${e}</div>`
|
||||
list.innerHTML = `<div style="color:var(--error)">${t('services.backupLoadFailed')}: ${e}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
function renderBackups(container, backups) {
|
||||
if (!backups || !backups.length) {
|
||||
container.innerHTML = '<div style="color:var(--text-tertiary);padding:var(--space-md) 0">暂无备份</div>'
|
||||
container.innerHTML = `<div style="color:var(--text-tertiary);padding:var(--space-md) 0">${t('services.noBackup')}</div>`
|
||||
return
|
||||
}
|
||||
container.innerHTML = backups.map(b => {
|
||||
const date = b.created_at ? new Date(b.created_at * 1000).toLocaleString('zh-CN') : '未知'
|
||||
const date = b.created_at ? new Date(b.created_at * 1000).toLocaleString() : t('common.unknown')
|
||||
const size = b.size ? (b.size / 1024).toFixed(1) + ' KB' : ''
|
||||
return `
|
||||
<div class="service-card" data-backup="${b.name}">
|
||||
@@ -226,8 +227,8 @@ function renderBackups(container, backups) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="service-actions">
|
||||
<button class="btn btn-primary btn-sm" data-action="restore-backup" data-name="${b.name}">恢复</button>
|
||||
<button class="btn btn-danger btn-sm" data-action="delete-backup" data-name="${b.name}">删除</button>
|
||||
<button class="btn btn-primary btn-sm" data-action="restore-backup" data-name="${b.name}">${t('services.restore')}</button>
|
||||
<button class="btn btn-danger btn-sm" data-action="delete-backup" data-name="${b.name}">${t('common.delete')}</button>
|
||||
</div>
|
||||
</div>`
|
||||
}).join('')
|
||||
@@ -293,7 +294,7 @@ function bindEvents(page) {
|
||||
|
||||
// ===== 服务操作 =====
|
||||
|
||||
const ACTION_LABELS = { start: '启动', stop: '停止', restart: '重启' }
|
||||
const ACTION_LABELS = { start: t('services.start'), stop: t('services.stop'), restart: t('services.restart') }
|
||||
const POLL_INTERVAL = 1500 // 轮询间隔 ms
|
||||
const POLL_TIMEOUT = 30000 // 最长等待 30s
|
||||
|
||||
@@ -316,8 +317,8 @@ async function handleServiceAction(action, label, page) {
|
||||
actionsEl.innerHTML = `
|
||||
<div class="service-loading">
|
||||
<div class="service-spinner"></div>
|
||||
<span class="service-loading-text">正在${actionLabel}...</span>
|
||||
<button class="btn btn-sm btn-ghost service-cancel-btn" style="display:none">取消等待</button>
|
||||
<span class="service-loading-text">${t('services.actionProgress', { action: actionLabel })}</span>
|
||||
<button class="btn btn-sm btn-ghost service-cancel-btn" style="display:none">${t('services.cancelWait')}</button>
|
||||
</div>`
|
||||
const cancelBtn = actionsEl.querySelector('.service-cancel-btn')
|
||||
if (cancelBtn) {
|
||||
@@ -332,7 +333,7 @@ async function handleServiceAction(action, label, page) {
|
||||
try {
|
||||
await fn(label)
|
||||
} catch (e) {
|
||||
toast(`${actionLabel}命令失败: ${e.message || e}`, 'error')
|
||||
toast(t('services.actionCmdFailed', { action: actionLabel, error: e.message || e }), 'error')
|
||||
if (actionsEl) actionsEl.innerHTML = origHtml
|
||||
if (dot) dot.className = 'status-dot stopped'
|
||||
return
|
||||
@@ -356,12 +357,12 @@ async function handleServiceAction(action, label, page) {
|
||||
// 更新等待时间
|
||||
if (loadingText) {
|
||||
const sec = Math.floor(elapsed / 1000)
|
||||
loadingText.textContent = `正在${actionLabel}... ${sec}s`
|
||||
loadingText.textContent = t('services.actionProgressSec', { action: actionLabel, sec })
|
||||
}
|
||||
|
||||
// 超时
|
||||
if (elapsed > POLL_TIMEOUT) {
|
||||
toast(`${actionLabel}超时,Gateway 可能仍在启动中`, 'warning')
|
||||
toast(t('services.actionTimeout', { action: actionLabel }), 'warning')
|
||||
break
|
||||
}
|
||||
|
||||
@@ -370,7 +371,7 @@ async function handleServiceAction(action, label, page) {
|
||||
const services = await api.getServicesStatus()
|
||||
const svc = services?.find?.(s => s.label === label) || services?.[0]
|
||||
if (svc && svc.running === expectRunning) {
|
||||
toast(`${label} 已${actionLabel}${svc.pid ? ' (PID: ' + svc.pid + ')' : ''}`, 'success')
|
||||
toast(t('services.actionDone', { label, action: actionLabel }) + (svc.pid ? ' (PID: ' + svc.pid + ')' : ''), 'success')
|
||||
await loadServices(page)
|
||||
return
|
||||
}
|
||||
@@ -380,7 +381,7 @@ async function handleServiceAction(action, label, page) {
|
||||
}
|
||||
|
||||
if (cancelled) {
|
||||
toast('已取消等待,可稍后刷新查看状态', 'info')
|
||||
toast(t('services.cancelled'), 'info')
|
||||
}
|
||||
await loadServices(page)
|
||||
}
|
||||
@@ -389,23 +390,23 @@ async function handleServiceAction(action, label, page) {
|
||||
|
||||
async function handleCreateBackup(page) {
|
||||
const result = await api.createBackup()
|
||||
toast(`备份已创建: ${result.name}`, 'success')
|
||||
toast(t('services.backupCreated', { name: result.name }), 'success')
|
||||
await loadBackups(page)
|
||||
}
|
||||
|
||||
async function handleRestoreBackup(name, page) {
|
||||
const yes = await showConfirm(`确定要恢复备份 "${name}" 吗?\n当前配置将自动备份后再恢复。`)
|
||||
const yes = await showConfirm(t('services.restoreConfirm', { name }))
|
||||
if (!yes) return
|
||||
await api.restoreBackup(name)
|
||||
toast('配置已恢复', 'success')
|
||||
toast(t('services.restored'), 'success')
|
||||
await loadBackups(page)
|
||||
}
|
||||
|
||||
async function handleDeleteBackup(name, page) {
|
||||
const yes = await showConfirm(`确定要删除备份 "${name}" 吗?此操作不可撤销。`)
|
||||
const yes = await showConfirm(t('services.deleteConfirm', { name }))
|
||||
if (!yes) return
|
||||
await api.deleteBackup(name)
|
||||
toast('备份已删除', 'success')
|
||||
toast(t('services.backupDeleted'), 'success')
|
||||
await loadBackups(page)
|
||||
}
|
||||
|
||||
@@ -429,7 +430,7 @@ async function loadConfigEditor(page) {
|
||||
btnSave.disabled = false
|
||||
btnSaveOnly.disabled = false
|
||||
section.style.display = ''
|
||||
status.innerHTML = `<span style="color:var(--text-tertiary)">已加载 · ${(json.length / 1024).toFixed(1)} KB</span>`
|
||||
status.innerHTML = `<span style="color:var(--text-tertiary)">${t('services.configLoaded')} · ${(json.length / 1024).toFixed(1)} KB</span>`
|
||||
|
||||
// 实时检测 JSON 语法
|
||||
area.oninput = () => {
|
||||
@@ -437,12 +438,12 @@ async function loadConfigEditor(page) {
|
||||
JSON.parse(area.value)
|
||||
const changed = area.value !== _configOriginal
|
||||
status.innerHTML = changed
|
||||
? '<span style="color:var(--warning)">● 有未保存的修改</span>'
|
||||
: '<span style="color:var(--text-tertiary)">无修改</span>'
|
||||
? `<span style="color:var(--warning)">● ${t('services.configUnsaved')}</span>`
|
||||
: `<span style="color:var(--text-tertiary)">${t('services.configNoChange')}</span>`
|
||||
btnSave.disabled = !changed
|
||||
btnSaveOnly.disabled = !changed
|
||||
} catch (e) {
|
||||
status.innerHTML = `<span style="color:var(--error)">JSON 语法错误: ${e.message.split(' at ')[0]}</span>`
|
||||
status.innerHTML = `<span style="color:var(--error)">${t('services.configJsonError')}: ${e.message.split(' at ')[0]}</span>`
|
||||
btnSave.disabled = true
|
||||
btnSaveOnly.disabled = true
|
||||
}
|
||||
@@ -461,27 +462,27 @@ async function handleSaveConfig(page, restart) {
|
||||
try {
|
||||
config = JSON.parse(area.value)
|
||||
} catch (e) {
|
||||
toast('JSON 格式错误,无法保存', 'error')
|
||||
toast(t('services.configSaveJsonError'), 'error')
|
||||
return
|
||||
}
|
||||
|
||||
status.innerHTML = '<span style="color:var(--text-tertiary)">自动备份中...</span>'
|
||||
status.innerHTML = `<span style="color:var(--text-tertiary)">${t('services.autoBackingUp')}</span>`
|
||||
|
||||
try {
|
||||
// 保存前自动备份
|
||||
await api.createBackup()
|
||||
} catch (e) {
|
||||
const yes = await showConfirm('自动备份失败: ' + e + '\n\n是否仍然继续保存?')
|
||||
const yes = await showConfirm(t('services.autoBackupFailed') + ': ' + e + '\n\n' + t('services.continueWithoutBackup'))
|
||||
if (!yes) return
|
||||
}
|
||||
|
||||
status.innerHTML = '<span style="color:var(--text-tertiary)">保存中...</span>'
|
||||
status.innerHTML = `<span style="color:var(--text-tertiary)">${t('services.saving')}</span>`
|
||||
|
||||
try {
|
||||
await api.writeOpenclawConfig(config)
|
||||
_configOriginal = area.value
|
||||
toast('配置已保存' + (restart ? ',正在重启 Gateway...' : ''), 'success')
|
||||
status.innerHTML = '<span style="color:var(--success)">已保存</span>'
|
||||
toast(restart ? t('services.configSavedRestarting') : t('services.configSaved'), 'success')
|
||||
status.innerHTML = `<span style="color:var(--success)">${t('services.configSaved')}</span>`
|
||||
|
||||
page.querySelector('[data-action="save-config"]').disabled = true
|
||||
page.querySelector('[data-action="save-config-only"]').disabled = true
|
||||
@@ -489,24 +490,24 @@ async function handleSaveConfig(page, restart) {
|
||||
if (restart) {
|
||||
try {
|
||||
await api.restartGateway()
|
||||
toast('Gateway 已重启', 'success')
|
||||
toast(t('services.gwRestarted'), 'success')
|
||||
} catch (e) {
|
||||
toast('配置已保存,但 Gateway 重启失败: ' + e, 'warning')
|
||||
toast(t('services.configSavedGwFailed') + ': ' + e, 'warning')
|
||||
}
|
||||
await loadServices(page)
|
||||
}
|
||||
|
||||
await loadBackups(page)
|
||||
} catch (e) {
|
||||
toast('保存失败: ' + e, 'error')
|
||||
status.innerHTML = `<span style="color:var(--error)">保存失败: ${e}</span>`
|
||||
toast(t('common.saveFailed') + ': ' + e, 'error')
|
||||
status.innerHTML = `<span style="color:var(--error)">${t('common.saveFailed')}: ${e}</span>`
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 升级操作 =====
|
||||
|
||||
async function doUpgradeWithModal(source, page, version = null, method = 'auto') {
|
||||
const modal = showUpgradeModal('升级 / 切换版本')
|
||||
const modal = showUpgradeModal(t('services.upgradeTitle'))
|
||||
let unlistenLog, unlistenProgress, unlistenDone, unlistenError
|
||||
setUpgrading(true)
|
||||
|
||||
@@ -528,14 +529,14 @@ async function doUpgradeWithModal(source, page, version = null, method = 'auto')
|
||||
// 后台任务完成事件
|
||||
unlistenDone = await listen('upgrade-done', (e) => {
|
||||
cleanup()
|
||||
modal.setDone(typeof e.payload === 'string' ? e.payload : '操作完成')
|
||||
modal.setDone(typeof e.payload === 'string' ? e.payload : t('services.taskDone'))
|
||||
loadVersion(page)
|
||||
})
|
||||
|
||||
// 后台任务失败事件
|
||||
unlistenError = await listen('upgrade-error', (e) => {
|
||||
cleanup()
|
||||
const errStr = String(e.payload || '未知错误')
|
||||
const errStr = String(e.payload || t('common.error'))
|
||||
modal.appendLog(errStr)
|
||||
const fullLog = modal.getLogText() + '\n' + errStr
|
||||
const diagnosis = diagnoseInstallError(fullLog)
|
||||
@@ -544,18 +545,18 @@ async function doUpgradeWithModal(source, page, version = null, method = 'auto')
|
||||
if (diagnosis.hint) modal.appendHtmlLog(`${statusIcon('info', 14)} ${diagnosis.hint}`)
|
||||
if (diagnosis.command) modal.appendHtmlLog(`${icon('clipboard', 14)} ${diagnosis.command}`)
|
||||
if (window.__openAIDrawerWithError) {
|
||||
window.__openAIDrawerWithError({ title: diagnosis.title, error: fullLog, scene: '升级 OpenClaw', hint: diagnosis.hint })
|
||||
window.__openAIDrawerWithError({ title: diagnosis.title, error: fullLog, scene: t('services.upgradeScene'), hint: diagnosis.hint })
|
||||
}
|
||||
})
|
||||
|
||||
// 发起后台任务(立即返回)
|
||||
await api.upgradeOpenclaw(source, version, method)
|
||||
modal.appendLog('后台任务已启动,请等待完成...')
|
||||
modal.appendLog(t('services.taskStarted'))
|
||||
} else {
|
||||
// Web 模式:仍然同步等待(dev-api 后端没有 spawn)
|
||||
modal.appendLog('Web 模式:升级过程日志不可用,请等待完成...')
|
||||
modal.appendLog(t('services.webModeNoLog'))
|
||||
const msg = await api.upgradeOpenclaw(source, version, method)
|
||||
modal.setDone(typeof msg === 'string' ? msg : (msg?.message || '升级完成'))
|
||||
modal.setDone(typeof msg === 'string' ? msg : (msg?.message || t('services.upgradeDone')))
|
||||
await loadVersion(page)
|
||||
cleanup()
|
||||
}
|
||||
@@ -570,19 +571,19 @@ async function doUpgradeWithModal(source, page, version = null, method = 'auto')
|
||||
}
|
||||
|
||||
async function handleUpgrade(btn, page) {
|
||||
const sourceLabel = detectedSource === 'official' ? '官方原版' : '汉化优化版'
|
||||
const sourceLabel = detectedSource === 'official' ? t('services.officialEdition') : t('services.chineseEdition')
|
||||
const recommended = lastVersionInfo?.recommended
|
||||
const yes = await showConfirm(`确定要将 OpenClaw 切换到当前面板推荐的稳定${sourceLabel}${recommended ? `(${recommended})` : ''}吗?\n切换过程中 Gateway 会短暂中断。\n如果你想尝试最新版,请到「关于」页手动切换版本并自测兼容性。`)
|
||||
const yes = await showConfirm(t('services.upgradeConfirm', { source: sourceLabel, version: recommended ? `(${recommended})` : '' }))
|
||||
if (!yes) return
|
||||
await doUpgradeWithModal(detectedSource, page, recommended || null)
|
||||
}
|
||||
|
||||
async function handleSwitchSource(target, page) {
|
||||
const targetLabel = target === 'official' ? '官方原版' : '汉化优化版'
|
||||
const targetLabel = target === 'official' ? t('services.officialEdition') : t('services.chineseEdition')
|
||||
const recommended = target === 'official'
|
||||
? (lastVersionInfo?.source === 'official' ? lastVersionInfo?.recommended : null)
|
||||
: (lastVersionInfo?.source === 'chinese' ? lastVersionInfo?.recommended : null)
|
||||
const yes = await showConfirm(`确定要切换到${targetLabel}${recommended ? `(推荐稳定版 ${recommended})` : '(将自动选择该来源的推荐稳定版)'}吗?\n这会安装对应的 npm 包,配置数据不受影响。\n如需尝试最新版,请到「关于」页手动切换版本。`)
|
||||
const yes = await showConfirm(t('services.switchSourceConfirm', { target: targetLabel, version: recommended ? `(${recommended})` : '' }))
|
||||
if (!yes) return
|
||||
await doUpgradeWithModal(target, page, null)
|
||||
}
|
||||
@@ -591,30 +592,30 @@ async function handleSwitchSource(target, page) {
|
||||
|
||||
async function handleInstallGateway(btn, page) {
|
||||
btn.classList.add('btn-loading')
|
||||
btn.textContent = '安装中...'
|
||||
btn.textContent = t('services.installing')
|
||||
try {
|
||||
await api.installGateway()
|
||||
toast('Gateway 服务已安装', 'success')
|
||||
toast(t('services.gwInstalled'), 'success')
|
||||
await loadServices(page)
|
||||
} catch (e) {
|
||||
toast('安装失败: ' + e, 'error')
|
||||
toast(t('services.installFailed') + ': ' + e, 'error')
|
||||
btn.classList.remove('btn-loading')
|
||||
btn.textContent = '安装'
|
||||
btn.textContent = t('services.install')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUninstallGateway(btn, page) {
|
||||
const yes = await showConfirm('确定要卸载 Gateway 服务吗?\n这会停止服务并移除 LaunchAgent。')
|
||||
const yes = await showConfirm(t('services.uninstallConfirm'))
|
||||
if (!yes) return
|
||||
btn.classList.add('btn-loading')
|
||||
btn.textContent = '卸载中...'
|
||||
btn.textContent = t('services.uninstalling')
|
||||
try {
|
||||
await api.uninstallGateway()
|
||||
toast('Gateway 服务已卸载', 'success')
|
||||
toast(t('services.gwUninstalled'), 'success')
|
||||
await loadServices(page)
|
||||
} catch (e) {
|
||||
toast('卸载失败: ' + e, 'error')
|
||||
toast(t('services.uninstallFailed') + ': ' + e, 'error')
|
||||
btn.classList.remove('btn-loading')
|
||||
btn.textContent = '卸载'
|
||||
btn.textContent = t('services.uninstall')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,9 @@ function escapeHtml(str) {
|
||||
}
|
||||
|
||||
const REGISTRIES = [
|
||||
{ label: '淘宝镜像 (推荐)', value: 'https://registry.npmmirror.com' },
|
||||
{ label: 'npm 官方源', value: 'https://registry.npmjs.org' },
|
||||
{ label: '华为云镜像', value: 'https://repo.huaweicloud.com/repository/npm/' },
|
||||
{ label: () => t('settings.registryTaobao'), value: 'https://registry.npmmirror.com' },
|
||||
{ label: () => t('settings.registryNpm'), value: 'https://registry.npmjs.org' },
|
||||
{ label: () => t('settings.registryHuawei'), value: 'https://repo.huaweicloud.com/repository/npm/' },
|
||||
]
|
||||
|
||||
export async function render() {
|
||||
@@ -27,22 +27,22 @@ export async function render() {
|
||||
|
||||
page.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">面板设置</h1>
|
||||
<p class="page-desc">管理 ClawPanel 的网络、代理和下载源配置</p>
|
||||
<h1 class="page-title">${t('settings.title')}</h1>
|
||||
<p class="page-desc">${t('settings.desc')}</p>
|
||||
</div>
|
||||
|
||||
<div class="config-section" id="proxy-section">
|
||||
<div class="config-section-title">网络代理</div>
|
||||
<div class="config-section-title">${t('settings.networkProxy')}</div>
|
||||
<div id="proxy-bar"><div class="stat-card loading-placeholder" style="height:48px"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="config-section" id="model-proxy-section">
|
||||
<div class="config-section-title">模型请求代理</div>
|
||||
<div class="config-section-title">${t('settings.modelProxy')}</div>
|
||||
<div id="model-proxy-bar"><div class="stat-card loading-placeholder" style="height:48px"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="config-section" id="registry-section">
|
||||
<div class="config-section-title">npm 源设置</div>
|
||||
<div class="config-section-title">${t('settings.npmRegistry')}</div>
|
||||
<div id="registry-bar"><div class="stat-card loading-placeholder" style="height:48px"></div></div>
|
||||
</div>
|
||||
|
||||
@@ -86,17 +86,17 @@ async function loadProxyConfig(page) {
|
||||
bar.innerHTML = `
|
||||
<div style="display:flex;align-items:center;gap:var(--space-sm);flex-wrap:wrap">
|
||||
<input class="form-input" data-name="proxy-url" placeholder="http://127.0.0.1:7897" value="${escapeHtml(proxyUrl)}" style="max-width:360px">
|
||||
<button class="btn btn-primary btn-sm" data-action="save-proxy">保存</button>
|
||||
<button class="btn btn-secondary btn-sm" data-action="test-proxy" ${proxyUrl ? '' : 'disabled'}>测试连通</button>
|
||||
<button class="btn btn-secondary btn-sm" data-action="clear-proxy" ${proxyUrl ? '' : 'disabled'}>关闭代理</button>
|
||||
<button class="btn btn-primary btn-sm" data-action="save-proxy">${t('common.save')}</button>
|
||||
<button class="btn btn-secondary btn-sm" data-action="test-proxy" ${proxyUrl ? '' : 'disabled'}>${t('settings.testProxy')}</button>
|
||||
<button class="btn btn-secondary btn-sm" data-action="clear-proxy" ${proxyUrl ? '' : 'disabled'}>${t('settings.clearProxy')}</button>
|
||||
</div>
|
||||
<div id="proxy-test-result" style="margin-top:var(--space-xs);font-size:var(--font-size-xs);min-height:20px"></div>
|
||||
<div class="form-hint" style="margin-top:var(--space-xs)">
|
||||
设置后,npm 安装/升级、版本检测、GitHub/Gitee 更新检查、ClawHub Skills 等下载类操作会走此代理。自动绕过 localhost 和内网地址。保存后新请求立即生效;如 Gateway 正在运行,建议重启一次服务。
|
||||
${t('settings.proxyHint')}
|
||||
</div>
|
||||
`
|
||||
} catch (e) {
|
||||
bar.innerHTML = `<div style="color:var(--error)">加载失败: ${escapeHtml(String(e))}</div>`
|
||||
bar.innerHTML = `<div style="color:var(--error)">${t('common.loadFailed')}: ${escapeHtml(String(e))}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,19 +115,19 @@ async function loadModelProxyConfig(page) {
|
||||
<div style="display:flex;align-items:center;gap:var(--space-sm);flex-wrap:wrap">
|
||||
<label style="display:flex;align-items:center;gap:6px;font-size:var(--font-size-sm);cursor:pointer">
|
||||
<input type="checkbox" data-name="model-proxy-toggle" ${modelProxy ? 'checked' : ''} ${hasProxy ? '' : 'disabled'}>
|
||||
模型测试和模型列表请求也走代理
|
||||
${t('settings.modelProxyToggle')}
|
||||
</label>
|
||||
<button class="btn btn-primary btn-sm" data-action="save-model-proxy">保存</button>
|
||||
<button class="btn btn-primary btn-sm" data-action="save-model-proxy">${t('common.save')}</button>
|
||||
</div>
|
||||
<div class="form-hint" style="margin-top:var(--space-xs)">
|
||||
${hasProxy
|
||||
? '默认关闭。部分用户的模型 API 地址本身就是国内中转或内网地址,走代理反而会连接失败。只有当你的模型服务商需要翻墙访问时才建议开启。'
|
||||
: '请先在上方设置网络代理地址后,才能启用此选项。'
|
||||
? t('settings.modelProxyHint')
|
||||
: t('settings.modelProxyNoProxy')
|
||||
}
|
||||
</div>
|
||||
`
|
||||
} catch (e) {
|
||||
bar.innerHTML = `<div style="color:var(--error)">加载失败: ${escapeHtml(String(e))}</div>`
|
||||
bar.innerHTML = `<div style="color:var(--error)">${t('common.loadFailed')}: ${escapeHtml(String(e))}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,13 +141,13 @@ async function loadRegistry(page) {
|
||||
bar.innerHTML = `
|
||||
<div style="display:flex;align-items:center;gap:var(--space-sm);flex-wrap:wrap">
|
||||
<select class="form-input" data-name="registry" style="max-width:320px">
|
||||
${REGISTRIES.map(r => `<option value="${r.value}" ${r.value === current ? 'selected' : ''}>${r.label}</option>`).join('')}
|
||||
<option value="custom" ${!isPreset ? 'selected' : ''}>自定义</option>
|
||||
${REGISTRIES.map(r => `<option value="${r.value}" ${r.value === current ? 'selected' : ''}>${typeof r.label === 'function' ? r.label() : r.label}</option>`).join('')}
|
||||
<option value="custom" ${!isPreset ? 'selected' : ''}>${t('settings.registryCustom')}</option>
|
||||
</select>
|
||||
<input class="form-input" data-name="custom-registry" placeholder="https://..." value="${isPreset ? '' : escapeHtml(current)}" style="max-width:320px;${isPreset ? 'display:none' : ''}">
|
||||
<button class="btn btn-primary btn-sm" data-action="save-registry">保存</button>
|
||||
<button class="btn btn-primary btn-sm" data-action="save-registry">${t('common.save')}</button>
|
||||
</div>
|
||||
<div class="form-hint" style="margin-top:var(--space-xs)">升级和版本检测使用此源下载 npm 包,国内用户推荐淘宝镜像</div>
|
||||
<div class="form-hint" style="margin-top:var(--space-xs)">${t('settings.registryHint')}</div>
|
||||
`
|
||||
const select = bar.querySelector('[data-name="registry"]')
|
||||
const customInput = bar.querySelector('[data-name="custom-registry"]')
|
||||
@@ -155,7 +155,7 @@ async function loadRegistry(page) {
|
||||
customInput.style.display = select.value === 'custom' ? '' : 'none'
|
||||
}
|
||||
} catch (e) {
|
||||
bar.innerHTML = `<div style="color:var(--error)">加载失败: ${escapeHtml(String(e))}</div>`
|
||||
bar.innerHTML = `<div style="color:var(--error)">${t('common.loadFailed')}: ${escapeHtml(String(e))}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,26 +169,26 @@ async function loadOpenclawDir(page) {
|
||||
const cfg = await api.readPanelConfig()
|
||||
const customValue = cfg?.openclawDir || ''
|
||||
const statusText = info.configExists
|
||||
? '<span style="color:var(--success)">配置文件存在</span>'
|
||||
: '<span style="color:var(--warning)">配置文件不存在</span>'
|
||||
? `<span style="color:var(--success)">${t('settings.configExists')}</span>`
|
||||
: `<span style="color:var(--warning)">${t('settings.configMissing')}</span>`
|
||||
bar.innerHTML = `
|
||||
<div style="margin-bottom:var(--space-xs)">
|
||||
<span class="form-hint">当前路径:</span>
|
||||
<span class="form-hint">${t('settings.currentPath')}:</span>
|
||||
<strong style="font-size:var(--font-size-sm)">${escapeHtml(info.path)}</strong>
|
||||
<span style="margin-left:var(--space-xs);font-size:var(--font-size-xs)">${statusText}</span>
|
||||
${info.isCustom ? '<span class="clawhub-badge" style="margin-left:var(--space-xs);background:rgba(99,102,241,0.14);color:#6366f1;font-size:var(--font-size-xs)">自定义</span>' : ''}
|
||||
${info.isCustom ? `<span class="clawhub-badge" style="margin-left:var(--space-xs);background:rgba(99,102,241,0.14);color:#6366f1;font-size:var(--font-size-xs)">${t('settings.customBadge')}</span>` : ''}
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:var(--space-sm);flex-wrap:wrap">
|
||||
<input class="form-input" data-name="openclaw-dir" placeholder="留空使用默认路径 ~/.openclaw" value="${escapeHtml(customValue)}" style="max-width:420px">
|
||||
<button class="btn btn-primary btn-sm" data-action="save-openclaw-dir">保存</button>
|
||||
${info.isCustom ? '<button class="btn btn-secondary btn-sm" data-action="reset-openclaw-dir">恢复默认</button>' : ''}
|
||||
<input class="form-input" data-name="openclaw-dir" placeholder="${t('settings.dirPlaceholder')}" value="${escapeHtml(customValue)}" style="max-width:420px">
|
||||
<button class="btn btn-primary btn-sm" data-action="save-openclaw-dir">${t('common.save')}</button>
|
||||
${info.isCustom ? `<button class="btn btn-secondary btn-sm" data-action="reset-openclaw-dir">${t('settings.resetDefault')}</button>` : ''}
|
||||
</div>
|
||||
<div class="form-hint" style="margin-top:var(--space-xs)">
|
||||
自定义 OpenClaw 配置目录路径。修改后需要重启面板生效。目标目录必须存在且包含 <code>openclaw.json</code>。
|
||||
${t('settings.dirHint')}
|
||||
</div>
|
||||
`
|
||||
} catch (e) {
|
||||
bar.innerHTML = `<div style="color:var(--error)">加载失败: ${escapeHtml(String(e))}</div>`
|
||||
bar.innerHTML = `<div style="color:var(--error)">${t('common.loadFailed')}: ${escapeHtml(String(e))}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,7 +203,7 @@ async function handleSaveOpenclawDir(page) {
|
||||
}
|
||||
await api.writePanelConfig(cfg)
|
||||
await loadOpenclawDir(page)
|
||||
await promptRestart(value ? '自定义路径已保存' : '已恢复默认路径')
|
||||
await promptRestart(value ? t('settings.customPathSaved') : t('settings.defaultRestored'))
|
||||
}
|
||||
|
||||
async function handleResetOpenclawDir(page) {
|
||||
@@ -211,17 +211,17 @@ async function handleResetOpenclawDir(page) {
|
||||
delete cfg.openclawDir
|
||||
await api.writePanelConfig(cfg)
|
||||
await loadOpenclawDir(page)
|
||||
await promptRestart('已恢复默认路径')
|
||||
await promptRestart(t('settings.defaultRestored'))
|
||||
}
|
||||
|
||||
async function promptRestart(msg) {
|
||||
if (!isTauri) { toast(msg, 'success'); return }
|
||||
const ok = await showConfirm(`${msg}。\n\n需要重启面板才能生效,是否立即重启?`)
|
||||
const ok = await showConfirm(`${msg}\n\n${t('settings.restartConfirm')}`)
|
||||
if (ok) {
|
||||
toast('正在重启...', 'info')
|
||||
try { await api.relaunchApp() } catch { toast('自动重启失败,请手动关闭后重新打开', 'warning') }
|
||||
toast(t('settings.restarting'), 'info')
|
||||
try { await api.relaunchApp() } catch { toast(t('settings.restartFailed'), 'warning') }
|
||||
} else {
|
||||
toast(`${msg},下次启动时生效`, 'success')
|
||||
toast(`${msg}, ${t('settings.effectNextLaunch')}`, 'success')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,20 +276,20 @@ function normalizeProxyUrl(value) {
|
||||
const url = String(value || '').trim()
|
||||
if (!url) return ''
|
||||
if (!/^https?:\/\//i.test(url)) {
|
||||
throw new Error('代理地址必须以 http:// 或 https:// 开头')
|
||||
throw new Error(t('settings.proxyUrlInvalid'))
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
async function handleTestProxy(page) {
|
||||
const resultEl = page.querySelector('#proxy-test-result')
|
||||
if (resultEl) resultEl.innerHTML = '<span style="color:var(--text-tertiary)">正在测试代理连通性...</span>'
|
||||
if (resultEl) resultEl.innerHTML = `<span style="color:var(--text-tertiary)">${t('settings.testingProxy')}</span>`
|
||||
try {
|
||||
const r = await api.testProxy()
|
||||
if (resultEl) {
|
||||
resultEl.innerHTML = r.ok
|
||||
? `<span style="color:var(--success)">✓ 代理连通(HTTP ${r.status},耗时 ${r.elapsed_ms}ms)→ ${escapeHtml(r.target)}</span>`
|
||||
: `<span style="color:var(--warning)">⚠ 代理可达但返回异常(HTTP ${r.status},${r.elapsed_ms}ms)</span>`
|
||||
? `<span style="color:var(--success)">✓ ${t('settings.proxyOk', { status: r.status, ms: r.elapsed_ms, target: escapeHtml(r.target) })}</span>`
|
||||
: `<span style="color:var(--warning)">⚠ ${t('settings.proxyWarn', { status: r.status, ms: r.elapsed_ms })}</span>`
|
||||
}
|
||||
} catch (e) {
|
||||
if (resultEl) resultEl.innerHTML = `<span style="color:var(--error)">✗ ${escapeHtml(String(e))}</span>`
|
||||
@@ -300,7 +300,7 @@ async function handleSaveProxy(page) {
|
||||
const input = page.querySelector('[data-name="proxy-url"]')
|
||||
const proxyUrl = normalizeProxyUrl(input?.value || '')
|
||||
if (!proxyUrl) {
|
||||
toast('请输入代理地址,或点击"关闭代理"', 'error')
|
||||
toast(t('settings.proxyUrlEmpty'), 'error')
|
||||
return
|
||||
}
|
||||
const cfg = await api.readPanelConfig()
|
||||
@@ -309,7 +309,7 @@ async function handleSaveProxy(page) {
|
||||
}
|
||||
cfg.networkProxy.url = proxyUrl
|
||||
await api.writePanelConfig(cfg)
|
||||
toast('网络代理已保存;如 Gateway 正在运行,建议重启服务', 'success')
|
||||
toast(t('settings.proxySaved'), 'success')
|
||||
await loadProxyConfig(page)
|
||||
await loadModelProxyConfig(page)
|
||||
}
|
||||
@@ -318,7 +318,7 @@ async function handleClearProxy(page) {
|
||||
const cfg = await api.readPanelConfig()
|
||||
delete cfg.networkProxy
|
||||
await api.writePanelConfig(cfg)
|
||||
toast('网络代理已关闭', 'success')
|
||||
toast(t('settings.proxyCleared'), 'success')
|
||||
await loadProxyConfig(page)
|
||||
await loadModelProxyConfig(page)
|
||||
}
|
||||
@@ -332,16 +332,16 @@ async function handleSaveModelProxy(page) {
|
||||
}
|
||||
cfg.networkProxy.proxyModelRequests = checked
|
||||
await api.writePanelConfig(cfg)
|
||||
toast(checked ? '模型请求将走代理' : '模型请求已关闭代理', 'success')
|
||||
toast(checked ? t('settings.modelProxyOn') : t('settings.modelProxyOff'), 'success')
|
||||
}
|
||||
|
||||
async function handleSaveRegistry(page) {
|
||||
const select = page.querySelector('[data-name="registry"]')
|
||||
const customInput = page.querySelector('[data-name="custom-registry"]')
|
||||
const registry = select.value === 'custom' ? customInput.value.trim() : select.value
|
||||
if (!registry) { toast('请输入源地址', 'error'); return }
|
||||
if (!registry) { toast(t('settings.registryEmpty'), 'error'); return }
|
||||
await api.setNpmRegistry(registry)
|
||||
toast('npm 源已保存', 'success')
|
||||
toast(t('settings.registrySaved'), 'success')
|
||||
}
|
||||
|
||||
// ===== CLI 绑定 =====
|
||||
|
||||
@@ -8,6 +8,7 @@ import { toast } from '../components/toast.js'
|
||||
import { setUpgrading, isMacPlatform } from '../lib/app-state.js'
|
||||
import { diagnoseInstallError } from '../lib/error-diagnosis.js'
|
||||
import { icon, statusIcon } from '../lib/icons.js'
|
||||
import { t } from '../lib/i18n.js'
|
||||
|
||||
export async function render() {
|
||||
const page = document.createElement('div')
|
||||
@@ -18,9 +19,9 @@ export async function render() {
|
||||
<div style="margin-bottom:var(--space-lg)">
|
||||
<img src="/images/logo-brand.png" alt="ClawPanel" style="max-width:160px;width:100%;height:auto">
|
||||
</div>
|
||||
<h1 style="font-size:var(--font-size-xl);margin-bottom:var(--space-xs)">欢迎使用 ClawPanel</h1>
|
||||
<h1 style="font-size:var(--font-size-xl);margin-bottom:var(--space-xs)">${t('setup.headerTitle')}</h1>
|
||||
<p style="color:var(--text-secondary);margin-bottom:var(--space-xl);line-height:1.6">
|
||||
OpenClaw AI Agent 框架的桌面管理面板
|
||||
${t('setup.headerDesc')}
|
||||
</p>
|
||||
|
||||
<div id="setup-steps"></div>
|
||||
@@ -28,7 +29,7 @@ export async function render() {
|
||||
<div style="margin-top:var(--space-lg)">
|
||||
<button class="btn btn-secondary btn-sm" id="btn-recheck" style="min-width:120px">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="margin-right:4px"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
|
||||
重新检测
|
||||
${t('setup.recheck')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -103,30 +104,30 @@ function renderSteps(page, { node, git, cliOk, config, version }) {
|
||||
html += `
|
||||
<div class="config-section" style="text-align:left">
|
||||
<div class="config-section-title" style="display:flex;align-items:center;gap:4px">
|
||||
${stepIcon(nodeOk)} Node.js 环境
|
||||
${stepIcon(nodeOk)} ${t('setup.stepNode')}
|
||||
</div>
|
||||
${nodeOk
|
||||
? `<p style="color:var(--success);font-size:var(--font-size-sm)">已安装 ${node.version || ''}</p>`
|
||||
? `<p style="color:var(--success);font-size:var(--font-size-sm)">${t('setup.installed')} ${node.version || ''}</p>`
|
||||
: `<p style="color:var(--text-secondary);font-size:var(--font-size-sm);margin-bottom:var(--space-sm)">
|
||||
OpenClaw 基于 Node.js 运行,请先安装。
|
||||
${t('setup.stepNodeHint')}
|
||||
</p>
|
||||
<a class="btn btn-primary btn-sm" href="https://nodejs.org/" target="_blank" rel="noopener">下载 Node.js</a>
|
||||
<span class="form-hint" style="margin-left:8px">安装后点击「重新检测」</span>
|
||||
<a class="btn btn-primary btn-sm" href="https://nodejs.org/" target="_blank" rel="noopener">${t('setup.downloadNode')}</a>
|
||||
<span class="form-hint" style="margin-left:8px">${t('setup.recheckAfterInstall')}</span>
|
||||
<div style="margin-top:var(--space-sm);padding:8px 12px;background:var(--bg-tertiary);border-radius:var(--radius-sm);font-size:var(--font-size-xs);color:var(--text-secondary);line-height:1.6">
|
||||
<strong>已经装了但检测不到?</strong>
|
||||
<strong>${t('setup.nodeInstalledButNotDetected')}</strong>
|
||||
${isMacPlatform()
|
||||
? `macOS 上从 Finder 启动可能找不到 Node.js。试试关掉 ClawPanel 后从终端启动:<br>
|
||||
? `${t('setup.macNodeHint')}<br>
|
||||
<code style="background:var(--bg-secondary);padding:2px 6px;border-radius:3px;user-select:all">open /Applications/ClawPanel.app</code>`
|
||||
: `安装 Node.js 后点击「重新检测」或使用下方「自动扫描」,无需重启。`
|
||||
: `${t('setup.winNodeHint')}`
|
||||
}
|
||||
<div style="margin-top:8px;display:flex;gap:6px;align-items:center;flex-wrap:wrap">
|
||||
<button class="btn btn-secondary btn-sm" id="btn-scan-node" style="font-size:11px;padding:3px 10px">${icon('search', 12)} 自动扫描</button>
|
||||
<span style="color:var(--text-tertiary)">或手动指定路径:</span>
|
||||
<button class="btn btn-secondary btn-sm" id="btn-scan-node" style="font-size:11px;padding:3px 10px">${icon('search', 12)} ${t('setup.scanNodeBtn')}</button>
|
||||
<span style="color:var(--text-tertiary)">${t('setup.orManualPath')}</span>
|
||||
</div>
|
||||
<div style="margin-top:6px;display:flex;gap:6px">
|
||||
<input id="input-node-path" type="text" placeholder="${isMacPlatform() ? '/usr/local/bin' : 'F:\\\\AI\\\\Node'}"
|
||||
style="flex:1;padding:4px 8px;border:1px solid var(--border-primary);border-radius:var(--radius-sm);background:var(--bg-secondary);color:var(--text-primary);font-size:11px;font-family:monospace">
|
||||
<button class="btn btn-primary btn-sm" id="btn-check-path" style="font-size:11px;padding:3px 10px">检测</button>
|
||||
<button class="btn btn-primary btn-sm" id="btn-check-path" style="font-size:11px;padding:3px 10px">${t('setup.checkPathBtn')}</button>
|
||||
</div>
|
||||
<div id="scan-result" style="margin-top:6px;display:none"></div>
|
||||
</div>`
|
||||
@@ -138,21 +139,21 @@ function renderSteps(page, { node, git, cliOk, config, version }) {
|
||||
html += `
|
||||
<div class="config-section" style="text-align:left;${nodeOk ? '' : 'opacity:0.4;pointer-events:none'}">
|
||||
<div class="config-section-title" style="display:flex;align-items:center;gap:4px">
|
||||
${stepIcon(gitOk)} Git 版本管理
|
||||
${stepIcon(gitOk)} ${t('setup.stepGit')}
|
||||
</div>
|
||||
${gitOk
|
||||
? `<p style="color:var(--success);font-size:var(--font-size-sm)">已安装 ${git.version || ''}</p>
|
||||
<p style="font-size:var(--font-size-xs);color:var(--text-tertiary);margin-top:4px">✅ 已自动配置 Git 使用 HTTPS(避免 SSH 连接问题)</p>`
|
||||
? `<p style="color:var(--success);font-size:var(--font-size-sm)">${t('setup.installed')} ${git.version || ''}</p>
|
||||
<p style="font-size:var(--font-size-xs);color:var(--text-tertiary);margin-top:4px">✅ ${t('setup.gitHttpsConfigured')}</p>`
|
||||
: `<p style="color:var(--text-secondary);font-size:var(--font-size-sm);margin-bottom:var(--space-sm);line-height:1.5">
|
||||
部分依赖需要 Git 下载源码。点击下方按钮自动安装,如果失败请手动安装。
|
||||
${t('setup.stepGitHint')}
|
||||
</p>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<button class="btn btn-primary btn-sm" id="btn-auto-install-git">一键安装 Git</button>
|
||||
<a class="btn btn-secondary btn-sm" href="https://git-scm.com/downloads" target="_blank" rel="noopener">手动下载</a>
|
||||
<button class="btn btn-primary btn-sm" id="btn-auto-install-git">${t('setup.autoInstallGitBtn')}</button>
|
||||
<a class="btn btn-secondary btn-sm" href="https://git-scm.com/downloads" target="_blank" rel="noopener">${t('setup.manualDownload')}</a>
|
||||
</div>
|
||||
<div id="git-install-result" style="margin-top:var(--space-sm);display:none"></div>
|
||||
<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary);line-height:1.5">
|
||||
<strong>没有 Git 也能安装?</strong> 大部分情况下可以,但个别依赖可能需要 Git。建议安装以避免问题。
|
||||
${t('setup.gitOptionalHint')}
|
||||
</div>`
|
||||
}
|
||||
</div>
|
||||
@@ -165,10 +166,10 @@ function renderSteps(page, { node, git, cliOk, config, version }) {
|
||||
${stepIcon(cliOk)} OpenClaw CLI
|
||||
</div>
|
||||
${cliOk
|
||||
? `<p style="color:var(--success);font-size:var(--font-size-sm)">CLI 可用</p>
|
||||
? `<p style="color:var(--success);font-size:var(--font-size-sm)">${t('setup.cliAvailable')}</p>
|
||||
${version?.ahead_of_recommended && version?.recommended
|
||||
? `<div style="margin-top:8px;padding:8px 12px;background:var(--bg-tertiary);border-radius:var(--radius-sm);font-size:var(--font-size-xs);color:var(--warning,#f59e0b);line-height:1.6">
|
||||
检测到当前本地 OpenClaw ${version.current || ''} 高于当前面板推荐稳定版 ${version.recommended},可能存在兼容或稳定性风险。建议稍后到「关于」页回退到推荐版。
|
||||
${t('setup.cliAheadWarning', { current: version.current || '', recommended: version.recommended })}
|
||||
</div>`
|
||||
: ''}`
|
||||
: renderInstallSection()
|
||||
@@ -179,28 +180,28 @@ function renderSteps(page, { node, git, cliOk, config, version }) {
|
||||
html += `
|
||||
<div class="config-section" style="text-align:left;${cliOk ? '' : 'opacity:0.4;pointer-events:none'}">
|
||||
<div class="config-section-title" style="display:flex;align-items:center;gap:4px">
|
||||
${stepIcon(config.installed)} 配置文件
|
||||
${stepIcon(config.installed)} ${t('setup.stepConfig')}
|
||||
</div>
|
||||
${config.installed
|
||||
? `<p style="color:var(--success);font-size:var(--font-size-sm)">配置文件位于 ${config.path || ''}</p>`
|
||||
? `<p style="color:var(--success);font-size:var(--font-size-sm)">${t('setup.configAt', { path: config.path || '' })}</p>`
|
||||
: `<p style="color:var(--text-secondary);font-size:var(--font-size-sm);margin-bottom:var(--space-sm)">
|
||||
配置文件不存在,点击下方按钮自动创建默认配置。
|
||||
${t('setup.configMissing')}
|
||||
</p>
|
||||
<button class="btn btn-primary btn-sm" id="btn-init-config">一键初始化配置</button>`
|
||||
<button class="btn btn-primary btn-sm" id="btn-init-config">${t('setup.initConfigLabel')}</button>`
|
||||
}
|
||||
<details style="margin-top:var(--space-sm);cursor:pointer" id="custom-dir-details">
|
||||
<summary style="font-size:var(--font-size-xs);color:var(--text-secondary);font-weight:600;user-select:none">
|
||||
自定义 OpenClaw 安装路径
|
||||
${t('setup.customDirTitle')}
|
||||
</summary>
|
||||
<div style="margin-top:var(--space-sm);padding:10px 12px;background:var(--bg-tertiary);border-radius:var(--radius-sm);font-size:var(--font-size-xs);line-height:1.6">
|
||||
<p style="color:var(--text-secondary);margin-bottom:8px">
|
||||
如果 OpenClaw 安装在非默认目录(如 <code>E:\\数据\\AI\\.openclaw</code>),可在此指定。留空则使用默认路径。
|
||||
${t('setup.customDirHint')}
|
||||
</p>
|
||||
<div style="display:flex;gap:6px">
|
||||
<input id="input-openclaw-dir" type="text" placeholder="例如 E:\\数据\\AI\\.openclaw"
|
||||
<input id="input-openclaw-dir" type="text" placeholder="${t('setup.customDirPlaceholder')}"
|
||||
style="flex:1;padding:4px 8px;border:1px solid var(--border-primary);border-radius:var(--radius-sm);background:var(--bg-secondary);color:var(--text-primary);font-size:11px;font-family:monospace">
|
||||
<button class="btn btn-primary btn-sm" id="btn-save-openclaw-dir" style="font-size:11px;padding:3px 10px">保存</button>
|
||||
<button class="btn btn-secondary btn-sm" id="btn-reset-openclaw-dir" style="font-size:11px;padding:3px 10px">恢复默认</button>
|
||||
<button class="btn btn-primary btn-sm" id="btn-save-openclaw-dir" style="font-size:11px;padding:3px 10px">${t('setup.saveBtn')}</button>
|
||||
<button class="btn btn-secondary btn-sm" id="btn-reset-openclaw-dir" style="font-size:11px;padding:3px 10px">${t('setup.resetDefaultBtn')}</button>
|
||||
</div>
|
||||
<div id="openclaw-dir-result" style="margin-top:6px;display:none"></div>
|
||||
</div>
|
||||
@@ -213,19 +214,19 @@ function renderSteps(page, { node, git, cliOk, config, version }) {
|
||||
<div class="config-section" style="text-align:left;margin-top:var(--space-md)">
|
||||
<div class="config-section-title" style="display:flex;align-items:center;gap:6px">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"/></svg>
|
||||
晴辰助手
|
||||
${t('setup.aiAssistant')}
|
||||
</div>
|
||||
<p style="color:var(--text-secondary);font-size:var(--font-size-sm);margin-bottom:var(--space-sm);line-height:1.5">
|
||||
遇到安装问题?AI 助手可以帮你诊断和解决。配置好模型后,点击下方按钮${!allOk ? ',当前问题会自动发送给 AI 分析' : ''}。
|
||||
${t('setup.aiAssistantDesc')}${!allOk ? t('setup.aiAssistantDescProblem') : ''}。
|
||||
</p>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<button class="btn btn-secondary btn-sm" id="btn-goto-assistant">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="margin-right:4px"><path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"/></svg>
|
||||
打开 AI 助手
|
||||
${t('setup.openAiAssistant')}
|
||||
</button>
|
||||
${!allOk ? `<button class="btn btn-primary btn-sm" id="btn-ask-ai-help">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="margin-right:4px"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
|
||||
让 AI 帮我解决
|
||||
${t('setup.askAiHelp')}
|
||||
</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
@@ -235,23 +236,23 @@ function renderSteps(page, { node, git, cliOk, config, version }) {
|
||||
if (allOk) {
|
||||
html += `
|
||||
<div class="config-section" style="text-align:left;margin-top:var(--space-md)">
|
||||
<div class="config-section-title">下一步建议</div>
|
||||
<div class="config-section-title">${t('setup.nextStepsTitle')}</div>
|
||||
<div style="color:var(--text-secondary);font-size:var(--font-size-sm);line-height:1.7">
|
||||
当前仅表示运行环境已经就绪,并不代表已经可以直接聊天。通常还需要继续完成以下步骤:
|
||||
${t('setup.nextStepsDesc')}
|
||||
<ol style="margin:8px 0 0 18px;padding:0">
|
||||
<li>前往「模型配置」添加至少一个可用模型,并确认主模型已设置</li>
|
||||
<li>前往「Gateway」确认服务已启动</li>
|
||||
<li>如需飞书、钉钉、QQ 等消息渠道,请到「消息渠道」完成接入与配对</li>
|
||||
<li>${t('setup.nextStep1')}</li>
|
||||
<li>${t('setup.nextStep2')}</li>
|
||||
<li>${t('setup.nextStep3')}</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:10px">
|
||||
<button class="btn btn-secondary btn-sm" id="btn-goto-models">配置模型</button>
|
||||
<button class="btn btn-secondary btn-sm" id="btn-goto-gateway">Gateway 设置</button>
|
||||
<button class="btn btn-secondary btn-sm" id="btn-goto-channels">消息渠道</button>
|
||||
<button class="btn btn-secondary btn-sm" id="btn-goto-models">${t('setup.configModels')}</button>
|
||||
<button class="btn btn-secondary btn-sm" id="btn-goto-gateway">${t('setup.gatewaySetup')}</button>
|
||||
<button class="btn btn-secondary btn-sm" id="btn-goto-channels">${t('setup.messageChannels')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:var(--space-lg)">
|
||||
<button class="btn btn-primary" id="btn-enter" style="min-width:200px">进入面板</button>
|
||||
<button class="btn btn-primary" id="btn-enter" style="min-width:200px">${t('setup.enterPanel')}</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
@@ -269,118 +270,118 @@ function renderInstallSection() {
|
||||
if (isDesktop) {
|
||||
envHint = `
|
||||
<div style="margin-top:var(--space-sm);padding:10px 12px;background:var(--bg-tertiary);border-radius:var(--radius-sm);border-left:3px solid var(--warning);font-size:var(--font-size-xs);color:var(--text-secondary);line-height:1.7">
|
||||
<strong style="color:var(--text-primary)">找不到已安装的 OpenClaw?</strong>
|
||||
<p style="margin:6px 0 2px">ClawPanel 桌面版只能管理<strong>本机</strong>安装的 OpenClaw。以下环境中的安装无法被检测到:</p>
|
||||
<strong style="color:var(--text-primary)">${t('setup.envHintTitle')}</strong>
|
||||
<p style="margin:6px 0 2px">${t('setup.envHintDesc')}</p>
|
||||
<ul style="margin:4px 0 8px 16px;padding:0">
|
||||
${isWin ? `
|
||||
<li><strong>WSL (Windows 子系统)</strong> — OpenClaw 装在 WSL 里,Windows 侧无法访问</li>
|
||||
<li><strong>Docker 容器</strong> — 容器内的安装与宿主机隔离</li>
|
||||
<li><strong>${t('setup.envHintWsl')}</strong> — ${t('setup.envHintWslDesc')}</li>
|
||||
<li><strong>${t('setup.envHintDocker')}</strong> — ${t('setup.envHintDockerDesc')}</li>
|
||||
` : ''}
|
||||
${isMac ? `
|
||||
<li><strong>Docker 容器</strong> — 容器内的安装与宿主机隔离</li>
|
||||
<li><strong>远程服务器</strong> — 安装在其他机器上</li>
|
||||
<li><strong>${t('setup.envHintDocker')}</strong> — ${t('setup.envHintDockerDesc')}</li>
|
||||
<li><strong>${t('setup.envHintRemote')}</strong> — ${t('setup.envHintRemoteDesc')}</li>
|
||||
` : ''}
|
||||
${!isWin && !isMac ? `
|
||||
<li><strong>Docker 容器</strong> — 容器内的安装与宿主机隔离</li>
|
||||
<li><strong>${t('setup.envHintDocker')}</strong> — ${t('setup.envHintDockerDesc')}</li>
|
||||
` : ''}
|
||||
</ul>
|
||||
<details style="cursor:pointer">
|
||||
<summary style="font-weight:600;color:var(--primary);margin-bottom:6px">
|
||||
在对应环境中安装管理面板
|
||||
${t('setup.envHintInstallManage')}
|
||||
</summary>
|
||||
<div style="margin-top:8px">
|
||||
${isWin ? `
|
||||
<div style="margin-bottom:10px">
|
||||
<div style="font-weight:600;margin-bottom:4px">WSL 中使用 Web 版:</div>
|
||||
<div style="margin-bottom:2px;opacity:0.8">打开 WSL 终端,一键部署 ClawPanel Web 版:</div>
|
||||
<div style="font-weight:600;margin-bottom:4px">${t('setup.wslWebHint')}</div>
|
||||
<div style="margin-bottom:2px;opacity:0.8">${t('setup.wslWebDesc')}</div>
|
||||
<code style="display:block;background:var(--bg-secondary);padding:6px 10px;border-radius:4px;user-select:all;word-break:break-all">curl -fsSL https://raw.githubusercontent.com/qingchencloud/clawpanel/main/deploy.sh | bash</code>
|
||||
<div style="margin-top:4px;opacity:0.7">国内用户如无法访问 GitHub:<code style="background:var(--bg-secondary);padding:2px 4px;border-radius:3px;user-select:all">curl -fsSL https://gitee.com/QtCodeCreators/clawpanel/raw/main/deploy.sh | bash</code></div>
|
||||
<div style="margin-top:4px;opacity:0.7">部署后在浏览器访问 WSL 的 IP 即可管理。</div>
|
||||
<div style="margin-top:4px;opacity:0.7">${t('setup.domesticMirror')}<code style="background:var(--bg-secondary);padding:2px 4px;border-radius:3px;user-select:all">curl -fsSL https://gitee.com/QtCodeCreators/clawpanel/raw/main/deploy.sh | bash</code></div>
|
||||
<div style="margin-top:4px;opacity:0.7">${t('setup.wslWebPostDeploy')}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<div style="margin-bottom:10px">
|
||||
<div style="font-weight:600;margin-bottom:4px">Docker 容器中使用:</div>
|
||||
<div style="margin-bottom:2px;opacity:0.8">在容器内安装 OpenClaw + ClawPanel Web 版:</div>
|
||||
<div style="font-weight:600;margin-bottom:4px">${t('setup.dockerHint')}</div>
|
||||
<div style="margin-bottom:2px;opacity:0.8">${t('setup.dockerDesc')}</div>
|
||||
<code style="display:block;background:var(--bg-secondary);padding:6px 10px;border-radius:4px;user-select:all;word-break:break-all;margin-bottom:4px">npm i -g @qingchencloud/openclaw-zh</code>
|
||||
<code style="display:block;background:var(--bg-secondary);padding:6px 10px;border-radius:4px;user-select:all;word-break:break-all">curl -fsSL https://raw.githubusercontent.com/qingchencloud/clawpanel/main/deploy.sh | bash</code>
|
||||
<div style="margin-top:4px;opacity:0.7">国内镜像:<code style="background:var(--bg-secondary);padding:2px 4px;border-radius:3px;user-select:all">curl -fsSL https://gitee.com/QtCodeCreators/clawpanel/raw/main/deploy.sh | bash</code></div>
|
||||
<div style="margin-top:4px;opacity:0.7">${t('setup.domesticMirrorShort')}<code style="background:var(--bg-secondary);padding:2px 4px;border-radius:3px;user-select:all">curl -fsSL https://gitee.com/QtCodeCreators/clawpanel/raw/main/deploy.sh | bash</code></div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-weight:600;margin-bottom:4px">远程服务器:</div>
|
||||
<div style="margin-bottom:2px;opacity:0.8">SSH 登录服务器后执行:</div>
|
||||
<div style="font-weight:600;margin-bottom:4px">${t('setup.remoteHint')}</div>
|
||||
<div style="margin-bottom:2px;opacity:0.8">${t('setup.remoteDesc')}</div>
|
||||
<code style="display:block;background:var(--bg-secondary);padding:6px 10px;border-radius:4px;user-select:all;word-break:break-all">curl -fsSL https://raw.githubusercontent.com/qingchencloud/clawpanel/main/deploy.sh | bash</code>
|
||||
<div style="margin-top:4px;opacity:0.7">国内镜像:<code style="background:var(--bg-secondary);padding:2px 4px;border-radius:3px;user-select:all">curl -fsSL https://gitee.com/QtCodeCreators/clawpanel/raw/main/deploy.sh | bash</code></div>
|
||||
<div style="margin-top:4px;opacity:0.7">${t('setup.domesticMirrorShort')}<code style="background:var(--bg-secondary);padding:2px 4px;border-radius:3px;user-select:all">curl -fsSL https://gitee.com/QtCodeCreators/clawpanel/raw/main/deploy.sh | bash</code></div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<div style="margin-top:6px;opacity:0.7">
|
||||
或者,你也可以在本机重新安装 OpenClaw(使用下方的「一键安装」)。
|
||||
${t('setup.envHintLocalReinstall')}
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
return `
|
||||
<p style="color:var(--text-secondary);font-size:var(--font-size-sm);margin-bottom:var(--space-sm)">
|
||||
点击安装后,将默认安装当前 ClawPanel 版本绑定的推荐稳定版;如需升降级,可稍后到「关于」页面切换版本。
|
||||
${t('setup.installHint')}
|
||||
</p>
|
||||
<p style="color:var(--text-tertiary);font-size:var(--font-size-xs);line-height:1.6;margin:-4px 0 var(--space-sm)">
|
||||
如果你是为了体验最新版功能,建议先安装推荐稳定版再手动切换;若希望面板优先适配最新版,欢迎提交 issue。
|
||||
${t('setup.installHint2')}
|
||||
</p>
|
||||
<div style="display:flex;gap:var(--space-sm);margin-bottom:var(--space-sm)">
|
||||
<label class="setup-source-option" style="flex:1;cursor:pointer">
|
||||
<input type="radio" name="install-source" value="chinese" checked style="margin-right:6px">
|
||||
<div>
|
||||
<div style="font-weight:600;font-size:var(--font-size-sm)">汉化优化版(推荐)</div>
|
||||
<div style="font-weight:600;font-size:var(--font-size-sm)">${t('setup.sourceChineseLabel')}</div>
|
||||
<div style="font-size:var(--font-size-xs);color:var(--text-tertiary)">@qingchencloud/openclaw-zh</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="setup-source-option" style="flex:1;cursor:pointer">
|
||||
<input type="radio" name="install-source" value="official" style="margin-right:6px">
|
||||
<div>
|
||||
<div style="font-weight:600;font-size:var(--font-size-sm)">官方原版</div>
|
||||
<div style="font-weight:600;font-size:var(--font-size-sm)">${t('setup.sourceOfficialLabel')}</div>
|
||||
<div style="font-size:var(--font-size-xs);color:var(--text-tertiary)">openclaw</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div style="margin-bottom:var(--space-sm)" id="install-method-section">
|
||||
<label style="font-size:var(--font-size-xs);color:var(--text-tertiary);display:block;margin-bottom:4px">安装方式</label>
|
||||
<label style="font-size:var(--font-size-xs);color:var(--text-tertiary);display:block;margin-bottom:4px">${t('setup.installMethodLabel')}</label>
|
||||
<select id="install-method" style="width:100%;padding:6px 8px;border-radius:var(--radius-sm);border:1px solid var(--border-primary);background:var(--bg-secondary);color:var(--text-primary);font-size:var(--font-size-sm)">
|
||||
<option value="auto">自动选择(推荐)</option>
|
||||
<option value="standalone-r2">独立安装包 · CDN 加速(国内推荐,自带 Node.js,无需 npm)</option>
|
||||
<option value="standalone-github">独立安装包 · GitHub(CDN 不可用时备选)</option>
|
||||
<option value="npm">npm 编译安装(传统方式,需要 Node.js + npm + 网络)</option>
|
||||
<option value="auto">${t('setup.methodAuto')}</option>
|
||||
<option value="standalone-r2">${t('setup.methodStandaloneR2')}</option>
|
||||
<option value="standalone-github">${t('setup.methodStandaloneGithub')}</option>
|
||||
<option value="npm">${t('setup.methodNpm')}</option>
|
||||
</select>
|
||||
<div id="method-hint" style="font-size:var(--font-size-xs);color:var(--text-tertiary);margin-top:4px;line-height:1.5"></div>
|
||||
</div>
|
||||
<div style="margin-bottom:var(--space-sm)" id="registry-section">
|
||||
<label style="font-size:var(--font-size-xs);color:var(--text-tertiary);display:block;margin-bottom:4px">npm 镜像源</label>
|
||||
<label style="font-size:var(--font-size-xs);color:var(--text-tertiary);display:block;margin-bottom:4px">${t('setup.registryLabel')}</label>
|
||||
<select id="registry-select" style="width:100%;padding:6px 8px;border-radius:var(--radius-sm);border:1px solid var(--border-primary);background:var(--bg-secondary);color:var(--text-primary);font-size:var(--font-size-sm)">
|
||||
<option value="https://registry.npmmirror.com">淘宝镜像(推荐国内用户)</option>
|
||||
<option value="https://registry.npmjs.org">npm 官方源</option>
|
||||
<option value="https://repo.huaweicloud.com/repository/npm/">华为云镜像</option>
|
||||
<option value="https://registry.npmmirror.com">${t('setup.registryTaobao')}</option>
|
||||
<option value="https://registry.npmjs.org">${t('setup.registryNpm')}</option>
|
||||
<option value="https://repo.huaweicloud.com/repository/npm/">${t('setup.registryHuawei')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" id="btn-install">一键安装</button>
|
||||
<button class="btn btn-primary btn-sm" id="btn-install">${t('setup.installBtn')}</button>
|
||||
${envHint}
|
||||
`
|
||||
}
|
||||
|
||||
function buildSetupProblemPrompt({ node, git, cliOk, config }) {
|
||||
const problems = []
|
||||
if (!node.installed) problems.push('- Node.js 未安装或未检测到')
|
||||
else problems.push(`- Node.js 已安装: ${node.version || '版本未知'}`)
|
||||
if (!git?.installed) problems.push('- Git 未安装')
|
||||
else problems.push(`- Git 已安装: ${git.version || '版本未知'}`)
|
||||
if (!cliOk) problems.push('- OpenClaw CLI 未安装')
|
||||
else problems.push('- OpenClaw CLI 已安装')
|
||||
if (!config.installed) problems.push('- 配置文件不存在')
|
||||
else problems.push(`- 配置文件正常: ${config.path || ''}`)
|
||||
if (!node.installed) problems.push(`- ${t('setup.promptNodeMissing')}`)
|
||||
else problems.push(`- ${t('setup.promptNodeOk', { version: node.version || t('common.unknown') })}`)
|
||||
if (!git?.installed) problems.push(`- ${t('setup.promptGitMissing')}`)
|
||||
else problems.push(`- ${t('setup.promptGitOk', { version: git.version || t('common.unknown') })}`)
|
||||
if (!cliOk) problems.push(`- ${t('setup.promptCliMissing')}`)
|
||||
else problems.push(`- ${t('setup.promptCliOk')}`)
|
||||
if (!config.installed) problems.push(`- ${t('setup.promptConfigMissing')}`)
|
||||
else problems.push(`- ${t('setup.promptConfigOk', { path: config.path || '' })}`)
|
||||
|
||||
return `我在安装 OpenClaw 时遇到问题,以下是当前检测状态:
|
||||
return `${t('setup.promptIntro')}
|
||||
|
||||
${problems.join('\n')}
|
||||
|
||||
请帮我分析问题并给出解决步骤。如果需要,请使用工具帮我检查系统环境。`
|
||||
${t('setup.promptOutro')}`
|
||||
}
|
||||
|
||||
function bindEvents(page, nodeOk, detectState) {
|
||||
@@ -417,15 +418,15 @@ function bindEvents(page, nodeOk, detectState) {
|
||||
const btn = page.querySelector('#btn-auto-install-git')
|
||||
const resultEl = page.querySelector('#git-install-result')
|
||||
btn.disabled = true
|
||||
btn.textContent = '安装中...'
|
||||
btn.textContent = t('setup.installingGit')
|
||||
if (resultEl) {
|
||||
resultEl.style.display = 'block'
|
||||
resultEl.innerHTML = '<span style="color:var(--text-tertiary)">正在安装 Git,请稍候...</span>'
|
||||
resultEl.innerHTML = `<span style="color:var(--text-tertiary)">${t('setup.gitInstallingHint')}</span>`
|
||||
}
|
||||
try {
|
||||
const msg = await api.autoInstallGit()
|
||||
if (resultEl) resultEl.innerHTML = `<span style="color:var(--success)">✓ ${msg}</span>`
|
||||
toast('Git 安装成功', 'success')
|
||||
toast(t('setup.gitInstallSuccess'), 'success')
|
||||
// 安装成功后自动配置 HTTPS
|
||||
api.configureGitHttps().catch(() => {})
|
||||
setTimeout(() => runDetect(page), 1000)
|
||||
@@ -433,19 +434,17 @@ function bindEvents(page, nodeOk, detectState) {
|
||||
const errMsg = String(e.message || e)
|
||||
if (resultEl) {
|
||||
resultEl.innerHTML = `<div>
|
||||
<span style="color:var(--danger)">自动安装失败: ${errMsg}</span>
|
||||
<span style="color:var(--danger)">${t('setup.gitAutoInstallFailed', { err: errMsg })}</span>
|
||||
<p style="margin-top:6px;font-size:var(--font-size-xs);color:var(--text-secondary);line-height:1.5">
|
||||
请手动安装 Git:<br>
|
||||
<strong>Windows:</strong> 下载 <a href="https://git-scm.com/downloads" target="_blank" style="color:var(--accent)">git-scm.com</a> 安装包<br>
|
||||
<strong>macOS:</strong> 在终端执行 <code style="background:var(--bg-secondary);padding:2px 4px;border-radius:3px">xcode-select --install</code> 或 <code style="background:var(--bg-secondary);padding:2px 4px;border-radius:3px">brew install git</code><br>
|
||||
<strong>Linux:</strong> <code style="background:var(--bg-secondary);padding:2px 4px;border-radius:3px">sudo apt install git</code> 或 <code style="background:var(--bg-secondary);padding:2px 4px;border-radius:3px">sudo yum install git</code>
|
||||
${t('setup.gitManualHint')}<br>
|
||||
${t('setup.gitManualInstallHtml')}
|
||||
</p>
|
||||
</div>`
|
||||
}
|
||||
toast('Git 自动安装失败,请手动安装', 'warning')
|
||||
toast(t('setup.gitAutoInstallFailedToast'), 'warning')
|
||||
} finally {
|
||||
btn.disabled = false
|
||||
btn.textContent = '一键安装 Git'
|
||||
btn.textContent = t('setup.autoInstallGitBtn')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -466,21 +465,21 @@ function bindEvents(page, nodeOk, detectState) {
|
||||
|
||||
page.querySelector('#btn-save-openclaw-dir')?.addEventListener('click', async () => {
|
||||
const value = dirInput?.value?.trim()
|
||||
if (!value) { toast('请输入路径', 'warning'); return }
|
||||
if (!value) { toast(t('setup.enterPath'), 'warning'); return }
|
||||
const btn = page.querySelector('#btn-save-openclaw-dir')
|
||||
btn.disabled = true
|
||||
if (dirResultEl) { dirResultEl.style.display = 'block'; dirResultEl.innerHTML = '<span style="color:var(--text-tertiary)">保存中...</span>' }
|
||||
if (dirResultEl) { dirResultEl.style.display = 'block'; dirResultEl.innerHTML = `<span style="color:var(--text-tertiary)">${t('setup.saving')}</span>` }
|
||||
try {
|
||||
const cfg = await api.readPanelConfig()
|
||||
cfg.openclawDir = value
|
||||
await api.writePanelConfig(cfg)
|
||||
invalidate()
|
||||
if (dirResultEl) dirResultEl.innerHTML = `<span style="color:var(--success)">✓ 路径已保存,正在重新检测...</span>`
|
||||
toast('自定义路径已保存', 'success')
|
||||
if (dirResultEl) dirResultEl.innerHTML = `<span style="color:var(--success)">✓ ${t('setup.pathSaved')}</span>`
|
||||
toast(t('setup.customPathSaved'), 'success')
|
||||
setTimeout(() => runDetect(page), 500)
|
||||
} catch (e) {
|
||||
if (dirResultEl) dirResultEl.innerHTML = `<span style="color:var(--error)">保存失败: ${e}</span>`
|
||||
toast('保存失败: ' + e, 'error')
|
||||
if (dirResultEl) dirResultEl.innerHTML = `<span style="color:var(--error)">${t('setup.saveFailed', { err: e })}</span>`
|
||||
toast(t('setup.saveFailed', { err: e }), 'error')
|
||||
} finally {
|
||||
btn.disabled = false
|
||||
}
|
||||
@@ -495,11 +494,11 @@ function bindEvents(page, nodeOk, detectState) {
|
||||
await api.writePanelConfig(cfg)
|
||||
invalidate()
|
||||
if (dirInput) dirInput.value = ''
|
||||
if (dirResultEl) { dirResultEl.style.display = 'block'; dirResultEl.innerHTML = `<span style="color:var(--success)">✓ 已恢复默认路径,正在重新检测...</span>` }
|
||||
toast('已恢复默认路径', 'success')
|
||||
if (dirResultEl) { dirResultEl.style.display = 'block'; dirResultEl.innerHTML = `<span style="color:var(--success)">✓ ${t('setup.defaultRestored')}</span>` }
|
||||
toast(t('setup.defaultRestoredToast'), 'success')
|
||||
setTimeout(() => runDetect(page), 500)
|
||||
} catch (e) {
|
||||
toast('恢复失败: ' + e, 'error')
|
||||
toast(t('setup.restoreFailed', { err: e }), 'error')
|
||||
} finally {
|
||||
btn.disabled = false
|
||||
}
|
||||
@@ -509,19 +508,19 @@ function bindEvents(page, nodeOk, detectState) {
|
||||
page.querySelector('#btn-init-config')?.addEventListener('click', async () => {
|
||||
const btn = page.querySelector('#btn-init-config')
|
||||
btn.disabled = true
|
||||
btn.textContent = '初始化中...'
|
||||
btn.textContent = t('setup.initializing')
|
||||
try {
|
||||
const result = await api.initOpenclawConfig()
|
||||
if (result?.created) {
|
||||
toast('配置文件已创建', 'success')
|
||||
toast(t('setup.configCreated'), 'success')
|
||||
} else {
|
||||
toast(result?.message || '配置文件已存在', 'info')
|
||||
toast(result?.message || t('setup.configExists'), 'info')
|
||||
}
|
||||
setTimeout(() => runDetect(page), 500)
|
||||
} catch (e) {
|
||||
toast('初始化失败: ' + e, 'error')
|
||||
toast(t('setup.initFailed', { err: e }), 'error')
|
||||
btn.disabled = false
|
||||
btn.textContent = '一键初始化配置'
|
||||
btn.textContent = t('setup.initConfigLabel')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -530,35 +529,35 @@ function bindEvents(page, nodeOk, detectState) {
|
||||
const btn = page.querySelector('#btn-scan-node')
|
||||
const resultEl = page.querySelector('#scan-result')
|
||||
btn.disabled = true
|
||||
btn.textContent = '扫描中...'
|
||||
btn.textContent = t('setup.scanning')
|
||||
resultEl.style.display = 'block'
|
||||
resultEl.innerHTML = '<span style="color:var(--text-tertiary)">正在扫描常见安装路径...</span>'
|
||||
resultEl.innerHTML = `<span style="color:var(--text-tertiary)">${t('setup.scanningPaths')}</span>`
|
||||
try {
|
||||
const results = await api.scanNodePaths()
|
||||
if (results.length === 0) {
|
||||
resultEl.innerHTML = '<span style="color:var(--warning)">未找到 Node.js 安装,请手动指定路径或下载安装。</span>'
|
||||
resultEl.innerHTML = `<span style="color:var(--warning)">${t('setup.scanNotFound')}</span>`
|
||||
} else {
|
||||
resultEl.innerHTML = results.map(r =>
|
||||
`<div style="display:flex;align-items:center;gap:6px;margin-top:4px">
|
||||
<span style="color:var(--success)">✓</span>
|
||||
<code style="flex:1;background:var(--bg-secondary);padding:2px 6px;border-radius:3px;font-size:11px">${r.path}</code>
|
||||
<span style="font-size:11px;color:var(--text-tertiary)">${r.version}</span>
|
||||
<button class="btn btn-primary btn-sm btn-use-path" data-path="${r.path}" style="font-size:10px;padding:2px 8px">使用</button>
|
||||
<button class="btn btn-primary btn-sm btn-use-path" data-path="${r.path}" style="font-size:10px;padding:2px 8px">${t('setup.scanUseBtn')}</button>
|
||||
</div>`
|
||||
).join('')
|
||||
resultEl.querySelectorAll('.btn-use-path').forEach(b => {
|
||||
b.addEventListener('click', async () => {
|
||||
await api.saveCustomNodePath(b.dataset.path)
|
||||
toast('Node.js 路径已保存,正在重新检测...', 'success')
|
||||
toast(t('setup.nodeSaved'), 'success')
|
||||
setTimeout(() => runDetect(page), 300)
|
||||
})
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
resultEl.innerHTML = `<span style="color:var(--danger)">扫描失败: ${e}</span>`
|
||||
resultEl.innerHTML = `<span style="color:var(--danger)">${t('setup.scanFailed', { err: e })}</span>`
|
||||
} finally {
|
||||
btn.disabled = false
|
||||
btn.innerHTML = `${icon('search', 12)} 自动扫描`
|
||||
btn.innerHTML = `${icon('search', 12)} ${t('setup.scanNodeBtn')}`
|
||||
}
|
||||
})
|
||||
|
||||
@@ -567,21 +566,21 @@ function bindEvents(page, nodeOk, detectState) {
|
||||
const input = page.querySelector('#input-node-path')
|
||||
const resultEl = page.querySelector('#scan-result')
|
||||
const dir = input?.value?.trim()
|
||||
if (!dir) { toast('请输入 Node.js 安装目录', 'warning'); return }
|
||||
if (!dir) { toast(t('setup.enterNodeDir'), 'warning'); return }
|
||||
resultEl.style.display = 'block'
|
||||
resultEl.innerHTML = '<span style="color:var(--text-tertiary)">检测中...</span>'
|
||||
resultEl.innerHTML = `<span style="color:var(--text-tertiary)">${t('setup.detecting2')}</span>`
|
||||
try {
|
||||
const result = await api.checkNodeAtPath(dir)
|
||||
if (result.installed) {
|
||||
await api.saveCustomNodePath(dir)
|
||||
resultEl.innerHTML = `<span style="color:var(--success)">✓ 找到 Node.js ${result.version},路径已保存</span>`
|
||||
toast('Node.js 路径已保存,正在重新检测...', 'success')
|
||||
resultEl.innerHTML = `<span style="color:var(--success)">✓ ${t('setup.nodeFoundSaved', { version: result.version })}</span>`
|
||||
toast(t('setup.nodeSaved'), 'success')
|
||||
setTimeout(() => runDetect(page), 300)
|
||||
} else {
|
||||
resultEl.innerHTML = `<span style="color:var(--warning)">该目录下未找到 node 可执行文件,请确认路径正确。</span>`
|
||||
resultEl.innerHTML = `<span style="color:var(--warning)">${t('setup.nodeNotFoundAtPath')}</span>`
|
||||
}
|
||||
} catch (e) {
|
||||
resultEl.innerHTML = `<span style="color:var(--danger)">检测失败: ${e}</span>`
|
||||
resultEl.innerHTML = `<span style="color:var(--danger)">${t('setup.checkFailed', { err: e })}</span>`
|
||||
}
|
||||
})
|
||||
|
||||
@@ -593,10 +592,10 @@ function bindEvents(page, nodeOk, detectState) {
|
||||
const sourceRadios = page.querySelectorAll('input[name="install-source"]')
|
||||
|
||||
const METHOD_HINTS = {
|
||||
'auto': '自动选择最优安装方式:优先使用独立安装包(零依赖、最快),失败时自动降级到 npm 编译安装。',
|
||||
'standalone-r2': '从晴辰云 CDN 下载独立安装包,自带 Node.js 运行时,无需 npm。国内下载速度最快。',
|
||||
'standalone-github': '从 GitHub Releases 下载独立安装包。CDN 不可用时的备选方案。',
|
||||
'npm': '传统的 npm install 方式,需要本机已安装 Node.js 和 npm,且网络能访问 npm 仓库。',
|
||||
'auto': t('setup.methodHintAuto'),
|
||||
'standalone-r2': t('setup.methodHintR2'),
|
||||
'standalone-github': t('setup.methodHintGithub'),
|
||||
'npm': t('setup.methodHintNpm'),
|
||||
}
|
||||
|
||||
function updateMethodVisibility() {
|
||||
@@ -624,7 +623,7 @@ function bindEvents(page, nodeOk, detectState) {
|
||||
const source = page.querySelector('input[name="install-source"]:checked')?.value || 'chinese'
|
||||
const method = (source === 'official') ? 'npm' : (page.querySelector('#install-method')?.value || 'auto')
|
||||
const registry = page.querySelector('#registry-select')?.value
|
||||
const modal = showUpgradeModal('安装 OpenClaw')
|
||||
const modal = showUpgradeModal(t('setup.installOpenclaw'))
|
||||
let unlistenLog, unlistenProgress
|
||||
|
||||
setUpgrading(true)
|
||||
@@ -648,15 +647,15 @@ function bindEvents(page, nodeOk, detectState) {
|
||||
// 后台任务完成:继续安装 Gateway + 自动配置
|
||||
unlistenDone = await listen('upgrade-done', async (e) => {
|
||||
cleanup()
|
||||
modal.setDone(typeof e.payload === 'string' ? e.payload : '安装完成')
|
||||
modal.setDone(typeof e.payload === 'string' ? e.payload : t('setup.installComplete'))
|
||||
|
||||
// 安装成功后自动安装 Gateway
|
||||
modal.appendLog('正在安装 Gateway 服务...')
|
||||
modal.appendLog(t('setup.installingGateway'))
|
||||
try {
|
||||
await api.installGateway()
|
||||
modal.appendHtmlLog(`${statusIcon('ok', 14)} Gateway 服务已安装`)
|
||||
modal.appendHtmlLog(`${statusIcon('ok', 14)} ${t('setup.gatewayInstalled')}`)
|
||||
} catch (ge) {
|
||||
modal.appendHtmlLog(`${statusIcon('warn', 14)} Gateway 安装失败: ${ge}`)
|
||||
modal.appendHtmlLog(`${statusIcon('warn', 14)} ${t('setup.gatewayInstallFailed', { err: ge })}`)
|
||||
}
|
||||
|
||||
// 确保 openclaw.json 有关键默认值
|
||||
@@ -668,7 +667,7 @@ function bindEvents(page, nodeOk, detectState) {
|
||||
if (!config.gateway.mode) {
|
||||
config.gateway.mode = 'local'
|
||||
patched = true
|
||||
modal.appendHtmlLog(`${statusIcon('ok', 14)} 已设置 Gateway 运行模式为 local`)
|
||||
modal.appendHtmlLog(`${statusIcon('ok', 14)} ${t('setup.gwModeSet')}`)
|
||||
}
|
||||
if (!config.tools || config.tools.profile !== 'full') {
|
||||
config.tools = { profile: 'full', sessions: { visibility: 'all' }, ...(config.tools || {}) }
|
||||
@@ -676,22 +675,22 @@ function bindEvents(page, nodeOk, detectState) {
|
||||
if (!config.tools.sessions) config.tools.sessions = {}
|
||||
config.tools.sessions.visibility = 'all'
|
||||
patched = true
|
||||
modal.appendHtmlLog(`${statusIcon('ok', 14)} 已开启 Agent 工具全部权限`)
|
||||
modal.appendHtmlLog(`${statusIcon('ok', 14)} ${t('setup.toolsFullEnabled')}`)
|
||||
}
|
||||
if (patched) await api.writeOpenclawConfig(config)
|
||||
}
|
||||
} catch (ce) {
|
||||
modal.appendHtmlLog(`${statusIcon('warn', 14)} 自动配置失败: ${ce}`)
|
||||
modal.appendHtmlLog(`${statusIcon('warn', 14)} ${t('setup.autoConfigFailed', { err: ce })}`)
|
||||
}
|
||||
|
||||
toast('OpenClaw 安装成功', 'success')
|
||||
toast(t('setup.installSuccess'), 'success')
|
||||
setTimeout(() => window.location.reload(), 1500)
|
||||
})
|
||||
|
||||
// 后台任务失败
|
||||
unlistenError = await listen('upgrade-error', async (e) => {
|
||||
cleanup()
|
||||
const errStr = String(e.payload || '未知错误')
|
||||
const errStr = String(e.payload || t('common.unknown'))
|
||||
modal.appendLog(errStr)
|
||||
await new Promise(r => setTimeout(r, 150))
|
||||
const fullLog = modal.getLogText() + '\n' + errStr
|
||||
@@ -701,29 +700,29 @@ function bindEvents(page, nodeOk, detectState) {
|
||||
if (diagnosis.hint) modal.appendHtmlLog(`${statusIcon('info', 14)} ${diagnosis.hint}`)
|
||||
if (diagnosis.command) modal.appendHtmlLog(`${icon('clipboard', 14)} ${diagnosis.command}`)
|
||||
if (window.__openAIDrawerWithError) {
|
||||
window.__openAIDrawerWithError({ title: diagnosis.title, error: fullLog, scene: '初始安装 OpenClaw', hint: diagnosis.hint })
|
||||
window.__openAIDrawerWithError({ title: diagnosis.title, error: fullLog, scene: t('setup.installScene'), hint: diagnosis.hint })
|
||||
}
|
||||
})
|
||||
|
||||
// 先设置镜像源
|
||||
if (registry) {
|
||||
modal.appendLog(`设置 npm 镜像源: ${registry}`)
|
||||
modal.appendLog(t('setup.setRegistry', { url: registry }))
|
||||
try { await api.setNpmRegistry(registry) } catch {}
|
||||
}
|
||||
|
||||
// 发起后台任务(立即返回)
|
||||
await api.upgradeOpenclaw(source, null, method)
|
||||
modal.appendLog('后台安装任务已启动,请等待完成...')
|
||||
modal.appendLog(t('setup.bgTaskStarted'))
|
||||
} else {
|
||||
// Web 模式:同步等待
|
||||
modal.appendLog('Web 模式:安装日志不可用,请等待完成...')
|
||||
modal.appendLog(t('setup.webModeLogHint'))
|
||||
if (registry) {
|
||||
modal.appendLog(`设置 npm 镜像源: ${registry}`)
|
||||
modal.appendLog(t('setup.setRegistry', { url: registry }))
|
||||
try { await api.setNpmRegistry(registry) } catch {}
|
||||
}
|
||||
const msg = await api.upgradeOpenclaw(source, null, method)
|
||||
modal.setDone(msg)
|
||||
toast('OpenClaw 安装成功', 'success')
|
||||
toast(t('setup.installSuccess'), 'success')
|
||||
setTimeout(() => window.location.reload(), 1500)
|
||||
cleanup()
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
import { api } from '../lib/tauri-api.js'
|
||||
import { toast } from '../components/toast.js'
|
||||
import { t } from '../lib/i18n.js'
|
||||
|
||||
let _loadSeq = 0
|
||||
|
||||
@@ -17,12 +18,12 @@ export async function render() {
|
||||
page.className = 'page'
|
||||
page.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Skills</h1>
|
||||
<p class="page-desc">管理已安装的 Skills,或从社区搜索安装新技能</p>
|
||||
<h1 class="page-title">${t('skills.title')}</h1>
|
||||
<p class="page-desc">${t('skills.desc')}</p>
|
||||
</div>
|
||||
<div class="tab-bar" id="skills-main-tabs">
|
||||
<div class="tab active" data-main-tab="installed">已安装</div>
|
||||
<div class="tab" data-main-tab="store">搜索安装</div>
|
||||
<div class="tab active" data-main-tab="installed">${t('skills.tabInstalled')}</div>
|
||||
<div class="tab" data-main-tab="store">${t('skills.tabStore')}</div>
|
||||
</div>
|
||||
<div id="skills-tab-installed" class="config-section">
|
||||
<div class="stat-card loading-placeholder" style="height:96px"></div>
|
||||
@@ -30,19 +31,19 @@ export async function render() {
|
||||
<div id="skills-tab-store" class="config-section" style="display:none">
|
||||
<div class="clawhub-toolbar" style="margin-bottom:var(--space-sm)">
|
||||
<select class="form-input" id="install-source-select" style="width:auto;min-width:160px">
|
||||
<option value="skillhub">SkillHub(国内加速)</option>
|
||||
<option value="clawhub">ClawHub(原版海外)</option>
|
||||
<option value="skillhub">${t('skills.sourceSkillHub')}</option>
|
||||
<option value="clawhub">${t('skills.sourceClawHub')}</option>
|
||||
</select>
|
||||
<input class="input clawhub-search-input" id="skill-install-search" placeholder="搜索技能,如 weather / github / tavily" type="text" style="flex:1">
|
||||
<button class="btn btn-primary btn-sm" data-action="install-source-search">搜索</button>
|
||||
<button class="btn btn-secondary btn-sm" data-action="skillhub-setup" id="btn-skillhub-setup" style="display:none">安装 CLI</button>
|
||||
<a class="btn btn-secondary btn-sm" id="btn-browse-source" href="https://skillhub.tencent.com" target="_blank" rel="noopener">浏览</a>
|
||||
<input class="input clawhub-search-input" id="skill-install-search" placeholder="${t('skills.searchPlaceholder')}" type="text" style="flex:1">
|
||||
<button class="btn btn-primary btn-sm" data-action="install-source-search">${t('skills.search')}</button>
|
||||
<button class="btn btn-secondary btn-sm" data-action="skillhub-setup" id="btn-skillhub-setup" style="display:none">${t('skills.installCLI')}</button>
|
||||
<a class="btn btn-secondary btn-sm" id="btn-browse-source" href="https://skillhub.tencent.com" target="_blank" rel="noopener">${t('skills.browse')}</a>
|
||||
</div>
|
||||
<div class="form-hint" id="store-hint" style="margin-bottom:var(--space-sm);display:flex;align-items:center;gap:var(--space-xs)">
|
||||
<span id="skillhub-status"></span>
|
||||
</div>
|
||||
<div id="install-source-results" class="clawhub-list" style="max-height:calc(100vh - 320px);overflow-y:auto">
|
||||
<div class="clawhub-empty" style="padding:var(--space-xl);text-align:center">输入关键词搜索社区 Skills,然后一键安装</div>
|
||||
<div class="clawhub-empty" style="padding:var(--space-xl);text-align:center">${t('skills.searchEmpty')}</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
@@ -58,7 +59,7 @@ async function loadSkills(page) {
|
||||
|
||||
el.innerHTML = `<div class="skills-loading-panel">
|
||||
<div class="stat-card loading-placeholder" style="height:96px"></div>
|
||||
<div class="form-hint" style="margin-top:8px">正在加载 Skills...</div>
|
||||
<div class="form-hint" style="margin-top:8px">${t('skills.loading')}</div>
|
||||
</div>`
|
||||
|
||||
try {
|
||||
@@ -68,9 +69,9 @@ async function loadSkills(page) {
|
||||
} catch (e) {
|
||||
if (seq !== _loadSeq) return
|
||||
el.innerHTML = `<div class="skills-load-error">
|
||||
<div style="color:var(--error);margin-bottom:8px">加载失败: ${esc(e?.message || e)}</div>
|
||||
<div class="form-hint" style="margin-bottom:10px">请确认 OpenClaw 已安装并可用</div>
|
||||
<button class="btn btn-secondary btn-sm" data-action="skill-retry">重试</button>
|
||||
<div style="color:var(--error);margin-bottom:8px">${t('skills.loadFailed')}: ${esc(e?.message || e)}</div>
|
||||
<div class="form-hint" style="margin-bottom:10px">${t('skills.loadFailedHint')}</div>
|
||||
<button class="btn btn-secondary btn-sm" data-action="skill-retry">${t('skills.retry')}</button>
|
||||
</div>`
|
||||
}
|
||||
}
|
||||
@@ -85,32 +86,32 @@ function renderSkills(el, data) {
|
||||
const disabled = skills.filter(s => s.disabled)
|
||||
const blocked = skills.filter(s => s.blockedByAllowlist && !s.disabled)
|
||||
|
||||
const summary = `${eligible.length} 可用 / ${missing.length} 缺依赖 / ${disabled.length} 已禁用`
|
||||
const summary = t('skills.summaryDetail', { eligible: eligible.length, missing: missing.length, disabled: disabled.length })
|
||||
let sourceHint = ''
|
||||
if (source === 'local-scan') {
|
||||
if (cliDiag?.status === 'timeout') sourceHint = 'CLI 可用,但本次调用超时,当前显示本地扫描结果'
|
||||
else if (cliDiag?.status === 'parse-failed') sourceHint = 'CLI 可用,但返回结果解析失败,当前显示本地扫描结果'
|
||||
else if (cliDiag?.status === 'exec-failed') sourceHint = 'CLI 调用失败,当前显示本地扫描结果'
|
||||
else sourceHint = cliAvailable ? '当前显示本地扫描结果' : 'CLI 不可用,当前显示本地扫描结果'
|
||||
if (cliDiag?.status === 'timeout') sourceHint = t('skills.sourceLocalScanTimeout')
|
||||
else if (cliDiag?.status === 'parse-failed') sourceHint = t('skills.sourceLocalScanParseFailed')
|
||||
else if (cliDiag?.status === 'exec-failed') sourceHint = t('skills.sourceLocalScanExecFailed')
|
||||
else sourceHint = cliAvailable ? t('skills.sourceLocalScan') : t('skills.sourceLocalScanNoCli')
|
||||
} else if (cliAvailable) {
|
||||
sourceHint = '当前已使用 OpenClaw CLI 结果'
|
||||
sourceHint = t('skills.sourceCLI')
|
||||
}
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="clawhub-toolbar">
|
||||
<input class="input clawhub-search-input" id="skill-filter-input" placeholder="过滤 Skills..." type="text">
|
||||
<button class="btn btn-secondary btn-sm" data-action="skill-retry">刷新</button>
|
||||
<input class="input clawhub-search-input" id="skill-filter-input" placeholder="${t('skills.filterPlaceholder')}" type="text">
|
||||
<button class="btn btn-secondary btn-sm" data-action="skill-retry">${t('skills.refresh')}</button>
|
||||
<a class="btn btn-secondary btn-sm" href="https://clawhub.ai/skills" target="_blank" rel="noopener">ClawHub</a>
|
||||
${sourceHint ? `<span class="form-hint" style="margin-left:auto;color:${source === 'local-scan' ? 'var(--warning)' : 'var(--text-tertiary)'}">${esc(sourceHint)}</span>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="skills-summary" style="margin-bottom:var(--space-lg);color:var(--text-secondary);font-size:var(--font-size-sm)">
|
||||
共 ${skills.length} 个 Skills: ${summary}
|
||||
${t('skills.summary', { total: skills.length, detail: summary })}
|
||||
</div>
|
||||
|
||||
${eligible.length ? `
|
||||
<div class="clawhub-panel" style="margin-bottom:var(--space-lg)">
|
||||
<div class="clawhub-panel-title" style="color:var(--success)">✓ 可用 (${eligible.length})</div>
|
||||
<div class="clawhub-panel-title" style="color:var(--success)">${t('skills.eligibleGroup')} (${eligible.length})</div>
|
||||
<div class="clawhub-list skills-scroll-area skills-trending-scroll" id="skills-eligible">
|
||||
${eligible.map(s => renderSkillCard(s, 'eligible')).join('')}
|
||||
</div>
|
||||
@@ -119,8 +120,8 @@ function renderSkills(el, data) {
|
||||
${missing.length ? `
|
||||
<div class="clawhub-panel" style="margin-bottom:var(--space-lg)">
|
||||
<div class="clawhub-panel-title" style="color:var(--warning);display:flex;align-items:center;gap:var(--space-sm)">
|
||||
<span>✗ 缺少依赖 (${missing.length})</span>
|
||||
<button class="btn btn-secondary btn-sm" data-action="skill-ai-fix" style="font-size:var(--font-size-xs);padding:2px 8px">让 AI 助手帮我安装</button>
|
||||
<span>${t('skills.missingGroup')} (${missing.length})</span>
|
||||
<button class="btn btn-secondary btn-sm" data-action="skill-ai-fix" style="font-size:var(--font-size-xs);padding:2px 8px">${t('skills.aiFixBtn')}</button>
|
||||
</div>
|
||||
<div class="clawhub-list skills-scroll-area skills-installed-scroll" id="skills-missing">
|
||||
${missing.map(s => renderSkillCard(s, 'missing')).join('')}
|
||||
@@ -129,7 +130,7 @@ function renderSkills(el, data) {
|
||||
|
||||
${disabled.length ? `
|
||||
<div class="clawhub-panel" style="margin-bottom:var(--space-lg)">
|
||||
<div class="clawhub-panel-title" style="color:var(--text-tertiary)">⏸ 已禁用 (${disabled.length})</div>
|
||||
<div class="clawhub-panel-title" style="color:var(--text-tertiary)">${t('skills.disabledGroup')} (${disabled.length})</div>
|
||||
<div class="clawhub-list skills-scroll-area skills-search-scroll" id="skills-disabled">
|
||||
${disabled.map(s => renderSkillCard(s, 'disabled')).join('')}
|
||||
</div>
|
||||
@@ -137,7 +138,7 @@ function renderSkills(el, data) {
|
||||
|
||||
${blocked.length ? `
|
||||
<div class="clawhub-panel" style="margin-bottom:var(--space-lg)">
|
||||
<div class="clawhub-panel-title" style="color:var(--text-tertiary)">🚫 白名单阻止 (${blocked.length})</div>
|
||||
<div class="clawhub-panel-title" style="color:var(--text-tertiary)">${t('skills.blockedGroup')} (${blocked.length})</div>
|
||||
<div class="clawhub-list">
|
||||
${blocked.map(s => renderSkillCard(s, 'blocked')).join('')}
|
||||
</div>
|
||||
@@ -146,8 +147,8 @@ function renderSkills(el, data) {
|
||||
${!skills.length ? `
|
||||
<div class="clawhub-panel">
|
||||
<div class="clawhub-empty" style="text-align:center;padding:var(--space-xl)">
|
||||
<div style="margin-bottom:var(--space-sm)">未检测到任何 Skills</div>
|
||||
<div class="form-hint">请确认 OpenClaw 已正确安装。Skills 随 OpenClaw 捆绑提供;自定义 Skills 可能位于 <code>~/.openclaw/skills/</code> 或 <code>~/.claude/skills/</code>。</div>
|
||||
<div style="margin-bottom:var(--space-sm)">${t('skills.noSkills')}</div>
|
||||
<div class="form-hint">${t('skills.noSkillsHint')}</div>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
@@ -172,22 +173,22 @@ function renderSkillCard(skill, status) {
|
||||
const emoji = skill.emoji || '📦'
|
||||
const name = skill.name || ''
|
||||
const desc = skill.description || ''
|
||||
const source = skill.bundled ? '捆绑' : (skill.source || '自定义')
|
||||
const source = skill.bundled ? t('skills.bundled') : (skill.source || t('skills.custom'))
|
||||
const missingBins = skill.missing?.bins || []
|
||||
const missingEnv = skill.missing?.env || []
|
||||
const missingConfig = skill.missing?.config || []
|
||||
const installOpts = skill.install || []
|
||||
|
||||
let statusBadge = ''
|
||||
if (status === 'eligible') statusBadge = '<span class="clawhub-badge installed">可用</span>'
|
||||
else if (status === 'missing') statusBadge = '<span class="clawhub-badge" style="background:rgba(245,158,11,0.14);color:#d97706">缺依赖</span>'
|
||||
else if (status === 'disabled') statusBadge = '<span class="clawhub-badge" style="background:rgba(107,114,128,0.14);color:#6b7280">已禁用</span>'
|
||||
else if (status === 'blocked') statusBadge = '<span class="clawhub-badge" style="background:rgba(239,68,68,0.14);color:#ef4444">已阻止</span>'
|
||||
if (status === 'eligible') statusBadge = `<span class="clawhub-badge installed">${t('skills.eligible')}</span>`
|
||||
else if (status === 'missing') statusBadge = `<span class="clawhub-badge" style="background:rgba(245,158,11,0.14);color:#d97706">${t('skills.missingDeps')}</span>`
|
||||
else if (status === 'disabled') statusBadge = `<span class="clawhub-badge" style="background:rgba(107,114,128,0.14);color:#6b7280">${t('skills.disabled')}</span>`
|
||||
else if (status === 'blocked') statusBadge = `<span class="clawhub-badge" style="background:rgba(239,68,68,0.14);color:#ef4444">${t('skills.blocked')}</span>`
|
||||
|
||||
let missingHtml = ''
|
||||
if (missingBins.length) missingHtml += `<div class="form-hint" style="margin-top:4px">缺少命令: ${missingBins.map(b => `<code>${esc(b)}</code>`).join(', ')}</div>`
|
||||
if (missingEnv.length) missingHtml += `<div class="form-hint" style="margin-top:4px">缺少环境变量: ${missingEnv.map(e => `<code>${esc(e)}</code>`).join(', ')} <span style="color:var(--text-tertiary);font-size:var(--font-size-xs)">— 需在系统环境变量中配置</span></div>`
|
||||
if (missingConfig.length) missingHtml += `<div class="form-hint" style="margin-top:4px">缺少配置: ${missingConfig.map(c => `<code>${esc(c)}</code>`).join(', ')} <span style="color:var(--text-tertiary);font-size:var(--font-size-xs)">— 需在 openclaw.json 中配置</span></div>`
|
||||
if (missingBins.length) missingHtml += `<div class="form-hint" style="margin-top:4px">${t('skills.missingCmd')}: ${missingBins.map(b => `<code>${esc(b)}</code>`).join(', ')}</div>`
|
||||
if (missingEnv.length) missingHtml += `<div class="form-hint" style="margin-top:4px">${t('skills.missingEnv')}: ${missingEnv.map(e => `<code>${esc(e)}</code>`).join(', ')} <span style="color:var(--text-tertiary);font-size:var(--font-size-xs)">${t('skills.missingEnvHint')}</span></div>`
|
||||
if (missingConfig.length) missingHtml += `<div class="form-hint" style="margin-top:4px">${t('skills.missingConfig')}: ${missingConfig.map(c => `<code>${esc(c)}</code>`).join(', ')} <span style="color:var(--text-tertiary);font-size:var(--font-size-xs)">${t('skills.missingConfigHint')}</span></div>`
|
||||
|
||||
let installHtml = ''
|
||||
if (status === 'missing') {
|
||||
@@ -196,7 +197,7 @@ function renderSkillCard(skill, status) {
|
||||
`<button class="btn btn-primary btn-sm" style="margin-right:6px;margin-top:4px" data-action="skill-install-dep" data-kind="${esc(opt.kind)}" data-install='${esc(JSON.stringify(opt))}' data-skill-name="${esc(name)}">${esc(opt.label)}</button>`
|
||||
).join('')}</div>`
|
||||
} else if (missingBins.length && !missingEnv.length && !missingConfig.length) {
|
||||
installHtml = `<div class="form-hint" style="margin-top:6px;color:var(--text-tertiary);font-size:var(--font-size-xs)">无自动安装选项,请手动安装: ${missingBins.map(b => `<code>brew install ${esc(b)}</code> 或 <code>npm i -g ${esc(b)}</code>`).join(' / ')}</div>`
|
||||
installHtml = `<div class="form-hint" style="margin-top:6px;color:var(--text-tertiary);font-size:var(--font-size-xs)">${t('skills.noAutoInstall')}: ${missingBins.map(b => `<code>brew install ${esc(b)}</code> / <code>npm i -g ${esc(b)}</code>`).join(' / ')}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,8 +211,8 @@ function renderSkillCard(skill, status) {
|
||||
${installHtml}
|
||||
</div>
|
||||
<div class="clawhub-item-actions">
|
||||
<button class="btn btn-secondary btn-sm" data-action="skill-info" data-name="${esc(name)}">详情</button>
|
||||
${!skill.bundled ? `<button class="btn btn-sm" style="color:var(--error);border:1px solid var(--error);background:transparent;font-size:var(--font-size-xs)" data-action="skill-uninstall" data-name="${esc(name)}">卸载</button>` : ''}
|
||||
<button class="btn btn-secondary btn-sm" data-action="skill-info" data-name="${esc(name)}">${t('skills.detail')}</button>
|
||||
${!skill.bundled ? `<button class="btn btn-sm" style="color:var(--error);border:1px solid var(--error);background:transparent;font-size:var(--font-size-xs)" data-action="skill-uninstall" data-name="${esc(name)}">${t('skills.uninstall')}</button>` : ''}
|
||||
${statusBadge}
|
||||
</div>
|
||||
</div>
|
||||
@@ -221,7 +222,7 @@ function renderSkillCard(skill, status) {
|
||||
async function handleInfo(page, name) {
|
||||
const detail = page.querySelector('#skill-detail-area')
|
||||
if (!detail) return
|
||||
detail.innerHTML = '<div class="form-hint" style="margin-top:var(--space-md)">正在加载详情...</div>'
|
||||
detail.innerHTML = `<div class="form-hint" style="margin-top:var(--space-md)">${t('skills.loadingDetail')}</div>`
|
||||
detail.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
try {
|
||||
const skill = await api.skillsInfo(name)
|
||||
@@ -231,13 +232,13 @@ async function handleInfo(page, name) {
|
||||
|
||||
let reqsHtml = ''
|
||||
if (reqs.bins?.length) {
|
||||
reqsHtml += `<div style="margin-top:8px"><strong>需要命令:</strong> ${reqs.bins.map(b => {
|
||||
reqsHtml += `<div style="margin-top:8px"><strong>${t('skills.reqBins')}:</strong> ${reqs.bins.map(b => {
|
||||
const ok = !(miss.bins || []).includes(b)
|
||||
return `<code style="color:var(--${ok ? 'success' : 'error'})">${ok ? '✓' : '✗'} ${esc(b)}</code>`
|
||||
}).join(' ')}</div>`
|
||||
}
|
||||
if (reqs.env?.length) {
|
||||
reqsHtml += `<div style="margin-top:4px"><strong>环境变量:</strong> ${reqs.env.map(e => {
|
||||
reqsHtml += `<div style="margin-top:4px"><strong>${t('skills.reqEnv')}:</strong> ${reqs.env.map(e => {
|
||||
const ok = !(miss.env || []).includes(e)
|
||||
return `<code style="color:var(--${ok ? 'success' : 'error'})">${ok ? '✓' : '✗'} ${esc(e)}</code>`
|
||||
}).join(' ')}</div>`
|
||||
@@ -247,16 +248,16 @@ async function handleInfo(page, name) {
|
||||
<div class="clawhub-detail-card">
|
||||
<div class="clawhub-detail-title">${esc(s.emoji || '📦')} ${esc(s.name || name)}</div>
|
||||
<div class="clawhub-detail-meta">
|
||||
来源: ${esc(s.source || '')} · 路径: <code>${esc(s.filePath || '')}</code>
|
||||
${t('skills.detailSource')}: ${esc(s.source || '')} · ${t('skills.detailPath')}: <code>${esc(s.filePath || '')}</code>
|
||||
${s.homepage ? ` · <a href="${esc(s.homepage)}" target="_blank" rel="noopener">${esc(s.homepage)}</a>` : ''}
|
||||
</div>
|
||||
<div class="clawhub-detail-desc" style="margin-top:8px">${esc(s.description || '')}</div>
|
||||
${reqsHtml}
|
||||
${(s.install || []).length && !s.eligible ? `<div style="margin-top:8px"><strong>安装选项:</strong> ${s.install.map(i => `<span class="form-hint">→ ${esc(i.label)}</span>`).join(' ')}</div>` : ''}
|
||||
${(s.install || []).length && !s.eligible ? `<div style="margin-top:8px"><strong>${t('skills.installOptions')}:</strong> ${s.install.map(i => `<span class="form-hint">→ ${esc(i.label)}</span>`).join(' ')}</div>` : ''}
|
||||
</div>
|
||||
`
|
||||
} catch (e) {
|
||||
detail.innerHTML = `<div style="color:var(--error);margin-top:var(--space-md)">加载详情失败: ${esc(e?.message || e)}</div>`
|
||||
detail.innerHTML = `<div style="color:var(--error);margin-top:var(--space-md)">${t('skills.detailLoadFailed')}: ${esc(e?.message || e)}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,15 +267,15 @@ async function handleInstallDep(page, btn) {
|
||||
try { spec = JSON.parse(btn.dataset.install) } catch { spec = {} }
|
||||
const skillName = btn.dataset.skillName || ''
|
||||
btn.disabled = true
|
||||
btn.textContent = '安装中...'
|
||||
btn.textContent = t('skills.installing')
|
||||
try {
|
||||
await api.skillsInstallDep(kind, spec)
|
||||
toast(`${skillName} 依赖安装成功`, 'success')
|
||||
toast(t('skills.depInstalled', { name: skillName }), 'success')
|
||||
await loadSkills(page)
|
||||
} catch (e) {
|
||||
toast(`安装失败: ${e?.message || e}`, 'error')
|
||||
toast(`${t('skills.installFailed')}: ${e?.message || e}`, 'error')
|
||||
btn.disabled = false
|
||||
btn.textContent = spec.label || '重试'
|
||||
btn.textContent = spec.label || t('skills.retry')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,7 +290,7 @@ async function handleSourceSearch(page) {
|
||||
const results = page.querySelector('#install-source-results')
|
||||
if (!input || !results) return
|
||||
const q = input.value.trim()
|
||||
if (!q) { results.innerHTML = '<div class="clawhub-empty">输入关键词搜索社区 Skills</div>'; return }
|
||||
if (!q) { results.innerHTML = `<div class="clawhub-empty">${t('skills.searchKeyword')}</div>`; return }
|
||||
const source = getInstallSource()
|
||||
// SkillHub 未安装时友好提示(先实时检测一次,避免竞态误判)
|
||||
if (source === 'skillhub' && !_skillhubInstalled) {
|
||||
@@ -300,16 +301,16 @@ async function handleSourceSearch(page) {
|
||||
}
|
||||
if (source === 'skillhub' && !_skillhubInstalled) {
|
||||
results.innerHTML = `<div style="padding:var(--space-lg);text-align:center">
|
||||
<div style="color:var(--warning);margin-bottom:8px">⚠️ 请先安装 SkillHub CLI</div>
|
||||
<div class="form-hint" style="margin-bottom:12px">点击上方「安装 CLI」按钮,或切换到 ClawHub 源搜索</div>
|
||||
<button class="btn btn-primary btn-sm" data-action="skillhub-setup">一键安装 SkillHub CLI</button>
|
||||
<div style="color:var(--warning);margin-bottom:8px">${t('skills.skillhubNeedCLI')}</div>
|
||||
<div class="form-hint" style="margin-bottom:12px">${t('skills.skillhubNeedCLIHint')}</div>
|
||||
<button class="btn btn-primary btn-sm" data-action="skillhub-setup">${t('skills.skillhubSetup')}</button>
|
||||
</div>`
|
||||
return
|
||||
}
|
||||
results.innerHTML = '<div class="form-hint">正在搜索...</div>'
|
||||
results.innerHTML = `<div class="form-hint">${t('skills.searching')}</div>`
|
||||
try {
|
||||
const items = source === 'skillhub' ? await api.skillsSkillHubSearch(q) : await api.skillsClawHubSearch(q)
|
||||
if (!items?.length) { results.innerHTML = '<div class="clawhub-empty">没有找到匹配的 Skill</div>'; return }
|
||||
if (!items?.length) { results.innerHTML = `<div class="clawhub-empty">${t('skills.noResults')}</div>`; return }
|
||||
const installAction = source === 'skillhub' ? 'source-install-skillhub' : 'source-install-clawhub'
|
||||
results.innerHTML = items.map(item => `
|
||||
<div class="clawhub-item">
|
||||
@@ -318,7 +319,7 @@ async function handleSourceSearch(page) {
|
||||
<div class="clawhub-item-desc">${esc(item.description || item.summary || '')}</div>
|
||||
</div>
|
||||
<div class="clawhub-item-actions">
|
||||
<button class="btn btn-primary btn-sm" data-action="${installAction}" data-slug="${esc(item.slug || item.name || '')}">安装</button>
|
||||
<button class="btn btn-primary btn-sm" data-action="${installAction}" data-slug="${esc(item.slug || item.name || '')}">${t('skills.install')}</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')
|
||||
@@ -327,11 +328,11 @@ async function handleSourceSearch(page) {
|
||||
const isRateLimit = /rate.?limit|429|too many/i.test(errMsg)
|
||||
if (isRateLimit) {
|
||||
results.innerHTML = `<div style="padding:var(--space-lg);text-align:center">
|
||||
<div style="color:var(--warning);margin-bottom:8px">⚠️ 请求频率超限</div>
|
||||
<div class="form-hint">${source === 'clawhub' ? 'ClawHub 海外源限流,建议切换到 SkillHub(国内加速)' : '请稍后再试'}</div>
|
||||
<div style="color:var(--warning);margin-bottom:8px">${t('skills.rateLimited')}</div>
|
||||
<div class="form-hint">${source === 'clawhub' ? t('skills.rateLimitClawHub') : t('skills.rateLimitRetry')}</div>
|
||||
</div>`
|
||||
} else {
|
||||
results.innerHTML = `<div style="color:var(--error);padding:var(--space-sm)">搜索失败: ${esc(errMsg)}</div>`
|
||||
results.innerHTML = `<div style="color:var(--error);padding:var(--space-sm)">${t('skills.searchFailed')}: ${esc(errMsg)}</div>`
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -339,54 +340,54 @@ async function handleSourceSearch(page) {
|
||||
async function handleSourceInstall(page, btn, source) {
|
||||
const slug = btn.dataset.slug
|
||||
btn.disabled = true
|
||||
btn.textContent = '安装中...'
|
||||
btn.textContent = t('skills.installing')
|
||||
try {
|
||||
if (source === 'skillhub') await api.skillsSkillHubInstall(slug)
|
||||
else await api.skillsClawHubInstall(slug)
|
||||
toast(`Skill ${slug} 安装成功`, 'success')
|
||||
btn.textContent = '已安装'
|
||||
toast(t('skills.skillInstalled', { name: slug }), 'success')
|
||||
btn.textContent = t('skills.installed')
|
||||
btn.classList.remove('btn-primary')
|
||||
btn.classList.add('btn-secondary')
|
||||
// 后台刷新已安装列表(不阻塞 UI)
|
||||
loadSkills(page).catch(() => {})
|
||||
} catch (e) {
|
||||
toast(`安装失败: ${e?.message || e}`, 'error')
|
||||
toast(`${t('skills.installFailed')}: ${e?.message || e}`, 'error')
|
||||
btn.disabled = false
|
||||
btn.textContent = '安装'
|
||||
btn.textContent = t('skills.install')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSkillUninstall(page, btn) {
|
||||
const name = btn.dataset.name
|
||||
if (!name) return
|
||||
if (!confirm(`确定卸载 Skill「${name}」?`)) return
|
||||
if (!confirm(t('skills.confirmUninstall', { name }))) return
|
||||
btn.disabled = true
|
||||
btn.textContent = '卸载中...'
|
||||
btn.textContent = t('skills.uninstalling')
|
||||
try {
|
||||
await api.skillsUninstall(name)
|
||||
toast(`已卸载 ${name}`, 'success')
|
||||
toast(t('skills.uninstalled', { name }), 'success')
|
||||
await loadSkills(page)
|
||||
} catch (e) {
|
||||
toast(`卸载失败: ${e?.message || e}`, 'error')
|
||||
toast(`${t('skills.uninstallFailed')}: ${e?.message || e}`, 'error')
|
||||
btn.disabled = false
|
||||
btn.textContent = '卸载'
|
||||
btn.textContent = t('skills.uninstall')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSkillHubSetup(page) {
|
||||
const statusEl = page.querySelector('#skillhub-status')
|
||||
if (statusEl) statusEl.textContent = '正在安装 SkillHub CLI...'
|
||||
if (statusEl) statusEl.textContent = t('skills.skillhubInstalling')
|
||||
try {
|
||||
await api.skillsSkillHubSetup(true)
|
||||
_skillhubInstalled = true
|
||||
toast('SkillHub CLI 安装成功', 'success')
|
||||
if (statusEl) statusEl.textContent = '✅ 已安装'
|
||||
toast(t('skills.skillhubInstalled'), 'success')
|
||||
if (statusEl) statusEl.textContent = '✅'
|
||||
// 隐藏安装按钮
|
||||
const setupBtn = page.querySelector('#btn-skillhub-setup')
|
||||
if (setupBtn) setupBtn.style.display = 'none'
|
||||
} catch (e) {
|
||||
toast(`SkillHub CLI 安装失败: ${e?.message || e}`, 'error')
|
||||
if (statusEl) statusEl.textContent = '❌ 安装失败'
|
||||
toast(`${t('skills.skillhubInstallFailed')}: ${e?.message || e}`, 'error')
|
||||
if (statusEl) statusEl.textContent = '❌'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,7 +402,7 @@ async function checkSkillHubStatus(page) {
|
||||
statusEl.innerHTML = `<span style="color:var(--success)">✅ v${info.version}</span>`
|
||||
if (setupBtn) setupBtn.style.display = 'none'
|
||||
} else {
|
||||
statusEl.innerHTML = '<span style="color:var(--warning)">⚠️ 未安装 CLI</span>'
|
||||
statusEl.innerHTML = `<span style="color:var(--warning)">${t('skills.skillhubNeedCLI')}</span>`
|
||||
if (setupBtn && _installSource === 'skillhub') setupBtn.style.display = ''
|
||||
}
|
||||
} catch {
|
||||
@@ -414,7 +415,7 @@ function switchInstallSource(page, source) {
|
||||
const results = page.querySelector('#install-source-results')
|
||||
const setupBtn = page.querySelector('#btn-skillhub-setup')
|
||||
const browseBtn = page.querySelector('#btn-browse-source')
|
||||
if (results) results.innerHTML = '<div class="clawhub-empty">输入关键词搜索社区 Skills</div>'
|
||||
if (results) results.innerHTML = `<div class="clawhub-empty">${t('skills.searchKeyword')}</div>`
|
||||
if (source === 'skillhub') {
|
||||
if (browseBtn) browseBtn.href = 'https://skillhub.tencent.com'
|
||||
checkSkillHubStatus(page)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { wsClient } from '../lib/ws-client.js'
|
||||
import { toast } from '../components/toast.js'
|
||||
import { icon } from '../lib/icons.js'
|
||||
import { t } from '../lib/i18n.js'
|
||||
|
||||
let _page = null, _unsubReady = null
|
||||
|
||||
@@ -15,14 +16,14 @@ export async function render() {
|
||||
|
||||
page.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">使用情况</h1>
|
||||
<p class="page-desc">查看 Token 消耗、API 费用和模型使用统计</p>
|
||||
<h1 class="page-title">${t('usage.title')}</h1>
|
||||
<p class="page-desc">${t('usage.desc')}</p>
|
||||
</div>
|
||||
<div class="usage-toolbar" style="display:flex;gap:8px;align-items:center;margin-bottom:var(--space-lg);flex-wrap:wrap">
|
||||
<button class="btn btn-sm ${_days === 1 ? 'btn-primary' : 'btn-secondary'}" data-days="1">今天</button>
|
||||
<button class="btn btn-sm ${_days === 7 ? 'btn-primary' : 'btn-secondary'}" data-days="7">7天</button>
|
||||
<button class="btn btn-sm ${_days === 30 ? 'btn-primary' : 'btn-secondary'}" data-days="30">30天</button>
|
||||
<button class="btn btn-sm btn-secondary" id="btn-usage-refresh">${icon('refresh-cw', 14)} 刷新</button>
|
||||
<button class="btn btn-sm ${_days === 1 ? 'btn-primary' : 'btn-secondary'}" data-days="1">${t('usage.today')}</button>
|
||||
<button class="btn btn-sm ${_days === 7 ? 'btn-primary' : 'btn-secondary'}" data-days="7">${t('usage.days7')}</button>
|
||||
<button class="btn btn-sm ${_days === 30 ? 'btn-primary' : 'btn-secondary'}" data-days="30">${t('usage.days30')}</button>
|
||||
<button class="btn btn-sm btn-secondary" id="btn-usage-refresh">${icon('refresh-cw', 14)} ${t('usage.refresh')}</button>
|
||||
</div>
|
||||
<div id="usage-content">
|
||||
<div class="stat-card loading-placeholder" style="height:120px"></div>
|
||||
@@ -57,8 +58,8 @@ async function loadUsage(page) {
|
||||
|
||||
if (!wsClient.connected) {
|
||||
el.innerHTML = `<div class="usage-empty">
|
||||
<div style="color:var(--text-tertiary);margin-bottom:8px">Gateway 连接中...</div>
|
||||
<div class="form-hint">等待 Gateway 连接就绪后自动加载</div>
|
||||
<div style="color:var(--text-tertiary);margin-bottom:8px">${t('usage.gwConnecting')}</div>
|
||||
<div class="form-hint">${t('usage.gwWait')}</div>
|
||||
</div>`
|
||||
// 自动等待连接就绪后重试
|
||||
if (_unsubReady) _unsubReady()
|
||||
@@ -77,17 +78,17 @@ async function loadUsage(page) {
|
||||
renderUsage(el, data)
|
||||
} catch (e) {
|
||||
el.innerHTML = `<div class="usage-empty">
|
||||
<div style="color:var(--error);margin-bottom:8px">加载失败: ${esc(e?.message || e)}</div>
|
||||
<div class="form-hint">可能需要更新 OpenClaw 到 2026.3.11+ 以支持 Usage API</div>
|
||||
<button class="btn btn-secondary btn-sm" style="margin-top:8px" onclick="this.closest('.page').querySelector('#btn-usage-refresh').click()">重试</button>
|
||||
<div style="color:var(--error);margin-bottom:8px">${t('usage.loadFailed')}: ${esc(e?.message || e)}</div>
|
||||
<div class="form-hint">${t('usage.loadFailedHint')}</div>
|
||||
<button class="btn btn-secondary btn-sm" style="margin-top:8px" onclick="this.closest('.page').querySelector('#btn-usage-refresh').click()">${t('usage.retry')}</button>
|
||||
</div>`
|
||||
}
|
||||
}
|
||||
|
||||
function renderUsage(el, data) {
|
||||
if (!data) { el.innerHTML = '<div class="usage-empty">暂无数据</div>'; return }
|
||||
if (!data) { el.innerHTML = `<div class="usage-empty">${t('usage.noData')}</div>`; return }
|
||||
|
||||
const t = data.totals || {}
|
||||
const totals = data.totals || {}
|
||||
const a = data.aggregates || {}
|
||||
const msgs = a.messages || {}
|
||||
const tools = a.tools || {}
|
||||
@@ -109,32 +110,32 @@ function renderUsage(el, data) {
|
||||
const overviewHtml = `
|
||||
<div class="stat-cards" style="margin-bottom:var(--space-lg)">
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-header"><span class="stat-card-label">消息</span></div>
|
||||
<div class="stat-card-header"><span class="stat-card-label">${t('usage.messages')}</span></div>
|
||||
<div class="stat-card-value">${msgs.total || 0}</div>
|
||||
<div class="stat-card-meta">${msgs.user || 0} 用户 · ${msgs.assistant || 0} 助手</div>
|
||||
<div class="stat-card-meta">${msgs.user || 0} ${t('usage.userMsgs')} · ${msgs.assistant || 0} ${t('usage.assistantMsgs')}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-header"><span class="stat-card-label">工具调用</span></div>
|
||||
<div class="stat-card-header"><span class="stat-card-label">${t('usage.toolCalls')}</span></div>
|
||||
<div class="stat-card-value">${tools.totalCalls || 0}</div>
|
||||
<div class="stat-card-meta">${tools.uniqueTools || 0} 种工具</div>
|
||||
<div class="stat-card-meta">${t('usage.toolKinds', { count: tools.uniqueTools || 0 })}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-header"><span class="stat-card-label">错误</span></div>
|
||||
<div class="stat-card-header"><span class="stat-card-label">${t('usage.errors')}</span></div>
|
||||
<div class="stat-card-value">${msgs.errors || 0}</div>
|
||||
<div class="stat-card-meta">错误率 ${fmtRate(msgs.errors, msgs.total)}</div>
|
||||
<div class="stat-card-meta">${t('usage.errorRate')} ${fmtRate(msgs.errors, msgs.total)}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-header"><span class="stat-card-label">Token 总量</span></div>
|
||||
<div class="stat-card-value">${fmtTokens(t.totalTokens)}</div>
|
||||
<div class="stat-card-meta">${fmtTokens(t.input)} 输入 · ${fmtTokens(t.output)} 输出</div>
|
||||
<div class="stat-card-header"><span class="stat-card-label">${t('usage.totalTokens')}</span></div>
|
||||
<div class="stat-card-value">${fmtTokens(totals.totalTokens)}</div>
|
||||
<div class="stat-card-meta">${fmtTokens(totals.input)} ${t('usage.input')} · ${fmtTokens(totals.output)} ${t('usage.output')}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-header"><span class="stat-card-label">费用</span></div>
|
||||
<div class="stat-card-value">${fmtCost(t.totalCost)}</div>
|
||||
<div class="stat-card-meta">${fmtCost(t.inputCost)} 输入 · ${fmtCost(t.outputCost)} 输出</div>
|
||||
<div class="stat-card-header"><span class="stat-card-label">${t('usage.cost')}</span></div>
|
||||
<div class="stat-card-value">${fmtCost(totals.totalCost)}</div>
|
||||
<div class="stat-card-meta">${fmtCost(totals.inputCost)} ${t('usage.input')} · ${fmtCost(totals.outputCost)} ${t('usage.output')}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-header"><span class="stat-card-label">会话</span></div>
|
||||
<div class="stat-card-header"><span class="stat-card-label">${t('usage.sessions')}</span></div>
|
||||
<div class="stat-card-value">${(data.sessions || []).length}</div>
|
||||
<div class="stat-card-meta">${data.startDate || ''} ~ ${data.endDate || ''}</div>
|
||||
</div>
|
||||
@@ -142,7 +143,7 @@ function renderUsage(el, data) {
|
||||
`
|
||||
|
||||
// ── Top 排行 ──
|
||||
const renderTop = (title, items, keyFn, valueFn, metaFn) => {
|
||||
const renderTop = (title, items, keyFn, valueFn) => {
|
||||
if (!items || !items.length) return ''
|
||||
const rows = items.slice(0, 5).map(item => `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid var(--border-primary)">
|
||||
@@ -158,15 +159,15 @@ function renderUsage(el, data) {
|
||||
`
|
||||
}
|
||||
|
||||
const topModels = renderTop('热门模型',
|
||||
a.byModel, m => m.model || '未知', m => fmtCost(m.totals?.totalCost) + ' · ' + fmtTokens(m.totals?.totalTokens))
|
||||
const topProviders = renderTop('热门服务商',
|
||||
a.byProvider, p => p.provider || '未知', p => fmtCost(p.totals?.totalCost) + ' · ' + p.count + ' 次')
|
||||
const topTools = renderTop('热门工具',
|
||||
(tools.tools || []), t => t.name, t => t.count + ' 次调用')
|
||||
const topAgents = renderTop('热门 Agent',
|
||||
a.byAgent, a => a.agentId || 'main', a => fmtCost(a.totals?.totalCost))
|
||||
const topChannels = renderTop('热门渠道',
|
||||
const topModels = renderTop(t('usage.topModels'),
|
||||
a.byModel, m => m.model || t('usage.unknownModel'), m => fmtCost(m.totals?.totalCost) + ' · ' + fmtTokens(m.totals?.totalTokens))
|
||||
const topProviders = renderTop(t('usage.topProviders'),
|
||||
a.byProvider, p => p.provider || t('usage.unknownProvider'), p => fmtCost(p.totals?.totalCost) + ' · ' + t('usage.times', { count: p.count }))
|
||||
const topTools = renderTop(t('usage.topTools'),
|
||||
(tools.tools || []), item => item.name, item => t('usage.timesCall', { count: item.count }))
|
||||
const topAgents = renderTop(t('usage.topAgents'),
|
||||
a.byAgent, item => item.agentId || 'main', item => fmtCost(item.totals?.totalCost))
|
||||
const topChannels = renderTop(t('usage.topChannels'),
|
||||
a.byChannel, c => c.channel || 'webchat', c => fmtCost(c.totals?.totalCost))
|
||||
|
||||
const topsHtml = `<div class="usage-tops-grid">${topModels}${topProviders}${topTools}${topAgents}${topChannels}</div>`
|
||||
@@ -174,12 +175,12 @@ function renderUsage(el, data) {
|
||||
// ── Token 分类 ──
|
||||
const tokenBreakdownHtml = `
|
||||
<div class="config-section" style="margin-top:var(--space-lg)">
|
||||
<div class="config-section-title">Token 分类</div>
|
||||
<div class="config-section-title">${t('usage.tokenBreakdown')}</div>
|
||||
<div style="display:flex;gap:var(--space-lg);flex-wrap:wrap;padding:var(--space-md)">
|
||||
<div><span style="display:inline-block;width:10px;height:10px;background:var(--error);border-radius:2px;margin-right:6px"></span>输出 ${fmtTokens(t.output)}</div>
|
||||
<div><span style="display:inline-block;width:10px;height:10px;background:var(--accent);border-radius:2px;margin-right:6px"></span>输入 ${fmtTokens(t.input)}</div>
|
||||
<div><span style="display:inline-block;width:10px;height:10px;background:var(--success);border-radius:2px;margin-right:6px"></span>缓存读取 ${fmtTokens(t.cacheRead)}</div>
|
||||
<div><span style="display:inline-block;width:10px;height:10px;background:var(--warning);border-radius:2px;margin-right:6px"></span>缓存写入 ${fmtTokens(t.cacheWrite)}</div>
|
||||
<div><span style="display:inline-block;width:10px;height:10px;background:var(--error);border-radius:2px;margin-right:6px"></span>${t('usage.outputTokens')} ${fmtTokens(totals.output)}</div>
|
||||
<div><span style="display:inline-block;width:10px;height:10px;background:var(--accent);border-radius:2px;margin-right:6px"></span>${t('usage.inputTokens')} ${fmtTokens(totals.input)}</div>
|
||||
<div><span style="display:inline-block;width:10px;height:10px;background:var(--success);border-radius:2px;margin-right:6px"></span>${t('usage.cacheRead')} ${fmtTokens(totals.cacheRead)}</div>
|
||||
<div><span style="display:inline-block;width:10px;height:10px;background:var(--warning);border-radius:2px;margin-right:6px"></span>${t('usage.cacheWrite')} ${fmtTokens(totals.cacheWrite)}</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
@@ -199,7 +200,7 @@ function renderUsage(el, data) {
|
||||
}).join('')
|
||||
dailyHtml = `
|
||||
<div class="config-section" style="margin-top:var(--space-lg)">
|
||||
<div class="config-section-title">每日用量</div>
|
||||
<div class="config-section-title">${t('usage.dailyUsage')}</div>
|
||||
<div class="usage-daily-chart">${bars}</div>
|
||||
</div>
|
||||
`
|
||||
@@ -226,7 +227,7 @@ function renderUsage(el, data) {
|
||||
}).join('')
|
||||
sessionsHtml = `
|
||||
<div class="config-section" style="margin-top:var(--space-lg)">
|
||||
<div class="config-section-title">会话明细 <span style="font-weight:normal;color:var(--text-tertiary);font-size:var(--font-size-xs)">最近 ${sessions.length} 个</span></div>
|
||||
<div class="config-section-title">${t('usage.sessionDetail')} <span style="font-weight:normal;color:var(--text-tertiary);font-size:var(--font-size-xs)">${t('usage.recentN', { count: sessions.length })}</span></div>
|
||||
<div class="session-list">${rows}</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
@@ -243,6 +243,140 @@
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Language switcher */
|
||||
.lang-switcher {
|
||||
position: relative;
|
||||
}
|
||||
.lang-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
.lang-trigger span {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lang-chevron {
|
||||
opacity: 0.5;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.lang-switcher.open .lang-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.lang-dropdown {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.15);
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transition: max-height 0.25s ease, opacity 0.2s ease;
|
||||
z-index: 100;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.lang-dropdown.open {
|
||||
max-height: 280px;
|
||||
opacity: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.lang-search-wrap {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--border-secondary);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg-primary);
|
||||
z-index: 1;
|
||||
}
|
||||
.lang-search {
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-size-xs);
|
||||
outline: none;
|
||||
}
|
||||
.lang-search:focus {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.lang-options {
|
||||
padding: 4px;
|
||||
}
|
||||
.lang-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size-sm);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.lang-option:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.lang-option.active {
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
.lang-option-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.lang-option-code {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.lang-option-check {
|
||||
color: var(--primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Collapsed sidebar: hide lang label, keep icon */
|
||||
#sidebar.sidebar-collapsed .lang-trigger span,
|
||||
#sidebar.sidebar-collapsed .lang-chevron {
|
||||
display: none;
|
||||
}
|
||||
#sidebar.sidebar-collapsed .lang-trigger {
|
||||
justify-content: center;
|
||||
}
|
||||
#sidebar.sidebar-collapsed .lang-dropdown {
|
||||
left: 100%;
|
||||
bottom: 0;
|
||||
right: auto;
|
||||
min-width: 180px;
|
||||
margin-bottom: 0;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* Mobile: ensure dropdown doesn't overflow viewport */
|
||||
@media (max-width: 768px) {
|
||||
.lang-dropdown {
|
||||
max-height: 240px;
|
||||
}
|
||||
.lang-dropdown.open {
|
||||
max-height: 240px;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-section {
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user