diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0b45b9..592a827 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Setup Node uses: actions/setup-node@v4 with: @@ -23,4 +25,6 @@ jobs: - name: Lint run: npm run lint --if-present - name: Test + env: + WEB_UI_PARITY_BASE_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }} run: npm run test --if-present diff --git a/.github/workflows/coderabbit-review.yml b/.github/workflows/coderabbit-review.yml index 4365755..b6efe1c 100644 --- a/.github/workflows/coderabbit-review.yml +++ b/.github/workflows/coderabbit-review.yml @@ -56,6 +56,7 @@ jobs: const body = [ "@coderabbitai re-review", "Stop making breaking changes, do a proper review!", + "If I merge this directly, will it introduce any regressions? Please list only the impacted issues. Do not include style suggestions, speculative concerns, or already-resolved items.", ``, ].join("\n"); diff --git a/cli.js b/cli.js index 8a35576..f79435d 100644 --- a/cli.js +++ b/cli.js @@ -62,6 +62,12 @@ const { validateWorkflowDefinition, executeWorkflowDefinition } = require('./lib/workflow-engine'); +const { + readBundledWebUiCss, + readBundledWebUiHtml, + readExecutableBundledJavaScriptModule, + readExecutableBundledWebUiScript +} = require('./web-ui/source-bundle.cjs'); const DEFAULT_WEB_PORT = 3737; const DEFAULT_WEB_HOST = '0.0.0.0'; @@ -9945,10 +9951,11 @@ function formatHostForUrl(host) { return value; } +// #region watchPathsForRestart function watchPathsForRestart(targets, onChange) { - const disposers = []; const debounceMs = 300; let timer = null; + const watcherEntries = new Map(); const trigger = (info) => { if (timer) clearTimeout(timer); @@ -9958,35 +9965,201 @@ function watchPathsForRestart(targets, onChange) { }, debounceMs); }; - const addWatcher = (target, recursive) => { + const closeWatcher = (watchKey) => { + const entry = watcherEntries.get(watchKey); + if (!entry) return; + watcherEntries.delete(watchKey); + try { + entry.watcher.close(); + } catch (_) {} + }; + + const listDirectoryTree = (rootDir) => { + const queue = [rootDir]; + const directories = []; + const seen = new Set(); + while (queue.length) { + const current = queue.shift(); + if (!current || seen.has(current) || !fs.existsSync(current)) { + continue; + } + seen.add(current); + let stat = null; + try { + stat = fs.statSync(current); + } catch (_) { + continue; + } + if (!stat || !stat.isDirectory()) { + continue; + } + directories.push(current); + let entries = []; + try { + entries = fs.readdirSync(current, { withFileTypes: true }); + } catch (_) { + continue; + } + for (const entry of entries) { + if (entry && typeof entry.isDirectory === 'function' && entry.isDirectory()) { + queue.push(path.join(current, entry.name)); + } + } + } + return directories; + }; + + const isSameOrNestedPath = (candidate, rootDir) => { + return candidate === rootDir || candidate.startsWith(`${rootDir}${path.sep}`); + }; + + const addWatcher = (target, recursive, isDirectory = false) => { if (!fs.existsSync(target)) return; + const watchKey = `${recursive ? 'recursive' : 'plain'}:${target}`; + if (watcherEntries.has(watchKey)) { + return true; + } try { - const watcher = fs.watch(target, { recursive }, (eventType, filename) => { + const basename = isDirectory ? '' : path.basename(target); + const watchTarget = isDirectory ? target : path.dirname(target); + const watcher = fs.watch(watchTarget, { recursive }, (eventType, filename) => { + if (isDirectory && !recursive && eventType === 'rename') { + syncDirectoryTree(target); + } if (!filename) return; - const lower = filename.toLowerCase(); - if (!(/\.(html|js|mjs|css)$/.test(lower))) return; - trigger({ target, eventType, filename }); + let normalizedFilename = String(filename).replace(/\\/g, '/'); + if (!isDirectory) { + const fileNameOnly = normalizedFilename.split('/').pop(); + if (fileNameOnly !== basename) { + return; + } + normalizedFilename = basename; + } + const lower = normalizedFilename.toLowerCase(); + if (!(/\.(html|js|mjs|cjs|css)$/.test(lower))) return; + trigger({ target, eventType, filename: normalizedFilename }); + }); + watcher.on('error', () => { + closeWatcher(watchKey); + if (isDirectory && recursive && !fs.existsSync(target)) { + syncDirectoryTree(target); + addMissingDirectoryWatcher(target); + return; + } + if (isDirectory && !recursive) { + syncDirectoryTree(target); + } else if (fs.existsSync(target)) { + addWatcher(target, recursive, isDirectory); + } + }); + watcherEntries.set(watchKey, { + watcher, + target, + recursive, + isDirectory }); - disposers.push(() => watcher.close()); return true; } catch (e) { return false; } }; + const addMissingDirectoryWatcher = (target) => { + const parentDir = path.dirname(target); + if (!parentDir || parentDir === target || !fs.existsSync(parentDir)) { + return false; + } + const watchKey = `missing-dir:${target}`; + if (watcherEntries.has(watchKey)) { + return true; + } + const basename = path.basename(target); + try { + const watcher = fs.watch(parentDir, { recursive: false }, (_eventType, filename) => { + if (!filename) return; + const fileNameOnly = String(filename).replace(/\\/g, '/').split('/').pop(); + if (fileNameOnly !== basename) { + return; + } + if (!fs.existsSync(target)) { + syncDirectoryTree(target); + return; + } + closeWatcher(watchKey); + const ok = addWatcher(target, true, true); + if (!ok) { + syncDirectoryTree(target); + } + }); + watcher.on('error', () => { + closeWatcher(watchKey); + if (fs.existsSync(parentDir) && !fs.existsSync(target)) { + addMissingDirectoryWatcher(target); + } + }); + watcherEntries.set(watchKey, { + watcher, + target: parentDir, + recursive: false, + isDirectory: false + }); + return true; + } catch (_) { + return false; + } + }; + + const syncDirectoryTree = (rootDir) => { + const directories = listDirectoryTree(rootDir); + const existingDirectorySet = new Set(directories); + for (const [watchKey, entry] of Array.from(watcherEntries.entries())) { + if (!entry.isDirectory || entry.recursive) { + continue; + } + if (!isSameOrNestedPath(entry.target, rootDir)) { + continue; + } + if (!existingDirectorySet.has(entry.target)) { + closeWatcher(watchKey); + } + } + for (const directory of directories) { + addWatcher(directory, false, true); + } + }; + for (const target of targets) { - const ok = addWatcher(target, true); + if (!fs.existsSync(target)) continue; + let stat = null; + try { + stat = fs.statSync(target); + } catch (_) { + continue; + } + if (stat && stat.isDirectory()) { + const ok = addWatcher(target, true, true); + if (!ok) { + syncDirectoryTree(target); + } + continue; + } + const ok = addWatcher(target, true, false); if (!ok) { - addWatcher(target, false); + addWatcher(target, false, false); } } return () => { - for (const dispose of disposers) { - try { dispose(); } catch (_) {} + if (timer) { + clearTimeout(timer); + timer = null; + } + for (const watchKey of Array.from(watcherEntries.keys())) { + closeWatcher(watchKey); } }; } +// #endregion watchPathsForRestart function writeJsonResponse(res, statusCode, payload) { const body = JSON.stringify(payload, null, 2); @@ -10131,8 +10304,46 @@ async function handleImportSkillsZipUpload(req, res, options = {}) { } } +const PUBLIC_WEB_UI_DYNAMIC_ASSETS = new Map([ + ['app.js', { + mime: 'application/javascript; charset=utf-8', + reader: readExecutableBundledWebUiScript + }], + ['index.html', { + mime: 'text/html; charset=utf-8', + reader: readBundledWebUiHtml + }], + ['logic.mjs', { + mime: 'application/javascript; charset=utf-8', + reader: readExecutableBundledJavaScriptModule + }], + ['styles.css', { + mime: 'text/css; charset=utf-8', + reader: readBundledWebUiCss + }] +]); + +const PUBLIC_WEB_UI_STATIC_ASSETS = new Set([ + 'modules/config-mode.computed.mjs', + 'modules/skills.computed.mjs', + 'modules/skills.methods.mjs', + 'session-helpers.mjs' +]); + function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser }) { const connections = new Set(); + const writeWebUiAssetError = (res, requestPath, error) => { + const message = error && error.message ? error.message : String(error); + console.error(`! Web UI 资源读取失败 [${requestPath}]:`, message); + if (res.headersSent) { + try { + res.destroy(error); + } catch (_) {} + return; + } + res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Internal Server Error'); + }; const server = http.createServer((req, res) => { const requestPath = (req.url || '/').split('?')[0]; @@ -10562,6 +10773,14 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser res.end(errorBody, 'utf-8'); } }); + } else if (requestPath === '/web-ui') { + try { + const html = readBundledWebUiHtml(htmlPath); + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(html); + } catch (error) { + writeWebUiAssetError(res, requestPath, error); + } } else if (requestPath.startsWith('/web-ui/')) { const normalized = path.normalize(requestPath).replace(/^([\\.\\/])+/, ''); const filePath = path.join(__dirname, normalized); @@ -10570,6 +10789,23 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser res.end('Forbidden'); return; } + const relativePath = path.relative(webDir, filePath).replace(/\\/g, '/'); + const dynamicAsset = PUBLIC_WEB_UI_DYNAMIC_ASSETS.get(relativePath); + if (dynamicAsset) { + try { + const assetBody = dynamicAsset.reader(filePath); + res.writeHead(200, { 'Content-Type': dynamicAsset.mime }); + res.end(assetBody, 'utf-8'); + } catch (error) { + writeWebUiAssetError(res, requestPath, error); + } + return; + } + if (!PUBLIC_WEB_UI_STATIC_ASSETS.has(relativePath)) { + res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Not Found'); + return; + } if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('Not Found'); @@ -10642,9 +10878,13 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser res.writeHead(200, { 'Content-Type': mime }); fs.createReadStream(filePath).pipe(res); } else { - const html = fs.readFileSync(htmlPath, 'utf-8'); - res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(html); + try { + const html = readBundledWebUiHtml(htmlPath); + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(html); + } catch (error) { + writeWebUiAssetError(res, requestPath, error); + } } }); diff --git a/tests/e2e/run.js b/tests/e2e/run.js index aa5442f..b81c320 100644 --- a/tests/e2e/run.js +++ b/tests/e2e/run.js @@ -23,6 +23,7 @@ const testMessages = require('./test-messages'); const testMcp = require('./test-mcp'); const testWorkflow = require('./test-workflow'); const testInvalidConfig = require('./test-invalid-config'); +const testWebUiAssets = require('./test-web-ui-assets'); async function main() { const realHome = os.homedir(); @@ -118,6 +119,7 @@ async function main() { await testMessages(ctx); await testMcp(ctx); await testWorkflow(ctx); + await testWebUiAssets(ctx); } finally { const waitForExit = new Promise((resolve) => { diff --git a/tests/e2e/test-web-ui-assets.js b/tests/e2e/test-web-ui-assets.js new file mode 100644 index 0000000..0c462b6 --- /dev/null +++ b/tests/e2e/test-web-ui-assets.js @@ -0,0 +1,124 @@ +const http = require('http'); +const { assert } = require('./helpers'); + +function getText(port, requestPath, timeoutMs = 2000) { + return new Promise((resolve, reject) => { + const req = http.request({ + hostname: '127.0.0.1', + port, + path: requestPath, + method: 'GET' + }, (res) => { + let body = ''; + res.setEncoding('utf-8'); + res.on('data', chunk => body += chunk); + res.on('end', () => { + resolve({ + statusCode: res.statusCode, + headers: res.headers || {}, + body + }); + }); + }); + + req.on('error', reject); + req.setTimeout(timeoutMs, () => { + req.destroy(new Error('Request timeout')); + }); + req.end(); + }); +} + +module.exports = async function testWebUiAssets(ctx) { + const { port } = ctx; + + const rootPage = await getText(port, '/'); + assert(rootPage.statusCode === 200, 'root web ui page should return 200'); + assert( + /^text\/html\b/.test(String(rootPage.headers['content-type'] || '')), + 'root web ui page should return html content type' + ); + assert(rootPage.body.includes('id="panel-market"'), 'root web ui page should inline market panel'); + assert(rootPage.body.includes('class="modal modal-wide skills-modal"'), 'root web ui page should inline skills modal'); + assert(rootPage.body.includes('src="/web-ui/app.js"'), 'root web ui page should point to the absolute app entry'); + assert(!rootPage.body.includes('src="web-ui/app.js"'), 'root web ui page should not use a relative app entry'); + assert(!/ -
- -
- - - - -
-
- 模型 -
- - -
-
- - -
- 当前提供商未提供模型列表,视为不限。模型可手动输入。 -
-
- 模型列表获取失败,请检查接口或手动输入。 -
-
- {{ isCodexConfigMode ? '当前模型不在接口列表中,请手动输入或在模板中调整。' : '当前模型不在接口列表中,请手动输入。' }} -
-
- Codex 配置需先改模板,再手动应用。 -
-
- {{ activeProviderBridgeHint }} 模板仅在 Codex 模式下可编辑。 -
- -
- - - -
-
- 配置健康检查 -
- -
- -
-
-
-
{{ provider.name.charAt(0).toUpperCase() }}
-
-
- {{ provider.name }} - 系统 -
-
- {{ provider.url || '未设置 URL' }} -
-
-
-
- - {{ providerPillText(provider) }} - - - {{ formatLatency(speedResults[provider.name]) }} - -
- - - - -
-
-
-
-
- - -
- - -
- 默认应用到 ~/.claude/settings.json。 -
- -
-
- 模型 -
- - -
- 模型修改后会自动保存并应用到当前配置。 -
-
- -
-
- 配置健康检查 -
- -
- -
-
-
-
{{ name.charAt(0).toUpperCase() }}
-
-
{{ name }}
-
{{ config.model || '未设置模型' }}
-
-
-
- - {{ config.hasKey ? '已配置' : '未配置' }} - - - {{ formatLatency(claudeSpeedResults[name]) }} - -
- - - -
-
-
-
-
- - -
- -
- 默认应用到 ~/.openclaw/openclaw.json。支持 JSON5(注释/尾逗号)。 -
- -
-
- AGENTS.md -
-
- 管理 OpenClaw Workspace 指令文件,默认读写 ~/.openclaw/workspace/AGENTS.md。 -
- -
- -
-
- 工作区文件 -
- -
- 仅支持 OpenClaw Workspace 内的 .md 文件。 -
- -
- -
-
-
-
{{ name.charAt(0).toUpperCase() }}
-
-
{{ name }}
-
{{ openclawSubtitle(config) }}
-
-
-
- - {{ openclawHasContent(config) ? '已配置' : '未配置' }} - -
- - -
-
-
-
-
- - -
-
-
- 加载中... -
-
- {{ sessionStandaloneError }} -
-
-
- {{ sessionStandaloneTitle }} - · {{ sessionStandaloneSourceLabel }} -
-
{{ sessionStandaloneText }}
-
-
- -
-
-
- 会话来源 -
- -
-
-
-
- - -
-
- -
-
- - -
-
- -
- -
- 会话加载中... -
- -
- 暂无可用会话记录 -
- -
-
-
-
-
-
{{ session.title || session.sessionId }}
- {{ session.messageCount ?? 0 }} -
-
- - -
-
-
- {{ session.sourceLabel }} - {{ session.updatedAt || 'unknown time' }} -
-
-
-
- -
- - -
- {{ sessionStandaloneError }} - 请先在左侧选择一个会话 -
-
-
-
-
- - -
-
- - -
- -
-
-
- Claude 配置 -
- - - -
-
-
- Codex 配置 -
- - - -
-
- -
-
-
-
- - -
-
- -
- 正在加载回收站... -
-
- 回收站为空 -
-
- 回收站列表加载失败,请刷新重试 -
-
-
-
-
-
-
{{ item.title || item.sessionId }}
- {{ item.messageCount ?? 0 }} -
-
- {{ item.sourceLabel }} -
-
-
-
- - -
-
{{ item.deletedAt || item.updatedAt || 'unknown time' }}
-
-
-
- 工作区 - {{ item.cwd }} -
-
- 原文件 - {{ item.originalFilePath }} -
-
- -
-
-
-
- -
-
-
-
- Skills 市场概览 -
当前市场页只保留本地 skills 能力:切换 Codex / Claude Code 安装目标,查看已安装项,执行跨应用导入与 ZIP 分发。
-
-
- - -
-
- -
- - -
- -
{{ skillsRootPath || skillsDefaultRootPath }}
- -
-
- 安装目标 - {{ skillsTargetLabel }} -
-
- 本地总数 - {{ skillsList.length }} -
-
- 含 SKILL.md - {{ skillsConfiguredCount }} -
-
- 缺少 SKILL.md - {{ skillsMissingSkillFileCount }} -
-
- 可导入 - {{ skillsImportList.length }} -
-
- 可直接导入 - {{ skillsImportConfiguredCount }} -
-
-
- -
-
-
-
-
已安装 Skills
-
展示当前已落地到 {{ skillsRootPath || skillsDefaultRootPath }} 的前 6 个目录,可继续进入管理弹窗做筛选、导出和删除。
-
- -
-
正在加载本地 Skills...
-
当前还没有已安装 skill,可通过 ZIP 或跨应用导入补充。
-
-
-
-
{{ skill.displayName || skill.name }}
-
{{ skill.description || skill.path }}
-
- - {{ skill.hasSkillFile ? '已验证' : '待补 SKILL.md' }} - -
-
-
- -
-
-
-
可导入来源
-
扫描其他应用下未托管的 skill,先确认来源和目录,再批量导入到当前 {{ skillsTargetLabel }} skills 目录。
-
- -
-
正在扫描可导入 skill...
-
还没有扫描到可导入 skill,可点击“扫描来源”重新读取。
-
-
-
-
{{ skill.displayName || skill.name }}
-
{{ skill.sourceLabel }} · {{ skill.sourcePath }}
-
- - {{ skill.hasSkillFile ? '可直接导入' : '缺少 SKILL.md' }} - -
-
-
- -
-
-
-
分发入口
-
市场页聚焦本地落地:本地管理、跨应用导入、ZIP 分发,全部作用到当前安装目标。
-
-
-
- - - -
-
- -
-
-
-
市场说明
-
-
-
-
-
-
目标宿主切换
-
在 Codex 和 Claude Code 之间切换后,后续扫描、导入、导出、删除都会落到当前 {{ skillsTargetLabel }} 目录。
-
-
-
-
-
跨应用导入
-
扫描 `Codex`、`Claude Code` 与 `Agents` 目录里的未托管 skills,筛选后批量导入到当前宿主。
-
-
-
-
-
ZIP 分发
-
通过压缩包在不同环境间分发技能目录,保持本地可控,不依赖外部目录服务。
-
-
-
-
-
-
- - -
- 加载配置中... -
- - -
- {{ initError }} -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{{ message }}
- - - + + + + + + + + + + + + + + diff --git a/web-ui/logic.agents-diff.mjs b/web-ui/logic.agents-diff.mjs new file mode 100644 index 0000000..a0d78a9 --- /dev/null +++ b/web-ui/logic.agents-diff.mjs @@ -0,0 +1,386 @@ +export const DEFAULT_API_BODY_LIMIT_BYTES = 4 * 1024 * 1024; + +const LARGE_DIFF_LINE_LIMIT = 3000; +const LARGE_DIFF_SYNC_LOOKAHEAD = 64; + +function measureUtf8ByteLength(input) { + const text = typeof input === 'string' ? input : String(input ?? ''); + if (typeof TextEncoder === 'function') { + return new TextEncoder().encode(text).length; + } + if (typeof Buffer !== 'undefined' && typeof Buffer.byteLength === 'function') { + return Buffer.byteLength(text, 'utf8'); + } + return unescape(encodeURIComponent(text)).length; +} + +function buildApiRequestByteLength(action, params) { + return measureUtf8ByteLength(JSON.stringify({ action, params })); +} + +function normalizeDiffText(input) { + const safe = typeof input === 'string' ? input : ''; + const withoutBom = safe.charCodeAt(0) === 0xFEFF ? safe.slice(1) : safe; + return withoutBom.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); +} + +function splitDiffLines(input) { + const normalized = normalizeDiffText(input); + if (!normalized) return []; + return normalized.split('\n'); +} + +function buildDiffLcsMatrix(beforeLines, afterLines) { + const rows = beforeLines.length + 1; + const cols = afterLines.length + 1; + const matrix = Array.from({ length: rows }, () => new Array(cols).fill(0)); + for (let i = 1; i < rows; i += 1) { + const beforeLine = beforeLines[i - 1]; + for (let j = 1; j < cols; j += 1) { + if (beforeLine === afterLines[j - 1]) { + matrix[i][j] = matrix[i - 1][j - 1] + 1; + } else { + const up = matrix[i - 1][j]; + const left = matrix[i][j - 1]; + matrix[i][j] = up >= left ? up : left; + } + } + } + return matrix; +} + +function countDiffStats(lines) { + let added = 0; + let removed = 0; + let unchanged = 0; + for (const line of lines) { + if (line.type === 'add') { + added += 1; + } else if (line.type === 'del') { + removed += 1; + } else { + unchanged += 1; + } + } + return { added, removed, unchanged }; +} + +function buildCollapsedContextLine(hiddenCount) { + return { + type: 'context', + value: `... ${hiddenCount} unchanged lines ...`, + oldNumber: null, + newNumber: null + }; +} + +function compactContextRuns(lines, contextSize = 3) { + const compacted = []; + const keepCount = Number.isFinite(contextSize) ? Math.max(1, Math.floor(contextSize)) : 3; + let index = 0; + while (index < lines.length) { + if (!lines[index] || lines[index].type !== 'context') { + compacted.push(lines[index]); + index += 1; + continue; + } + const start = index; + while (index < lines.length && lines[index] && lines[index].type === 'context') { + index += 1; + } + const run = lines.slice(start, index); + if (run.length <= keepCount * 2 + 1) { + compacted.push(...run); + continue; + } + compacted.push(...run.slice(0, keepCount)); + compacted.push(buildCollapsedContextLine(run.length - keepCount * 2)); + compacted.push(...run.slice(-keepCount)); + } + return compacted; +} + +function buildExactDiffLines(beforeLines, afterLines) { + const matrix = buildDiffLcsMatrix(beforeLines, afterLines); + const lines = []; + let i = beforeLines.length; + let j = afterLines.length; + while (i > 0 || j > 0) { + if (i > 0 && j > 0 && beforeLines[i - 1] === afterLines[j - 1]) { + lines.push({ + type: 'context', + value: beforeLines[i - 1], + oldNumber: i, + newNumber: j + }); + i -= 1; + j -= 1; + continue; + } + const canAdd = j > 0; + const canDel = i > 0; + if (canAdd && (!canDel || matrix[i][j - 1] >= matrix[i - 1][j])) { + lines.push({ + type: 'add', + value: afterLines[j - 1], + oldNumber: null, + newNumber: j + }); + j -= 1; + continue; + } + if (canDel) { + lines.push({ + type: 'del', + value: beforeLines[i - 1], + oldNumber: i, + newNumber: null + }); + i -= 1; + } + } + lines.reverse(); + return lines; +} + +function findSyncPointInWindow(beforeLines, afterLines, beforeIndex, afterIndex, maxBeforeOffset, maxAfterOffset) { + if (maxBeforeOffset <= 0 && maxAfterOffset <= 0) { + return null; + } + + for (let offset = 1; offset <= maxAfterOffset; offset += 1) { + if (beforeLines[beforeIndex] === afterLines[afterIndex + offset]) { + return { beforeIndex, afterIndex: afterIndex + offset }; + } + } + for (let offset = 1; offset <= maxBeforeOffset; offset += 1) { + if (beforeLines[beforeIndex + offset] === afterLines[afterIndex]) { + return { beforeIndex: beforeIndex + offset, afterIndex }; + } + } + + const maxDistance = maxBeforeOffset + maxAfterOffset; + for (let distance = 2; distance <= maxDistance; distance += 1) { + const beforeStart = Math.max(1, distance - maxAfterOffset); + const beforeEnd = Math.min(maxBeforeOffset, distance - 1); + for (let beforeOffset = beforeStart; beforeOffset <= beforeEnd; beforeOffset += 1) { + const afterOffset = distance - beforeOffset; + if (beforeLines[beforeIndex + beforeOffset] === afterLines[afterIndex + afterOffset]) { + return { + beforeIndex: beforeIndex + beforeOffset, + afterIndex: afterIndex + afterOffset + }; + } + } + } + return null; +} + +function findNextSyncPoint(beforeLines, afterLines, beforeIndex, afterIndex, lookahead = LARGE_DIFF_SYNC_LOOKAHEAD) { + const remainingBefore = Math.max(0, beforeLines.length - beforeIndex - 1); + const remainingAfter = Math.max(0, afterLines.length - afterIndex - 1); + const maxWindow = Math.max(remainingBefore, remainingAfter); + if (maxWindow <= 0) { + return null; + } + + const initialWindow = Number.isFinite(lookahead) + ? Math.max(1, Math.floor(lookahead)) + : LARGE_DIFF_SYNC_LOOKAHEAD; + let window = Math.min(maxWindow, initialWindow); + while (window > 0) { + const syncPoint = findSyncPointInWindow( + beforeLines, + afterLines, + beforeIndex, + afterIndex, + Math.min(window, remainingBefore), + Math.min(window, remainingAfter) + ); + if (syncPoint) { + return syncPoint; + } + if (window >= maxWindow) { + return null; + } + window = Math.min(maxWindow, window * 2); + } + return null; +} + +function buildLargeDiffLines(beforeLines, afterLines) { + const rawLines = []; + let beforeIndex = 0; + let afterIndex = 0; + + while (beforeIndex < beforeLines.length && afterIndex < afterLines.length) { + if (beforeLines[beforeIndex] === afterLines[afterIndex]) { + rawLines.push({ + type: 'context', + value: beforeLines[beforeIndex], + oldNumber: beforeIndex + 1, + newNumber: afterIndex + 1 + }); + beforeIndex += 1; + afterIndex += 1; + continue; + } + + const syncPoint = findNextSyncPoint(beforeLines, afterLines, beforeIndex, afterIndex); + if (!syncPoint) { + rawLines.push({ + type: 'del', + value: beforeLines[beforeIndex], + oldNumber: beforeIndex + 1, + newNumber: null + }); + rawLines.push({ + type: 'add', + value: afterLines[afterIndex], + oldNumber: null, + newNumber: afterIndex + 1 + }); + beforeIndex += 1; + afterIndex += 1; + continue; + } + + while (beforeIndex < syncPoint.beforeIndex) { + rawLines.push({ + type: 'del', + value: beforeLines[beforeIndex], + oldNumber: beforeIndex + 1, + newNumber: null + }); + beforeIndex += 1; + } + while (afterIndex < syncPoint.afterIndex) { + rawLines.push({ + type: 'add', + value: afterLines[afterIndex], + oldNumber: null, + newNumber: afterIndex + 1 + }); + afterIndex += 1; + } + } + + while (beforeIndex < beforeLines.length) { + rawLines.push({ + type: 'del', + value: beforeLines[beforeIndex], + oldNumber: beforeIndex + 1, + newNumber: null + }); + beforeIndex += 1; + } + while (afterIndex < afterLines.length) { + rawLines.push({ + type: 'add', + value: afterLines[afterIndex], + oldNumber: null, + newNumber: afterIndex + 1 + }); + afterIndex += 1; + } + + return { + lines: compactContextRuns(rawLines), + stats: countDiffStats(rawLines) + }; +} + +export function buildLineDiff(beforeText, afterText) { + const beforeLines = splitDiffLines(beforeText); + const afterLines = splitDiffLines(afterText); + const result = (beforeLines.length > LARGE_DIFF_LINE_LIMIT || afterLines.length > LARGE_DIFF_LINE_LIMIT) + ? buildLargeDiffLines(beforeLines, afterLines) + : { + lines: buildExactDiffLines(beforeLines, afterLines), + stats: null + }; + const stats = result.stats || countDiffStats(result.lines); + return { + lines: result.lines, + stats, + oldLineCount: beforeLines.length, + newLineCount: afterLines.length, + truncated: false + }; +} + +export function buildAgentsDiffPreview(options = {}) { + const beforeText = normalizeDiffText(options.baseContent); + const afterText = normalizeDiffText(options.content); + const diff = buildLineDiff(beforeText, afterText); + return { + ...diff, + truncated: !!diff.truncated, + hasChanges: diff.truncated + ? beforeText !== afterText + : (diff.stats.added > 0 || diff.stats.removed > 0) + }; +} + +export function buildAgentsDiffPreviewRequest(options = {}) { + const contextRaw = typeof options.context === 'string' ? options.context.trim() : ''; + const context = contextRaw || 'codex'; + const params = { + content: typeof options.content === 'string' ? options.content : '', + lineEnding: options.lineEnding === '\r\n' ? '\r\n' : '\n', + context + }; + if (context === 'openclaw-workspace') { + const fileName = typeof options.fileName === 'string' ? options.fileName.trim() : ''; + if (fileName) { + params.fileName = fileName; + } + } + + const maxRequestBytes = Number.isFinite(options.maxRequestBytes) + ? Math.max(1024, Math.floor(options.maxRequestBytes)) + : DEFAULT_API_BODY_LIMIT_BYTES; + const hasBaseContent = typeof options.baseContent === 'string'; + const paramsWithBaseContent = hasBaseContent + ? { ...params, baseContent: options.baseContent } + : params; + if (!hasBaseContent) { + return { + params, + omittedBaseContent: false, + exceedsBodyLimit: buildApiRequestByteLength('preview-agents-diff', params) > maxRequestBytes + }; + } + if (buildApiRequestByteLength('preview-agents-diff', paramsWithBaseContent) <= maxRequestBytes) { + return { + params: paramsWithBaseContent, + omittedBaseContent: false, + exceedsBodyLimit: false + }; + } + return { + params, + omittedBaseContent: true, + exceedsBodyLimit: buildApiRequestByteLength('preview-agents-diff', params) > maxRequestBytes + }; +} + +export function isAgentsDiffPreviewPayloadTooLarge(result = {}) { + const status = Number(result && result.status); + const errorCode = result && typeof result.errorCode === 'string' ? result.errorCode : ''; + return status === 413 || errorCode === 'payload-too-large'; +} + +export function shouldApplyAgentsDiffPreviewResponse(options = {}) { + const requestToken = options && options.requestToken; + const activeRequestToken = options && options.activeRequestToken; + if (!requestToken || requestToken !== activeRequestToken) { + return false; + } + if (!options || !options.isVisible) { + return false; + } + const requestFingerprint = typeof options.requestFingerprint === 'string' ? options.requestFingerprint : ''; + const currentFingerprint = typeof options.currentFingerprint === 'string' ? options.currentFingerprint : ''; + return requestFingerprint === currentFingerprint; +} diff --git a/web-ui/logic.claude.mjs b/web-ui/logic.claude.mjs new file mode 100644 index 0000000..0ceb0ff --- /dev/null +++ b/web-ui/logic.claude.mjs @@ -0,0 +1,108 @@ +export function normalizeClaudeValue(value) { + return typeof value === 'string' ? value.trim() : ''; +} + +export function normalizeClaudeConfig(config) { + const safe = config && typeof config === 'object' ? config : {}; + const apiKey = normalizeClaudeValue(safe.apiKey); + const authToken = normalizeClaudeValue(safe.authToken); + const useKey = normalizeClaudeValue(safe.useKey); + const externalCredentialType = normalizeClaudeValue(safe.externalCredentialType) + || (apiKey ? '' : (authToken ? 'auth-token' : (useKey ? 'claude-code-use-key' : ''))); + return { + apiKey, + baseUrl: normalizeClaudeValue(safe.baseUrl), + model: normalizeClaudeValue(safe.model), + authToken, + useKey, + externalCredentialType + }; +} + +export function normalizeClaudeSettingsEnv(env) { + const safe = env && typeof env === 'object' ? env : {}; + const apiKey = normalizeClaudeValue(safe.ANTHROPIC_API_KEY); + const authToken = normalizeClaudeValue(safe.ANTHROPIC_AUTH_TOKEN); + const useKey = normalizeClaudeValue(safe.CLAUDE_CODE_USE_KEY); + return { + apiKey, + baseUrl: normalizeClaudeValue(safe.ANTHROPIC_BASE_URL), + model: normalizeClaudeValue(safe.ANTHROPIC_MODEL) || 'glm-4.7', + authToken, + useKey, + externalCredentialType: apiKey + ? '' + : (authToken ? 'auth-token' : (useKey ? 'claude-code-use-key' : '')) + }; +} + +function normalizeClaudeComparableUrl(value) { + const trimmed = normalizeClaudeValue(value); + if (!trimmed) return ''; + return trimmed.replace(/\/+$/g, ''); +} + +function hasClaudeCredential(config = {}) { + return !!(config.apiKey || config.authToken || config.useKey); +} + +export function matchClaudeConfigFromSettings(claudeConfigs = {}, env = {}) { + const normalizedSettings = normalizeClaudeSettingsEnv(env); + if (!normalizedSettings.baseUrl || !normalizedSettings.model || !hasClaudeCredential(normalizedSettings)) { + return ''; + } + const comparableSettingsUrl = normalizeClaudeComparableUrl(normalizedSettings.baseUrl); + const entries = Object.entries(claudeConfigs || {}); + for (const [name, config] of entries) { + const normalizedConfig = normalizeClaudeConfig(config); + if (!normalizedConfig.baseUrl || !normalizedConfig.model) { + continue; + } + if (normalizeClaudeComparableUrl(normalizedConfig.baseUrl) !== comparableSettingsUrl + || normalizedConfig.model !== normalizedSettings.model) { + continue; + } + if (normalizedSettings.apiKey && normalizedConfig.apiKey === normalizedSettings.apiKey) { + return name; + } + if (!normalizedSettings.apiKey + && normalizedConfig.apiKey === '' + && normalizedConfig.externalCredentialType + && normalizedConfig.externalCredentialType === normalizedSettings.externalCredentialType) { + return name; + } + } + return ''; +} + +export function findDuplicateClaudeConfigName(claudeConfigs = {}, config) { + const normalized = normalizeClaudeConfig(config); + if (!normalized.baseUrl || !normalized.model) { + return ''; + } + const comparableUrl = normalizeClaudeComparableUrl(normalized.baseUrl); + const isExternal = !normalized.apiKey && !!normalized.externalCredentialType; + if (!normalized.apiKey && !isExternal) { + return ''; + } + const entries = Object.entries(claudeConfigs || {}); + for (const [name, existing] of entries) { + const normalizedExisting = normalizeClaudeConfig(existing); + if (!normalizedExisting.baseUrl || !normalizedExisting.model) { + continue; + } + if (normalizeClaudeComparableUrl(normalizedExisting.baseUrl) !== comparableUrl + || normalizedExisting.model !== normalized.model) { + continue; + } + if (normalized.apiKey && normalizedExisting.apiKey === normalized.apiKey) { + return name; + } + if (isExternal + && !normalizedExisting.apiKey + && normalizedExisting.externalCredentialType === normalized.externalCredentialType) { + return name; + } + } + return ''; +} diff --git a/web-ui/logic.mjs b/web-ui/logic.mjs index 27527e8..b4119d9 100644 --- a/web-ui/logic.mjs +++ b/web-ui/logic.mjs @@ -1,793 +1,5 @@ -// 逻辑纯函数:供 Web UI 与单元测试共享 -export const DEFAULT_API_BODY_LIMIT_BYTES = 4 * 1024 * 1024; -const LARGE_DIFF_LINE_LIMIT = 3000; -const LARGE_DIFF_SYNC_LOOKAHEAD = 64; - -function measureUtf8ByteLength(input) { - const text = typeof input === 'string' ? input : String(input ?? ''); - if (typeof TextEncoder === 'function') { - return new TextEncoder().encode(text).length; - } - if (typeof Buffer !== 'undefined' && typeof Buffer.byteLength === 'function') { - return Buffer.byteLength(text, 'utf8'); - } - return unescape(encodeURIComponent(text)).length; -} - -function buildApiRequestByteLength(action, params) { - return measureUtf8ByteLength(JSON.stringify({ action, params })); -} - -function normalizeDiffText(input) { - const safe = typeof input === 'string' ? input : ''; - const withoutBom = safe.charCodeAt(0) === 0xFEFF ? safe.slice(1) : safe; - return withoutBom.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); -} - -function splitDiffLines(input) { - let normalized = normalizeDiffText(input); - if (!normalized) return []; - if (normalized.endsWith('\n')) { - normalized = normalized.slice(0, -1); - } - if (!normalized) return []; - return normalized.split('\n'); -} - -function buildDiffLcsMatrix(beforeLines, afterLines) { - const rows = beforeLines.length + 1; - const cols = afterLines.length + 1; - const matrix = Array.from({ length: rows }, () => new Array(cols).fill(0)); - for (let i = 1; i < rows; i += 1) { - const beforeLine = beforeLines[i - 1]; - for (let j = 1; j < cols; j += 1) { - if (beforeLine === afterLines[j - 1]) { - matrix[i][j] = matrix[i - 1][j - 1] + 1; - } else { - const up = matrix[i - 1][j]; - const left = matrix[i][j - 1]; - matrix[i][j] = up >= left ? up : left; - } - } - } - return matrix; -} - -function countDiffStats(lines) { - let added = 0; - let removed = 0; - let unchanged = 0; - for (const line of lines) { - if (line.type === 'add') { - added += 1; - } else if (line.type === 'del') { - removed += 1; - } else { - unchanged += 1; - } - } - return { added, removed, unchanged }; -} - -function buildCollapsedContextLine(hiddenCount) { - return { - type: 'context', - value: `... ${hiddenCount} unchanged lines ...`, - oldNumber: null, - newNumber: null - }; -} - -function compactContextRuns(lines, contextSize = 3) { - const compacted = []; - const keepCount = Number.isFinite(contextSize) ? Math.max(1, Math.floor(contextSize)) : 3; - let index = 0; - while (index < lines.length) { - if (!lines[index] || lines[index].type !== 'context') { - compacted.push(lines[index]); - index += 1; - continue; - } - const start = index; - while (index < lines.length && lines[index] && lines[index].type === 'context') { - index += 1; - } - const run = lines.slice(start, index); - if (run.length <= keepCount * 2 + 1) { - compacted.push(...run); - continue; - } - compacted.push(...run.slice(0, keepCount)); - compacted.push(buildCollapsedContextLine(run.length - keepCount * 2)); - compacted.push(...run.slice(-keepCount)); - } - return compacted; -} - -function buildExactDiffLines(beforeLines, afterLines) { - const matrix = buildDiffLcsMatrix(beforeLines, afterLines); - const lines = []; - let i = beforeLines.length; - let j = afterLines.length; - while (i > 0 || j > 0) { - if (i > 0 && j > 0 && beforeLines[i - 1] === afterLines[j - 1]) { - lines.push({ - type: 'context', - value: beforeLines[i - 1], - oldNumber: i, - newNumber: j - }); - i -= 1; - j -= 1; - continue; - } - const canAdd = j > 0; - const canDel = i > 0; - if (canAdd && (!canDel || matrix[i][j - 1] >= matrix[i - 1][j])) { - lines.push({ - type: 'add', - value: afterLines[j - 1], - oldNumber: null, - newNumber: j - }); - j -= 1; - continue; - } - if (canDel) { - lines.push({ - type: 'del', - value: beforeLines[i - 1], - oldNumber: i, - newNumber: null - }); - i -= 1; - } - } - lines.reverse(); - return lines; -} - -function findSyncPointInWindow(beforeLines, afterLines, beforeIndex, afterIndex, maxBeforeOffset, maxAfterOffset) { - if (maxBeforeOffset <= 0 && maxAfterOffset <= 0) { - return null; - } - - for (let offset = 1; offset <= maxAfterOffset; offset += 1) { - if (beforeLines[beforeIndex] === afterLines[afterIndex + offset]) { - return { beforeIndex, afterIndex: afterIndex + offset }; - } - } - for (let offset = 1; offset <= maxBeforeOffset; offset += 1) { - if (beforeLines[beforeIndex + offset] === afterLines[afterIndex]) { - return { beforeIndex: beforeIndex + offset, afterIndex }; - } - } - - const maxDistance = maxBeforeOffset + maxAfterOffset; - for (let distance = 2; distance <= maxDistance; distance += 1) { - const beforeStart = Math.max(1, distance - maxAfterOffset); - const beforeEnd = Math.min(maxBeforeOffset, distance - 1); - for (let beforeOffset = beforeStart; beforeOffset <= beforeEnd; beforeOffset += 1) { - const afterOffset = distance - beforeOffset; - if (beforeLines[beforeIndex + beforeOffset] === afterLines[afterIndex + afterOffset]) { - return { - beforeIndex: beforeIndex + beforeOffset, - afterIndex: afterIndex + afterOffset - }; - } - } - } - return null; -} - -function findNextSyncPoint(beforeLines, afterLines, beforeIndex, afterIndex, lookahead = LARGE_DIFF_SYNC_LOOKAHEAD) { - const remainingBefore = Math.max(0, beforeLines.length - beforeIndex - 1); - const remainingAfter = Math.max(0, afterLines.length - afterIndex - 1); - const maxWindow = Math.max(remainingBefore, remainingAfter); - if (maxWindow <= 0) { - return null; - } - - const initialWindow = Number.isFinite(lookahead) - ? Math.max(1, Math.floor(lookahead)) - : LARGE_DIFF_SYNC_LOOKAHEAD; - let window = Math.min(maxWindow, initialWindow); - while (window > 0) { - const syncPoint = findSyncPointInWindow( - beforeLines, - afterLines, - beforeIndex, - afterIndex, - Math.min(window, remainingBefore), - Math.min(window, remainingAfter) - ); - if (syncPoint) { - return syncPoint; - } - if (window >= maxWindow) { - return null; - } - window = Math.min(maxWindow, window * 2); - } - return null; -} - -function buildLargeDiffLines(beforeLines, afterLines) { - const rawLines = []; - let beforeIndex = 0; - let afterIndex = 0; - - while (beforeIndex < beforeLines.length && afterIndex < afterLines.length) { - if (beforeLines[beforeIndex] === afterLines[afterIndex]) { - rawLines.push({ - type: 'context', - value: beforeLines[beforeIndex], - oldNumber: beforeIndex + 1, - newNumber: afterIndex + 1 - }); - beforeIndex += 1; - afterIndex += 1; - continue; - } - - const syncPoint = findNextSyncPoint(beforeLines, afterLines, beforeIndex, afterIndex); - if (!syncPoint) { - rawLines.push({ - type: 'del', - value: beforeLines[beforeIndex], - oldNumber: beforeIndex + 1, - newNumber: null - }); - rawLines.push({ - type: 'add', - value: afterLines[afterIndex], - oldNumber: null, - newNumber: afterIndex + 1 - }); - beforeIndex += 1; - afterIndex += 1; - continue; - } - - while (beforeIndex < syncPoint.beforeIndex) { - rawLines.push({ - type: 'del', - value: beforeLines[beforeIndex], - oldNumber: beforeIndex + 1, - newNumber: null - }); - beforeIndex += 1; - } - while (afterIndex < syncPoint.afterIndex) { - rawLines.push({ - type: 'add', - value: afterLines[afterIndex], - oldNumber: null, - newNumber: afterIndex + 1 - }); - afterIndex += 1; - } - } - - while (beforeIndex < beforeLines.length) { - rawLines.push({ - type: 'del', - value: beforeLines[beforeIndex], - oldNumber: beforeIndex + 1, - newNumber: null - }); - beforeIndex += 1; - } - while (afterIndex < afterLines.length) { - rawLines.push({ - type: 'add', - value: afterLines[afterIndex], - oldNumber: null, - newNumber: afterIndex + 1 - }); - afterIndex += 1; - } - - return { - lines: compactContextRuns(rawLines), - stats: countDiffStats(rawLines) - }; -} - -export function buildLineDiff(beforeText, afterText) { - const beforeLines = splitDiffLines(beforeText); - const afterLines = splitDiffLines(afterText); - const result = (beforeLines.length > LARGE_DIFF_LINE_LIMIT || afterLines.length > LARGE_DIFF_LINE_LIMIT) - ? buildLargeDiffLines(beforeLines, afterLines) - : { - lines: buildExactDiffLines(beforeLines, afterLines), - stats: null - }; - const stats = result.stats || countDiffStats(result.lines); - return { - lines: result.lines, - stats, - oldLineCount: beforeLines.length, - newLineCount: afterLines.length, - truncated: false - }; -} - -export function buildAgentsDiffPreview(options = {}) { - const beforeText = normalizeDiffText(options.baseContent); - const afterText = normalizeDiffText(options.content); - const diff = buildLineDiff(beforeText, afterText); - return { - ...diff, - truncated: !!diff.truncated, - hasChanges: diff.truncated - ? beforeText !== afterText - : (diff.stats.added > 0 || diff.stats.removed > 0) - }; -} - -export function buildAgentsDiffPreviewRequest(options = {}) { - const contextRaw = typeof options.context === 'string' ? options.context.trim() : ''; - const context = contextRaw || 'codex'; - const params = { - content: typeof options.content === 'string' ? options.content : '', - lineEnding: options.lineEnding === '\r\n' ? '\r\n' : '\n', - context - }; - if (context === 'openclaw-workspace') { - const fileName = typeof options.fileName === 'string' ? options.fileName.trim() : ''; - if (fileName) { - params.fileName = fileName; - } - } - - const maxRequestBytes = Number.isFinite(options.maxRequestBytes) - ? Math.max(1024, Math.floor(options.maxRequestBytes)) - : DEFAULT_API_BODY_LIMIT_BYTES; - const hasBaseContent = typeof options.baseContent === 'string'; - const paramsWithBaseContent = hasBaseContent - ? { ...params, baseContent: options.baseContent } - : params; - if (!hasBaseContent) { - return { - params, - omittedBaseContent: false, - exceedsBodyLimit: buildApiRequestByteLength('preview-agents-diff', params) > maxRequestBytes - }; - } - if (buildApiRequestByteLength('preview-agents-diff', paramsWithBaseContent) <= maxRequestBytes) { - return { - params: paramsWithBaseContent, - omittedBaseContent: false, - exceedsBodyLimit: false - }; - } - return { - params, - omittedBaseContent: true, - exceedsBodyLimit: buildApiRequestByteLength('preview-agents-diff', params) > maxRequestBytes - }; -} - -export function isAgentsDiffPreviewPayloadTooLarge(result = {}) { - const status = Number(result && result.status); - const errorCode = result && typeof result.errorCode === 'string' ? result.errorCode : ''; - return status === 413 || errorCode === 'payload-too-large'; -} - -export function shouldApplyAgentsDiffPreviewResponse(options = {}) { - const requestToken = options && options.requestToken; - const activeRequestToken = options && options.activeRequestToken; - if (!requestToken || requestToken !== activeRequestToken) { - return false; - } - if (!options || !options.isVisible) { - return false; - } - const requestFingerprint = typeof options.requestFingerprint === 'string' ? options.requestFingerprint : ''; - const currentFingerprint = typeof options.currentFingerprint === 'string' ? options.currentFingerprint : ''; - return requestFingerprint === currentFingerprint; -} - -export function normalizeClaudeValue(value) { - return typeof value === 'string' ? value.trim() : ''; -} - -export function normalizeClaudeConfig(config) { - const safe = config && typeof config === 'object' ? config : {}; - return { - apiKey: normalizeClaudeValue(safe.apiKey), - baseUrl: normalizeClaudeValue(safe.baseUrl), - model: normalizeClaudeValue(safe.model), - authToken: normalizeClaudeValue(safe.authToken), - useKey: normalizeClaudeValue(safe.useKey), - externalCredentialType: normalizeClaudeValue(safe.externalCredentialType) - }; -} - -export function normalizeClaudeSettingsEnv(env) { - const safe = env && typeof env === 'object' ? env : {}; - const apiKey = normalizeClaudeValue(safe.ANTHROPIC_API_KEY); - const authToken = normalizeClaudeValue(safe.ANTHROPIC_AUTH_TOKEN); - const useKey = normalizeClaudeValue(safe.CLAUDE_CODE_USE_KEY); - return { - apiKey, - baseUrl: normalizeClaudeValue(safe.ANTHROPIC_BASE_URL), - model: normalizeClaudeValue(safe.ANTHROPIC_MODEL) || 'glm-4.7', - authToken, - useKey, - externalCredentialType: apiKey - ? '' - : (authToken ? 'auth-token' : (useKey ? 'claude-code-use-key' : '')) - }; -} - -function normalizeClaudeComparableUrl(value) { - const trimmed = normalizeClaudeValue(value); - if (!trimmed) return ''; - return trimmed.replace(/\/+$/g, ''); -} - -function hasClaudeCredential(config = {}) { - return !!(config.apiKey || config.authToken || config.useKey); -} - -export function matchClaudeConfigFromSettings(claudeConfigs = {}, env = {}) { - const normalizedSettings = normalizeClaudeSettingsEnv(env); - if (!normalizedSettings.baseUrl || !normalizedSettings.model || !hasClaudeCredential(normalizedSettings)) { - return ''; - } - const comparableSettingsUrl = normalizeClaudeComparableUrl(normalizedSettings.baseUrl); - const entries = Object.entries(claudeConfigs || {}); - for (const [name, config] of entries) { - const normalizedConfig = normalizeClaudeConfig(config); - if (!normalizedConfig.baseUrl || !normalizedConfig.model) { - continue; - } - if (normalizeClaudeComparableUrl(normalizedConfig.baseUrl) !== comparableSettingsUrl - || normalizedConfig.model !== normalizedSettings.model) { - continue; - } - if (normalizedSettings.apiKey && normalizedConfig.apiKey === normalizedSettings.apiKey) { - return name; - } - if (!normalizedSettings.apiKey - && normalizedConfig.apiKey === '' - && normalizedConfig.externalCredentialType - && normalizedConfig.externalCredentialType === normalizedSettings.externalCredentialType) { - return name; - } - } - return ''; -} - -export function findDuplicateClaudeConfigName(claudeConfigs = {}, config) { - const normalized = normalizeClaudeConfig(config); - if (!normalized.baseUrl || !normalized.model) { - return ''; - } - const comparableUrl = normalizeClaudeComparableUrl(normalized.baseUrl); - const isExternal = !normalized.apiKey && !!normalized.externalCredentialType; - if (!normalized.apiKey && !isExternal) { - return ''; - } - const entries = Object.entries(claudeConfigs || {}); - for (const [name, existing] of entries) { - const normalizedExisting = normalizeClaudeConfig(existing); - if (!normalizedExisting.baseUrl || !normalizedExisting.model) { - continue; - } - if (normalizeClaudeComparableUrl(normalizedExisting.baseUrl) !== comparableUrl - || normalizedExisting.model !== normalized.model) { - continue; - } - if (normalized.apiKey && normalizedExisting.apiKey === normalized.apiKey) { - return name; - } - if (isExternal - && !normalizedExisting.apiKey - && normalizedExisting.externalCredentialType === normalized.externalCredentialType) { - return name; - } - } - return ''; -} - -export function formatLatency(result) { - if (!result) return ''; - if (!result.ok) return result.status ? `ERR ${result.status}` : 'ERR'; - const ms = typeof result.durationMs === 'number' ? result.durationMs : 0; - return `${ms}ms`; -} - -export function buildSpeedTestIssue(name, result) { - if (!name || !result) return null; - if (result.error) { - const error = String(result.error || ''); - const errorLower = error.toLowerCase(); - if (error === 'Provider not found') { - return { - code: 'remote-speedtest-provider-missing', - message: `提供商 ${name} 未找到,无法测速`, - suggestion: '检查配置是否存在该 provider' - }; - } - if (error === 'Provider missing URL' || error === 'Missing name or url') { - return { - code: 'remote-speedtest-baseurl-missing', - message: `提供商 ${name} 缺少 base_url`, - suggestion: '补全 base_url 后重试' - }; - } - if (errorLower.includes('invalid url')) { - return { - code: 'remote-speedtest-invalid-url', - message: `提供商 ${name} 的 base_url 无效`, - suggestion: '请设置为 http/https 的完整 URL' - }; - } - if (errorLower.includes('timeout')) { - return { - code: 'remote-speedtest-timeout', - message: `提供商 ${name} 远程测速超时`, - suggestion: '检查网络或 base_url 是否可达' - }; - } - return { - code: 'remote-speedtest-unreachable', - message: `提供商 ${name} 远程测速失败:${error || '无法连接'}`, - suggestion: '检查网络或 base_url 是否可用' - }; - } - - const status = typeof result.status === 'number' ? result.status : 0; - if (status === 401 || status === 403) { - return { - code: 'remote-speedtest-auth-failed', - message: `提供商 ${name} 远程测速鉴权失败(401/403)`, - suggestion: '检查 API Key 或认证方式' - }; - } - if (status >= 400) { - return { - code: 'remote-speedtest-http-error', - message: `提供商 ${name} 远程测速返回异常状态: ${status}`, - suggestion: '检查 base_url 或服务状态' - }; - } - return null; -} - -export async function runLatestOnlyQueue(initialTarget, options = {}) { - const perform = typeof options.perform === 'function' - ? options.perform - : async () => {}; - const consumePending = typeof options.consumePending === 'function' - ? options.consumePending - : () => ''; - let currentTarget = typeof initialTarget === 'string' ? initialTarget.trim() : ''; - let lastError = ''; - - while (currentTarget) { - try { - await perform(currentTarget); - lastError = ''; - } catch (e) { - lastError = e && e.message ? e.message : 'queue task failed'; - } - const queued = String(consumePending() || '').trim(); - if (!queued || queued === currentTarget) { - break; - } - currentTarget = queued; - } - - return { - lastTarget: currentTarget, - lastError - }; -} - -export function shouldForceCompactLayoutMode(options = {}) { - const viewportWidth = Number(options.viewportWidth || 0); - const screenWidth = Number(options.screenWidth || 0); - const screenHeight = Number(options.screenHeight || 0); - const shortEdge = Number(options.shortEdge || (screenWidth > 0 && screenHeight > 0 ? Math.min(screenWidth, screenHeight) : 0)); - const maxTouchPoints = Number(options.maxTouchPoints || 0); - const userAgent = typeof options.userAgent === 'string' ? options.userAgent : ''; - const isMobileUa = typeof options.isMobileUa === 'boolean' - ? options.isMobileUa - : /(Android|iPhone|iPad|iPod|Mobile)/i.test(userAgent); - const coarsePointer = !!options.coarsePointer; - const noHover = !!options.noHover; - const isSmallPhysicalScreen = shortEdge > 0 && shortEdge <= 920; - const isNarrowViewport = viewportWidth > 0 && viewportWidth <= 960; - const pointerSuggestsTouchOnly = coarsePointer && noHover; - - if (isMobileUa) { - return isNarrowViewport || isSmallPhysicalScreen; - } - if (!pointerSuggestsTouchOnly) { - return false; - } - if (maxTouchPoints <= 0) { - return false; - } - return isSmallPhysicalScreen; -} - -// Session filtering helpers -export function isSessionQueryEnabled(source) { - const normalized = normalizeSessionSource(source, ''); - return normalized === 'codex' || normalized === 'claude' || normalized === 'all'; -} - -export function normalizeSessionSource(source, fallback = 'all') { - const normalized = typeof source === 'string' - ? source.trim().toLowerCase() - : ''; - if (normalized === 'codex' || normalized === 'claude' || normalized === 'all') { - return normalized; - } - return fallback; -} - -export function normalizeSessionPathFilter(pathFilter) { - return typeof pathFilter === 'string' ? pathFilter.trim() : ''; -} - -export function buildSessionFilterCacheState(source, pathFilter) { - return { - source: normalizeSessionSource(source, 'all'), - pathFilter: normalizeSessionPathFilter(pathFilter) - }; -} - -export function buildSessionListParams(options = {}) { - const { - source = 'all', - pathFilter = '', - query = '', - roleFilter = 'all', - timeRangePreset = 'all', - limit = 200 - } = options; - const queryValue = isSessionQueryEnabled(source) ? query : ''; - return { - source, - pathFilter, - query: queryValue, - queryMode: 'and', - queryScope: 'content', - contentScanLimit: 50, - roleFilter, - timeRangePreset, - limit, - forceRefresh: true - }; -} - -export function normalizeSessionMessageRole(role) { - const value = typeof role === 'string' ? role.trim().toLowerCase() : ''; - if (value === 'user' || value === 'assistant' || value === 'system') { - return value; - } - return 'assistant'; -} - -function toRoleMeta(role) { - if (role === 'user') { - return { role: 'user', roleLabel: 'User', roleShort: 'U' }; - } - if (role === 'assistant') { - return { role: 'assistant', roleLabel: 'Assistant', roleShort: 'A' }; - } - if (role === 'system') { - return { role: 'system', roleLabel: 'System', roleShort: 'S' }; - } - return { role: 'mixed', roleLabel: 'Mixed', roleShort: 'M' }; -} - -function clampTimelinePercent(percent) { - return Math.max(6, Math.min(94, percent)); -} - -export function formatSessionTimelineTimestamp(timestamp) { - const value = typeof timestamp === 'string' ? timestamp.trim() : ''; - if (!value) return ''; - - // 优先按 ISO/常见时间串抽取,避免本地时区格式差异导致的展示抖动。 - const matched = value.match(/^(\d{4})-(\d{2})-(\d{2})[T\s](\d{2}):(\d{2})(?::(\d{2}))?/); - if (matched) { - const second = matched[6] || '00'; - return `${matched[2]}-${matched[3]} ${matched[4]}:${matched[5]}:${second}`; - } - - return value; -} - -export function buildSessionTimelineNodes(messages = [], options = {}) { - const list = Array.isArray(messages) ? messages : []; - const getKey = typeof options.getKey === 'function' - ? options.getKey - : ((_message, index) => `msg-${index}`); - const total = list.length; - const rawMaxMarkers = Number(options.maxMarkers); - const maxMarkers = Number.isFinite(rawMaxMarkers) - ? Math.max(1, Math.min(80, Math.floor(rawMaxMarkers))) - : 30; - - const buildSingleNode = (message, index) => { - const role = normalizeSessionMessageRole(message && (message.normalizedRole || message.role)); - const roleMeta = toRoleMeta(role); - const key = String(getKey(message, index) || `msg-${index}`); - const displayTime = formatSessionTimelineTimestamp(message && message.timestamp ? message.timestamp : ''); - const title = displayTime - ? `#${index + 1} · ${roleMeta.roleLabel} · ${displayTime}` - : `#${index + 1} · ${roleMeta.roleLabel}`; - const percent = total <= 1 ? 0 : (index / (total - 1)) * 100; - return { - key, - role: roleMeta.role, - roleLabel: roleMeta.roleLabel, - roleShort: roleMeta.roleShort, - displayTime, - title, - percent, - safePercent: clampTimelinePercent(percent) - }; - }; - - if (total <= maxMarkers) { - return list.map((message, index) => buildSingleNode(message, index)); - } - - const nodes = []; - const bucketWidth = total / maxMarkers; - for (let bucket = 0; bucket < maxMarkers; bucket += 1) { - let start = Math.floor(bucket * bucketWidth); - if (nodes.length && start <= nodes[nodes.length - 1].endIndex) { - start = nodes[nodes.length - 1].endIndex + 1; - } - if (start >= total) { - break; - } - let end = Math.floor((bucket + 1) * bucketWidth) - 1; - end = Math.max(start, Math.min(total - 1, end)); - const targetIndex = Math.min(total - 1, start + Math.floor((end - start) / 2)); - const targetMessage = list[targetIndex] || null; - const key = String(getKey(targetMessage, targetIndex) || `msg-${targetIndex}`); - const percent = total <= 1 ? 0 : (targetIndex / (total - 1)) * 100; - const messagesInGroup = end - start + 1; - const roleSet = new Set(); - for (let i = start; i <= end; i += 1) { - roleSet.add(normalizeSessionMessageRole(list[i] && (list[i].normalizedRole || list[i].role))); - } - const roleValue = roleSet.size === 1 ? Array.from(roleSet)[0] : 'mixed'; - const roleMeta = toRoleMeta(roleValue); - const firstTime = formatSessionTimelineTimestamp(list[start] && list[start].timestamp ? list[start].timestamp : ''); - const lastTime = formatSessionTimelineTimestamp(list[end] && list[end].timestamp ? list[end].timestamp : ''); - let displayTime = ''; - if (firstTime && lastTime) { - displayTime = firstTime === lastTime ? firstTime : `${firstTime} ~ ${lastTime}`; - } else { - displayTime = firstTime || lastTime; - } - const titleBase = `#${start + 1}-${end + 1} · ${messagesInGroup} msgs · ${roleMeta.roleLabel}`; - const title = displayTime ? `${titleBase} · ${displayTime}` : titleBase; - nodes.push({ - key, - role: roleMeta.role, - roleLabel: roleMeta.roleLabel, - roleShort: roleMeta.roleShort, - displayTime, - title, - percent, - safePercent: clampTimelinePercent(percent), - startIndex: start, - endIndex: end, - messageCount: messagesInGroup - }); - } - return nodes; -} +// 逻辑纯函数兼容出口:内部按职责拆分,外部保持原有导入路径不变 +export * from './logic.agents-diff.mjs'; +export * from './logic.claude.mjs'; +export * from './logic.runtime.mjs'; +export * from './logic.sessions.mjs'; diff --git a/web-ui/logic.runtime.mjs b/web-ui/logic.runtime.mjs new file mode 100644 index 0000000..68de51c --- /dev/null +++ b/web-ui/logic.runtime.mjs @@ -0,0 +1,124 @@ +export function formatLatency(result) { + if (!result) return ''; + if (!result.ok) return result.status ? `ERR ${result.status}` : 'ERR'; + const ms = (typeof result.durationMs === 'number' && Number.isFinite(result.durationMs)) + ? result.durationMs + : 0; + return `${ms}ms`; +} + +export function buildSpeedTestIssue(name, result) { + if (!name || !result) return null; + if (result.error) { + const error = String(result.error || ''); + const errorLower = error.toLowerCase(); + if (error === 'Provider not found') { + return { + code: 'remote-speedtest-provider-missing', + message: `提供商 ${name} 未找到,无法测速`, + suggestion: '检查配置是否存在该 provider' + }; + } + if (error === 'Provider missing URL' || error === 'Missing name or url') { + return { + code: 'remote-speedtest-baseurl-missing', + message: `提供商 ${name} 缺少 base_url`, + suggestion: '补全 base_url 后重试' + }; + } + if (errorLower.includes('invalid url')) { + return { + code: 'remote-speedtest-invalid-url', + message: `提供商 ${name} 的 base_url 无效`, + suggestion: '请设置为 http/https 的完整 URL' + }; + } + if (errorLower.includes('timeout')) { + return { + code: 'remote-speedtest-timeout', + message: `提供商 ${name} 远程测速超时`, + suggestion: '检查网络或 base_url 是否可达' + }; + } + return { + code: 'remote-speedtest-unreachable', + message: `提供商 ${name} 远程测速失败:${error || '无法连接'}`, + suggestion: '检查网络或 base_url 是否可用' + }; + } + + const status = typeof result.status === 'number' ? result.status : 0; + if (status === 401 || status === 403) { + return { + code: 'remote-speedtest-auth-failed', + message: `提供商 ${name} 远程测速鉴权失败(401/403)`, + suggestion: '检查 API Key 或认证方式' + }; + } + if (status >= 400) { + return { + code: 'remote-speedtest-http-error', + message: `提供商 ${name} 远程测速返回异常状态: ${status}`, + suggestion: '检查 base_url 或服务状态' + }; + } + return null; +} + +export async function runLatestOnlyQueue(initialTarget, options = {}) { + const perform = typeof options.perform === 'function' + ? options.perform + : async () => {}; + const consumePending = typeof options.consumePending === 'function' + ? options.consumePending + : () => ''; + let currentTarget = typeof initialTarget === 'string' ? initialTarget.trim() : ''; + let lastError = ''; + + while (currentTarget) { + try { + await perform(currentTarget); + lastError = ''; + } catch (e) { + lastError = e && e.message ? e.message : 'queue task failed'; + } + const queued = String(consumePending() || '').trim(); + if (!queued || queued === currentTarget) { + break; + } + currentTarget = queued; + } + + return { + lastTarget: currentTarget, + lastError + }; +} + +export function shouldForceCompactLayoutMode(options = {}) { + const viewportWidth = Number(options.viewportWidth || 0); + const screenWidth = Number(options.screenWidth || 0); + const screenHeight = Number(options.screenHeight || 0); + const shortEdge = Number(options.shortEdge || (screenWidth > 0 && screenHeight > 0 ? Math.min(screenWidth, screenHeight) : 0)); + const maxTouchPoints = Number(options.maxTouchPoints || 0); + const userAgent = typeof options.userAgent === 'string' ? options.userAgent : ''; + const isMobileUa = typeof options.isMobileUa === 'boolean' + ? options.isMobileUa + : /(Android|iPhone|iPad|iPod|Mobile)/i.test(userAgent); + const coarsePointer = !!options.coarsePointer; + const noHover = !!options.noHover; + const isSmallPhysicalScreen = shortEdge > 0 && shortEdge <= 920; + const isNarrowViewport = viewportWidth > 0 && viewportWidth <= 960; + const pointerSuggestsTouchOnly = coarsePointer && noHover; + + if (isMobileUa) { + return isNarrowViewport || isSmallPhysicalScreen; + } + if (!pointerSuggestsTouchOnly) { + return false; + } + if (maxTouchPoints <= 0) { + return false; + } + return isSmallPhysicalScreen; +} diff --git a/web-ui/logic.sessions.mjs b/web-ui/logic.sessions.mjs new file mode 100644 index 0000000..8e9b724 --- /dev/null +++ b/web-ui/logic.sessions.mjs @@ -0,0 +1,175 @@ +export function isSessionQueryEnabled(source) { + const normalized = normalizeSessionSource(source, ''); + return normalized === 'codex' || normalized === 'claude' || normalized === 'all'; +} + +export function normalizeSessionSource(source, fallback = 'all') { + const normalized = typeof source === 'string' + ? source.trim().toLowerCase() + : ''; + if (normalized === 'codex' || normalized === 'claude' || normalized === 'all') { + return normalized; + } + return fallback; +} + +export function normalizeSessionPathFilter(pathFilter) { + return typeof pathFilter === 'string' ? pathFilter.trim() : ''; +} + +export function buildSessionFilterCacheState(source, pathFilter) { + return { + source: normalizeSessionSource(source, 'all'), + pathFilter: normalizeSessionPathFilter(pathFilter) + }; +} + +export function buildSessionListParams(options = {}) { + const { + source = 'all', + pathFilter = '', + query = '', + roleFilter = 'all', + timeRangePreset = 'all', + limit = 200 + } = options; + const normalizedSource = normalizeSessionSource(source, 'all'); + const normalizedPathFilter = normalizeSessionPathFilter(pathFilter); + const queryValue = isSessionQueryEnabled(normalizedSource) ? query : ''; + return { + source: normalizedSource, + pathFilter: normalizedPathFilter, + query: queryValue, + queryMode: 'and', + queryScope: 'content', + contentScanLimit: 50, + roleFilter, + timeRangePreset, + limit, + forceRefresh: true + }; +} + +export function normalizeSessionMessageRole(role) { + const value = typeof role === 'string' ? role.trim().toLowerCase() : ''; + if (value === 'user' || value === 'assistant' || value === 'system') { + return value; + } + return 'assistant'; +} + +function toRoleMeta(role) { + if (role === 'user') { + return { role: 'user', roleLabel: 'User', roleShort: 'U' }; + } + if (role === 'assistant') { + return { role: 'assistant', roleLabel: 'Assistant', roleShort: 'A' }; + } + if (role === 'system') { + return { role: 'system', roleLabel: 'System', roleShort: 'S' }; + } + return { role: 'mixed', roleLabel: 'Mixed', roleShort: 'M' }; +} + +function clampTimelinePercent(percent) { + return Math.max(6, Math.min(94, percent)); +} + +export function formatSessionTimelineTimestamp(timestamp) { + const value = typeof timestamp === 'string' ? timestamp.trim() : ''; + if (!value) return ''; + + const matched = value.match(/^(\d{4})-(\d{2})-(\d{2})[T\s](\d{2}):(\d{2})(?::(\d{2}))?/); + if (matched) { + const second = matched[6] || '00'; + return `${matched[2]}-${matched[3]} ${matched[4]}:${matched[5]}:${second}`; + } + + return value; +} + +export function buildSessionTimelineNodes(messages = [], options = {}) { + const list = Array.isArray(messages) ? messages : []; + const getKey = typeof options.getKey === 'function' + ? options.getKey + : ((_message, index) => `msg-${index}`); + const total = list.length; + const rawMaxMarkers = Number(options.maxMarkers); + const maxMarkers = Number.isFinite(rawMaxMarkers) + ? Math.max(1, Math.min(80, Math.floor(rawMaxMarkers))) + : 30; + + const buildSingleNode = (message, index) => { + const role = normalizeSessionMessageRole(message && (message.normalizedRole || message.role)); + const roleMeta = toRoleMeta(role); + const key = String(getKey(message, index) || `msg-${index}`); + const displayTime = formatSessionTimelineTimestamp(message && message.timestamp ? message.timestamp : ''); + const title = displayTime + ? `#${index + 1} · ${roleMeta.roleLabel} · ${displayTime}` + : `#${index + 1} · ${roleMeta.roleLabel}`; + const percent = total <= 1 ? 0 : (index / (total - 1)) * 100; + return { + key, + role: roleMeta.role, + roleLabel: roleMeta.roleLabel, + roleShort: roleMeta.roleShort, + displayTime, + title, + percent, + safePercent: clampTimelinePercent(percent) + }; + }; + + if (total <= maxMarkers) { + return list.map((message, index) => buildSingleNode(message, index)); + } + + const nodes = []; + const bucketWidth = total / maxMarkers; + for (let bucket = 0; bucket < maxMarkers; bucket += 1) { + let start = Math.floor(bucket * bucketWidth); + if (nodes.length && start <= nodes[nodes.length - 1].endIndex) { + start = nodes[nodes.length - 1].endIndex + 1; + } + if (start >= total) { + break; + } + let end = Math.floor((bucket + 1) * bucketWidth) - 1; + end = Math.max(start, Math.min(total - 1, end)); + const targetIndex = Math.min(total - 1, start + Math.floor((end - start) / 2)); + const targetMessage = list[targetIndex] || null; + const key = String(getKey(targetMessage, targetIndex) || `msg-${targetIndex}`); + const percent = total <= 1 ? 0 : (targetIndex / (total - 1)) * 100; + const messagesInGroup = end - start + 1; + const roleSet = new Set(); + for (let i = start; i <= end; i += 1) { + roleSet.add(normalizeSessionMessageRole(list[i] && (list[i].normalizedRole || list[i].role))); + } + const roleValue = roleSet.size === 1 ? Array.from(roleSet)[0] : 'mixed'; + const roleMeta = toRoleMeta(roleValue); + const firstTime = formatSessionTimelineTimestamp(list[start] && list[start].timestamp ? list[start].timestamp : ''); + const lastTime = formatSessionTimelineTimestamp(list[end] && list[end].timestamp ? list[end].timestamp : ''); + let displayTime = ''; + if (firstTime && lastTime) { + displayTime = firstTime === lastTime ? firstTime : `${firstTime} ~ ${lastTime}`; + } else { + displayTime = firstTime || lastTime; + } + const titleBase = `#${start + 1}-${end + 1} · ${messagesInGroup} msgs · ${roleMeta.roleLabel}`; + const title = displayTime ? `${titleBase} · ${displayTime}` : titleBase; + nodes.push({ + key, + role: roleMeta.role, + roleLabel: roleMeta.roleLabel, + roleShort: roleMeta.roleShort, + displayTime, + title, + percent, + safePercent: clampTimelinePercent(percent), + startIndex: start, + endIndex: end, + messageCount: messagesInGroup + }); + } + return nodes; +} diff --git a/web-ui/modules/api.mjs b/web-ui/modules/api.mjs new file mode 100644 index 0000000..416388f --- /dev/null +++ b/web-ui/modules/api.mjs @@ -0,0 +1,69 @@ +const browserLocation = typeof location !== 'undefined' ? location : null; + +export const API_BASE = (browserLocation && browserLocation.origin && browserLocation.origin !== 'null') + ? browserLocation.origin + : 'http://localhost:3737'; + +async function postApi(action, params = {}) { + return await fetch(`${API_BASE}/api`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action, params }) + }); +} + +function buildApiResponseContext(action, res, contentType) { + return `${action} (${res.status} ${res.statusText}, content-type: ${contentType || 'unknown'})`; +} + +function withPayloadTooLargeErrorCode(res, payload) { + if (res.status !== 413 || (payload && typeof payload === 'object' && payload.errorCode)) { + return payload; + } + return { ...payload, errorCode: 'payload-too-large' }; +} + +export async function api(action, params = {}) { + const res = await postApi(action, params); + const contentType = String(res.headers.get('content-type') || '').toLowerCase(); + if (contentType && !contentType.includes('application/json')) { + const body = await res.text(); + const errorDetails = buildApiResponseContext(action, res, contentType); + const bodyDetails = body ? `: ${body}` : ''; + throw new Error(`Unexpected non-JSON API response for ${errorDetails}${bodyDetails}`); + } + try { + return await res.json(); + } catch (error) { + const errorDetails = buildApiResponseContext(action, res, contentType); + throw new Error(`Failed to parse API response for ${errorDetails}: ${error.message}`); + } +} + +export async function apiWithMeta(action, params = {}) { + const res = await postApi(action, params); + const contentType = String(res.headers.get('content-type') || '').toLowerCase(); + if (contentType.includes('application/json')) { + try { + const payload = await res.json(); + if (payload && typeof payload === 'object' && !Array.isArray(payload)) { + return { ...withPayloadTooLargeErrorCode(res, payload), ok: res.ok, status: res.status }; + } + return res.status === 413 + ? { ok: res.ok, status: res.status, data: payload, errorCode: 'payload-too-large' } + : { ok: res.ok, status: res.status, data: payload }; + } catch (error) { + if (res.status === 413) { + return { ok: false, status: 413, errorCode: 'payload-too-large' }; + } + throw error; + } + } + const error = await res.text(); + return { + ok: res.ok, + status: res.status, + error, + errorCode: res.status === 413 ? 'payload-too-large' : '' + }; +} diff --git a/web-ui/modules/app.computed.dashboard.mjs b/web-ui/modules/app.computed.dashboard.mjs new file mode 100644 index 0000000..29d4201 --- /dev/null +++ b/web-ui/modules/app.computed.dashboard.mjs @@ -0,0 +1,113 @@ +export function createDashboardComputed() { + return { + agentsDiffHasChanges() { + if (this.agentsDiffTruncated) { + return !!this.agentsDiffHasChangesValue; + } + const stats = this.agentsDiffStats || {}; + const added = Number(stats.added || 0); + const removed = Number(stats.removed || 0); + return added > 0 || removed > 0; + }, + claudeModelHasList() { + return this.claudeModelOptions.length > 0; + }, + claudeModelOptions() { + const list = Array.isArray(this.claudeModels) ? [...this.claudeModels] : []; + const current = (this.currentClaudeModel || '').trim(); + if (current && !list.includes(current)) { + list.unshift(current); + } + return list; + }, + hasLocalAndProxy() { + return false; + }, + displayCurrentProvider() { + const switching = String(this.providerSwitchDisplayTarget || '').trim(); + if (switching) return switching; + const current = String(this.currentProvider || '').trim(); + return current; + }, + displayProvidersList() { + const list = Array.isArray(this.providersList) ? this.providersList : []; + return list.filter((item) => String(item && item.name ? item.name : '').trim().toLowerCase() !== 'codexmate-proxy'); + }, + installTargetCards() { + const targets = Array.isArray(this.installStatusTargets) ? this.installStatusTargets : []; + const action = this.normalizeInstallAction(this.installCommandAction); + return targets.map((target) => { + const id = target && typeof target.id === 'string' ? target.id : ''; + return { + ...target, + command: this.getInstallCommand(id, action) + }; + }); + }, + installRegistryPreview() { + return this.resolveInstallRegistryUrl(this.installRegistryPreset, this.installRegistryCustom); + }, + inspectorBusyStatus() { + const tasks = []; + if (this.loading) tasks.push('初始化'); + if (this.sessionsLoading) tasks.push('会话加载'); + if (this.codexModelsLoading || this.claudeModelsLoading) tasks.push('模型加载'); + if (this.codexApplying || this.configTemplateApplying || this.openclawApplying) tasks.push('配置应用'); + if (this.agentsSaving) tasks.push('AGENTS 保存'); + if (this.skillsLoading || this.skillsDeleting || this.skillsScanningImports || this.skillsImporting || this.skillsZipImporting || this.skillsExporting) tasks.push('Skills 管理'); + return tasks.length ? tasks.join(' / ') : '空闲'; + }, + inspectorMessageSummary() { + const value = typeof this.message === 'string' ? this.message.trim() : ''; + return value || '暂无提示'; + }, + inspectorSessionSourceLabel() { + if (this.sessionFilterSource === 'codex') return 'Codex'; + if (this.sessionFilterSource === 'claude') return 'Claude Code'; + return '全部'; + }, + inspectorSessionPathLabel() { + const value = typeof this.sessionPathFilter === 'string' ? this.sessionPathFilter.trim() : ''; + return value || '全部路径'; + }, + inspectorSessionQueryLabel() { + if (!this.isSessionQueryEnabled) return '当前来源不支持'; + const value = typeof this.sessionQuery === 'string' ? this.sessionQuery.trim() : ''; + return value || '未设置'; + }, + inspectorHealthStatus() { + if (this.initError) return '读取失败'; + if (this.loading) return '初始化中'; + return '正常'; + }, + inspectorHealthTone() { + if (this.initError) return 'error'; + if (this.loading) return 'warn'; + return 'ok'; + }, + inspectorModelLoadStatus() { + if (this.codexModelsLoading || this.claudeModelsLoading) { + return '加载中'; + } + if (this.modelsSource === 'error' || this.claudeModelsSource === 'error') { + return '加载异常'; + } + return '正常'; + }, + installTroubleshootingTips() { + const platform = this.resolveInstallPlatform(); + if (platform === 'win32') { + return [ + 'PowerShell 报权限不足(EACCES/EPERM)时,请以管理员身份执行安装命令。', + '安装后若仍提示找不到命令,重开终端并执行:where codex / where claude。', + '公司网络受限时,可先切换镜像源快捷项(npmmirror / 腾讯云 / 自定义)。' + ]; + } + return [ + '出现 EACCES 权限错误时,优先修复 Node 全局目录权限,不建议直接 sudo npm。', + '安装后若命令未生效,重开终端并执行:which codex / which claude。', + '公司网络受限时,可先切换镜像源快捷项(npmmirror / 腾讯云 / 自定义)。' + ]; + } + }; +} diff --git a/web-ui/modules/app.computed.index.mjs b/web-ui/modules/app.computed.index.mjs new file mode 100644 index 0000000..3521f25 --- /dev/null +++ b/web-ui/modules/app.computed.index.mjs @@ -0,0 +1,13 @@ +import { createDashboardComputed } from './app.computed.dashboard.mjs'; +import { createSessionComputed } from './app.computed.session.mjs'; +import { createConfigModeComputed } from './config-mode.computed.mjs'; +import { createSkillsComputed } from './skills.computed.mjs'; + +export function createAppComputed() { + return { + ...createSessionComputed(), + ...createDashboardComputed(), + ...createSkillsComputed(), + ...createConfigModeComputed() + }; +} diff --git a/web-ui/modules/app.computed.session.mjs b/web-ui/modules/app.computed.session.mjs new file mode 100644 index 0000000..d43fac6 --- /dev/null +++ b/web-ui/modules/app.computed.session.mjs @@ -0,0 +1,125 @@ +import { + buildSessionTimelineNodes, + isSessionQueryEnabled +} from '../logic.mjs'; +import { SESSION_TRASH_PAGE_SIZE } from './app.constants.mjs'; + +export function createSessionComputed() { + return { + isSessionQueryEnabled() { + return isSessionQueryEnabled(this.sessionFilterSource); + }, + activeSessionExportKey() { + return this.activeSession ? this.getSessionExportKey(this.activeSession) : ''; + }, + sortedSessionsList() { + const list = Array.isArray(this.sessionsList) ? this.sessionsList : []; + if (list.length === 0) return []; + const pinnedMap = (this.sessionPinnedMap && typeof this.sessionPinnedMap === 'object') + ? this.sessionPinnedMap + : {}; + let hasPinned = false; + const decorated = list.map((session, index) => { + const key = session ? this.getSessionExportKey(session) : ''; + const rawPinnedAt = key ? pinnedMap[key] : 0; + const pinnedAt = Number.isFinite(Number(rawPinnedAt)) + ? Math.floor(Number(rawPinnedAt)) + : 0; + const isPinned = pinnedAt > 0; + if (isPinned) { + hasPinned = true; + } + return { session, index, pinnedAt, isPinned }; + }); + if (!hasPinned) return list; + decorated.sort((a, b) => { + if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1; + if (a.isPinned && a.pinnedAt !== b.pinnedAt) return b.pinnedAt - a.pinnedAt; + return a.index - b.index; + }); + return decorated.map(item => item.session); + }, + activeSessionVisibleMessages() { + if (this.mainTab !== 'sessions' || !this.sessionPreviewRenderEnabled) { + return []; + } + const list = Array.isArray(this.activeSessionMessages) ? this.activeSessionMessages : []; + const rawCount = Number(this.sessionPreviewVisibleCount); + const visibleCount = Number.isFinite(rawCount) + ? Math.max(0, Math.floor(rawCount)) + : 0; + if (visibleCount <= 0) { + if (!list.length) return []; + return list.slice(0, Math.min(8, list.length)); + } + if (visibleCount >= list.length) return list; + return list.slice(0, visibleCount); + }, + canLoadMoreSessionMessages() { + if (this.mainTab !== 'sessions' || !this.sessionPreviewRenderEnabled) { + return false; + } + const total = Array.isArray(this.activeSessionMessages) ? this.activeSessionMessages.length : 0; + const visible = Array.isArray(this.activeSessionVisibleMessages) ? this.activeSessionVisibleMessages.length : 0; + return total > visible; + }, + sessionPreviewRemainingCount() { + const total = Array.isArray(this.activeSessionMessages) ? this.activeSessionMessages.length : 0; + const visible = Array.isArray(this.activeSessionVisibleMessages) ? this.activeSessionVisibleMessages.length : 0; + return Math.max(0, total - visible); + }, + sessionTimelineNodes() { + if (this.mainTab !== 'sessions' || !this.sessionPreviewRenderEnabled) { + return []; + } + return buildSessionTimelineNodes(this.activeSessionVisibleMessages, { + getKey: (message, index) => this.getRecordRenderKey(message, index) + }); + }, + sessionTimelineNodeKeyMap() { + const nodes = Array.isArray(this.sessionTimelineNodes) ? this.sessionTimelineNodes : []; + if (!nodes.length) { + return Object.create(null); + } + const map = Object.create(null); + for (const node of nodes) { + if (!node || !node.key) continue; + map[node.key] = true; + } + return map; + }, + sessionTimelineActiveTitle() { + if (!this.sessionTimelineActiveKey) return ''; + const nodes = Array.isArray(this.sessionTimelineNodes) ? this.sessionTimelineNodes : []; + const matched = nodes.find(node => node.key === this.sessionTimelineActiveKey); + return matched ? matched.title : ''; + }, + sessionQueryPlaceholder() { + if (this.isSessionQueryEnabled) { + return '关键词检索(支持 Codex/Claude,例:claude code)'; + } + return '当前来源暂不支持关键词检索'; + }, + visibleSessionTrashItems() { + const items = Array.isArray(this.sessionTrashItems) ? this.sessionTrashItems : []; + const visibleCount = Number(this.sessionTrashVisibleCount); + const safeVisibleCount = Number.isFinite(visibleCount) && visibleCount > 0 + ? Math.floor(visibleCount) + : SESSION_TRASH_PAGE_SIZE; + return items.slice(0, safeVisibleCount); + }, + sessionTrashHasMoreItems() { + return this.visibleSessionTrashItems.length < this.sessionTrashCount; + }, + sessionTrashHiddenCount() { + return Math.max(0, this.sessionTrashCount - this.visibleSessionTrashItems.length); + }, + sessionTrashCount() { + const totalCount = Number(this.sessionTrashTotalCount); + if (Number.isFinite(totalCount) && totalCount >= 0) { + return Math.max(0, Math.floor(totalCount)); + } + return Array.isArray(this.sessionTrashItems) ? this.sessionTrashItems.length : 0; + } + }; +} diff --git a/web-ui/modules/app.constants.mjs b/web-ui/modules/app.constants.mjs new file mode 100644 index 0000000..0a42243 --- /dev/null +++ b/web-ui/modules/app.constants.mjs @@ -0,0 +1,15 @@ +export const SESSION_TRASH_LIST_LIMIT = 500; +export const SESSION_TRASH_PAGE_SIZE = 200; +export const DEFAULT_MODEL_CONTEXT_WINDOW = 190000; +export const DEFAULT_MODEL_AUTO_COMPACT_TOKEN_LIMIT = 185000; +export const DEFAULT_OPENCLAW_TEMPLATE = `{ + // OpenClaw config (JSON5) + agent: { + model: "gpt-4.1" + }, + agents: { + defaults: { + workspace: "~/.openclaw/workspace" + } + } +}`; diff --git a/web-ui/modules/app.methods.agents.mjs b/web-ui/modules/app.methods.agents.mjs new file mode 100644 index 0000000..3bf0c6f --- /dev/null +++ b/web-ui/modules/app.methods.agents.mjs @@ -0,0 +1,493 @@ +import { + buildAgentsDiffPreview, + buildAgentsDiffPreviewRequest, + isAgentsDiffPreviewPayloadTooLarge, + shouldApplyAgentsDiffPreviewResponse +} from '../logic.mjs'; + +function isValidOpenclawWorkspaceFileName(fileName) { + if (typeof fileName !== 'string') { + return false; + } + const normalized = fileName.trim(); + if (!normalized || !normalized.endsWith('.md')) { + return false; + } + if (normalized.startsWith('/') || normalized.includes('\\') || normalized.includes('/') || normalized.includes('..')) { + return false; + } + return true; +} + +function issueLatestRequestToken(context, key) { + const token = (Number(context[key]) || 0) + 1; + context[key] = token; + return token; +} + +function isLatestRequestToken(context, key, token) { + return !!context && context[key] === token; +} + +export function createAgentsMethods(options = {}) { + const { + api, + apiWithMeta + } = options; + + return { + async openAgentsEditor() { + this.setAgentsModalContext('codex'); + const requestToken = issueLatestRequestToken(this, '_agentsOpenRequestToken'); + this.agentsLoading = true; + try { + const res = await api('get-agents-file'); + if (!isLatestRequestToken(this, '_agentsOpenRequestToken', requestToken)) { + return; + } + if (res.error) { + this.showMessage(res.error, 'error'); + return; + } + this.agentsContent = res.content || ''; + this.agentsOriginalContent = this.agentsContent; + this.agentsPath = res.path || ''; + this.agentsExists = !!res.exists; + this.agentsLineEnding = res.lineEnding === '\r\n' ? '\r\n' : '\n'; + this.resetAgentsDiffState(); + this.showAgentsModal = true; + } catch (e) { + if (!isLatestRequestToken(this, '_agentsOpenRequestToken', requestToken)) { + return; + } + this.showMessage('加载文件失败', 'error'); + } finally { + if (isLatestRequestToken(this, '_agentsOpenRequestToken', requestToken)) { + this.agentsLoading = false; + } + } + }, + + async openOpenclawAgentsEditor() { + this.setAgentsModalContext('openclaw'); + const requestToken = issueLatestRequestToken(this, '_agentsOpenRequestToken'); + this.agentsLoading = true; + try { + const res = await api('get-openclaw-agents-file'); + if (!isLatestRequestToken(this, '_agentsOpenRequestToken', requestToken)) { + return; + } + if (res.error) { + this.showMessage(res.error, 'error'); + return; + } + if (res.configError) { + this.showMessage(`OpenClaw 配置解析失败,已使用默认 Workspace:${res.configError}`, 'error'); + } + this.agentsContent = res.content || ''; + this.agentsOriginalContent = this.agentsContent; + this.agentsPath = res.path || ''; + this.agentsExists = !!res.exists; + this.agentsLineEnding = res.lineEnding === '\r\n' ? '\r\n' : '\n'; + this.resetAgentsDiffState(); + this.showAgentsModal = true; + } catch (e) { + if (!isLatestRequestToken(this, '_agentsOpenRequestToken', requestToken)) { + return; + } + this.showMessage('加载文件失败', 'error'); + } finally { + if (isLatestRequestToken(this, '_agentsOpenRequestToken', requestToken)) { + this.agentsLoading = false; + } + } + }, + + async openOpenclawWorkspaceEditor() { + const fileName = (this.openclawWorkspaceFileName || '').trim(); + if (!fileName) { + this.showMessage('请输入文件名', 'error'); + return; + } + if (!isValidOpenclawWorkspaceFileName(fileName)) { + this.showMessage('仅支持 OpenClaw Workspace 内的 `.md` 文件', 'error'); + return; + } + this.setAgentsModalContext('openclaw-workspace', { fileName }); + const requestToken = issueLatestRequestToken(this, '_agentsOpenRequestToken'); + this.agentsLoading = true; + try { + const res = await api('get-openclaw-workspace-file', { fileName }); + if (!isLatestRequestToken(this, '_agentsOpenRequestToken', requestToken)) { + return; + } + if (res.error) { + this.showMessage(res.error, 'error'); + return; + } + if (res.configError) { + this.showMessage(`OpenClaw 配置解析失败,已使用默认 Workspace:${res.configError}`, 'error'); + } + this.agentsContent = res.content || ''; + this.agentsOriginalContent = this.agentsContent; + this.agentsPath = res.path || ''; + this.agentsExists = !!res.exists; + this.agentsLineEnding = res.lineEnding === '\r\n' ? '\r\n' : '\n'; + this.resetAgentsDiffState(); + this.showAgentsModal = true; + } catch (e) { + if (!isLatestRequestToken(this, '_agentsOpenRequestToken', requestToken)) { + return; + } + this.showMessage('加载文件失败', 'error'); + } finally { + if (isLatestRequestToken(this, '_agentsOpenRequestToken', requestToken)) { + this.agentsLoading = false; + } + } + }, + + setAgentsModalContext(context, options = {}) { + if (context === 'openclaw-workspace') { + const fileName = (options.fileName || this.openclawWorkspaceFileName || 'AGENTS.md').trim(); + this.agentsContext = 'openclaw-workspace'; + this.agentsWorkspaceFileName = fileName; + this.agentsModalTitle = `OpenClaw 工作区文件: ${fileName}`; + this.agentsModalHint = `保存后会写入 OpenClaw Workspace 下的 ${fileName}。`; + return; + } + this.agentsContext = context === 'openclaw' ? 'openclaw' : 'codex'; + if (this.agentsContext === 'openclaw') { + this.agentsModalTitle = 'OpenClaw AGENTS.md 编辑器'; + this.agentsModalHint = '保存后会写入 OpenClaw Workspace 下的 AGENTS.md。'; + } else { + this.agentsModalTitle = 'AGENTS.md 编辑器'; + this.agentsModalHint = '保存后会写入目标 AGENTS.md(与 config.toml 同级)。'; + } + this.agentsWorkspaceFileName = ''; + }, + + resetAgentsDiffState() { + this.agentsDiffVisible = false; + this.agentsDiffLoading = false; + this.agentsDiffError = ''; + this.agentsDiffLines = []; + this.agentsDiffStats = { + added: 0, + removed: 0, + unchanged: 0 + }; + this.agentsDiffTruncated = false; + this.agentsDiffHasChangesValue = false; + this.agentsDiffFingerprint = ''; + this._agentsDiffPreviewRequestToken = null; + }, + handleGlobalKeydown(event) { + if (!event || event.key !== 'Escape') { + return; + } + if (this.showConfirmDialog) { + event.preventDefault(); + event.stopPropagation(); + this.resolveConfirmDialog(false); + return; + } + if (!this.showAgentsModal) { + return; + } + event.preventDefault(); + event.stopPropagation(); + if (this.agentsSaving || this.agentsDiffLoading) { + return; + } + if (this.agentsDiffVisible) { + this.resetAgentsDiffState(); + return; + } + this.closeAgentsModal(); + }, + hasPendingAgentsDraft() { + if (!this.showAgentsModal || this.agentsLoading) { + return false; + } + return !!this.agentsSaving || this.hasAgentsContentChanged() || this.agentsDiffVisible; + }, + handleBeforeUnload(event) { + if (!this.hasPendingAgentsDraft()) { + return; + } + if (event && typeof event.preventDefault === 'function') { + event.preventDefault(); + event.returnValue = ''; + } + return ''; + }, + hasAgentsContentChanged() { + const original = typeof this.agentsOriginalContent === 'string' ? this.agentsOriginalContent : ''; + const current = typeof this.agentsContent === 'string' ? this.agentsContent : ''; + return original !== current; + }, + requestConfirmDialog(options = {}) { + if (typeof this.confirmDialogResolver === 'function') { + this.confirmDialogResolver(false); + } + const confirmDisabled = options.confirmDisabled; + this.confirmDialogTitle = typeof options.title === 'string' && options.title.trim() + ? options.title.trim() + : '请确认操作'; + this.confirmDialogMessage = typeof options.message === 'string' ? options.message : ''; + this.confirmDialogConfirmText = typeof options.confirmText === 'string' && options.confirmText.trim() + ? options.confirmText.trim() + : '确认'; + this.confirmDialogCancelText = typeof options.cancelText === 'string' && options.cancelText.trim() + ? options.cancelText.trim() + : '取消'; + this.confirmDialogDanger = !!options.danger; + this.confirmDialogConfirmDisabled = typeof confirmDisabled === 'function' ? false : !!confirmDisabled; + this.confirmDialogDisableWhen = typeof confirmDisabled === 'function' ? confirmDisabled : null; + this.showConfirmDialog = true; + return new Promise((resolve) => { + this.confirmDialogResolver = resolve; + }); + }, + isConfirmDialogDisabled() { + if (typeof this.confirmDialogDisableWhen === 'function') { + try { + return !!this.confirmDialogDisableWhen.call(this); + } catch (_) { + return true; + } + } + return !!this.confirmDialogConfirmDisabled; + }, + resolveConfirmDialog(confirmed) { + const resolver = typeof this.confirmDialogResolver === 'function' + ? this.confirmDialogResolver + : null; + this.showConfirmDialog = false; + this.confirmDialogTitle = ''; + this.confirmDialogMessage = ''; + this.confirmDialogConfirmText = '确认'; + this.confirmDialogCancelText = '取消'; + this.confirmDialogDanger = false; + this.confirmDialogConfirmDisabled = false; + this.confirmDialogDisableWhen = null; + this.confirmDialogResolver = null; + if (resolver) { + resolver(!!confirmed); + } + }, + closeConfirmDialog() { + this.resolveConfirmDialog(false); + }, + onAgentsContentInput() { + if (this.agentsDiffVisible || this.agentsDiffLines.length) { + this.resetAgentsDiffState(); + } + }, + buildAgentsDiffFingerprint() { + const context = this.agentsContext || 'codex'; + const fileName = context === 'openclaw-workspace' + ? (this.agentsWorkspaceFileName || '') + : ''; + const lineEnding = this.agentsLineEnding || '\n'; + const content = typeof this.agentsContent === 'string' ? this.agentsContent : ''; + const original = typeof this.agentsOriginalContent === 'string' ? this.agentsOriginalContent : ''; + return `${context}::${fileName}::${lineEnding}::${content.length}::${content}::${original.length}::${original}`; + }, + async prepareAgentsDiff() { + const requestFingerprint = this.buildAgentsDiffFingerprint(); + const requestToken = Symbol('agents-diff-preview'); + this._agentsDiffPreviewRequestToken = requestToken; + this.agentsDiffVisible = true; + this.agentsDiffLoading = true; + this.agentsDiffError = ''; + this.agentsDiffLines = []; + this.agentsDiffStats = { + added: 0, + removed: 0, + unchanged: 0 + }; + this.agentsDiffTruncated = false; + this.agentsDiffHasChangesValue = false; + try { + const shouldApplyPreviewState = () => shouldApplyAgentsDiffPreviewResponse({ + isVisible: this.agentsDiffVisible, + requestToken, + activeRequestToken: this._agentsDiffPreviewRequestToken, + requestFingerprint, + currentFingerprint: this.buildAgentsDiffFingerprint() + }); + const applyPreviewState = (diff) => { + if (!shouldApplyPreviewState()) { + return false; + } + const normalizedDiff = diff && typeof diff === 'object' ? diff : {}; + const rawLines = Array.isArray(normalizedDiff.lines) ? normalizedDiff.lines : []; + this.agentsDiffLines = rawLines.filter(line => line && line.type); + this.agentsDiffTruncated = !!normalizedDiff.truncated; + this.agentsDiffHasChangesValue = !!normalizedDiff.hasChanges; + if (normalizedDiff.stats && typeof normalizedDiff.stats === 'object') { + this.agentsDiffStats = { + added: Number(normalizedDiff.stats.added || 0), + removed: Number(normalizedDiff.stats.removed || 0), + unchanged: Number(normalizedDiff.stats.unchanged || 0) + }; + } else { + const stats = { added: 0, removed: 0, unchanged: 0 }; + for (const line of this.agentsDiffLines) { + if (line && line.type === 'add') stats.added += 1; + else if (line && line.type === 'del') stats.removed += 1; + else stats.unchanged += 1; + } + this.agentsDiffStats = stats; + } + this.agentsDiffFingerprint = requestFingerprint; + return true; + }; + const previewRequest = buildAgentsDiffPreviewRequest({ + baseContent: this.agentsOriginalContent, + content: this.agentsContent, + lineEnding: this.agentsLineEnding, + context: this.agentsContext, + fileName: this.agentsWorkspaceFileName + }); + if (previewRequest.exceedsBodyLimit) { + applyPreviewState(buildAgentsDiffPreview({ + baseContent: this.agentsOriginalContent, + content: this.agentsContent + })); + return; + } + const res = await apiWithMeta('preview-agents-diff', previewRequest.params); + if (!shouldApplyPreviewState()) { + return; + } + if (res.error) { + if (isAgentsDiffPreviewPayloadTooLarge(res)) { + applyPreviewState(buildAgentsDiffPreview({ + baseContent: this.agentsOriginalContent, + content: this.agentsContent + })); + return; + } + this.agentsDiffError = res.error; + return; + } + applyPreviewState(res.diff); + } catch (e) { + if (shouldApplyAgentsDiffPreviewResponse({ + isVisible: this.agentsDiffVisible, + requestToken, + activeRequestToken: this._agentsDiffPreviewRequestToken, + requestFingerprint, + currentFingerprint: this.buildAgentsDiffFingerprint() + })) { + this.agentsDiffError = '生成差异失败'; + } + } finally { + if (this._agentsDiffPreviewRequestToken === requestToken) { + this.agentsDiffLoading = false; + } + } + }, + + async closeAgentsModal(options = {}) { + const force = !!(options && options.force); + if (!force && (this.agentsSaving || this.agentsDiffLoading)) { + return; + } + const shouldConfirmClose = !force + && this.hasPendingAgentsDraft(); + if (shouldConfirmClose) { + const message = this.agentsDiffVisible + ? '当前处于差异预览模式,改动尚未保存。确认放弃改动并关闭吗?' + : '存在未保存改动,确认放弃改动并关闭吗?(关闭页面或应用也会丢失改动)'; + const confirmed = await this.requestConfirmDialog({ + title: '放弃未保存改动', + message, + confirmText: '放弃并关闭', + cancelText: '继续编辑', + danger: true + }); + if (!confirmed) { + return; + } + } + issueLatestRequestToken(this, '_agentsOpenRequestToken'); + this.agentsLoading = false; + this.showAgentsModal = false; + this.agentsContent = ''; + this.agentsOriginalContent = ''; + this.agentsPath = ''; + this.agentsExists = false; + this.agentsLineEnding = '\n'; + this.agentsSaving = false; + this.agentsWorkspaceFileName = ''; + this.resetAgentsDiffState(); + this.setAgentsModalContext('codex'); + }, + + async applyAgentsContent() { + if (this.agentsSaving) { + return; + } + if (!this.agentsDiffVisible) { + if (!this.hasAgentsContentChanged()) { + this.showMessage('未检测到改动', 'info'); + return; + } + await this.prepareAgentsDiff(); + return; + } + if (this.agentsDiffLoading) { + return; + } + if (this.agentsDiffError) { + this.showMessage(this.agentsDiffError, 'error'); + return; + } + const fingerprint = this.buildAgentsDiffFingerprint(); + if (this.agentsDiffFingerprint !== fingerprint) { + await this.prepareAgentsDiff(); + return; + } + if (!this.agentsDiffHasChanges) { + this.showMessage('未检测到改动', 'info'); + return; + } + if (this.agentsContext === 'openclaw-workspace' && !isValidOpenclawWorkspaceFileName(this.agentsWorkspaceFileName)) { + this.showMessage('仅支持 OpenClaw Workspace 内的 `.md` 文件', 'error'); + return; + } + this.agentsSaving = true; + try { + let action = 'apply-agents-file'; + const params = { + content: this.agentsContent, + lineEnding: this.agentsLineEnding + }; + if (this.agentsContext === 'openclaw') { + action = 'apply-openclaw-agents-file'; + } else if (this.agentsContext === 'openclaw-workspace') { + action = 'apply-openclaw-workspace-file'; + params.fileName = this.agentsWorkspaceFileName; + } + const res = await api(action, params); + if (res.error) { + this.showMessage(res.error, 'error'); + return; + } + const successLabel = this.agentsContext === 'openclaw-workspace' + ? `工作区文件已保存${this.agentsWorkspaceFileName ? `: ${this.agentsWorkspaceFileName}` : ''}` + : (this.agentsContext === 'openclaw' ? 'OpenClaw AGENTS.md 已保存' : 'AGENTS.md 已保存'); + this.showMessage(successLabel, 'success'); + this.closeAgentsModal({ force: true }); + } catch (e) { + this.showMessage('保存失败', 'error'); + } finally { + this.agentsSaving = false; + } + } + }; +} diff --git a/web-ui/modules/app.methods.claude-config.mjs b/web-ui/modules/app.methods.claude-config.mjs new file mode 100644 index 0000000..cb96bca --- /dev/null +++ b/web-ui/modules/app.methods.claude-config.mjs @@ -0,0 +1,174 @@ +export function createClaudeConfigMethods(options = {}) { + const { api } = options; + + return { + switchClaudeConfig(name) { + this.currentClaudeConfig = name; + this.refreshClaudeModelContext(); + }, + + onClaudeModelChange() { + const name = this.currentClaudeConfig; + if (!name) { + this.showMessage('请先选择配置', 'error'); + return; + } + const model = (this.currentClaudeModel || '').trim(); + if (!model) { + this.showMessage('请输入模型', 'error'); + return; + } + const existing = this.claudeConfigs[name] || {}; + this.currentClaudeModel = model; + this.claudeConfigs[name] = this.mergeClaudeConfig(existing, { model }); + this.saveClaudeConfigs(); + this.updateClaudeModelsCurrent(); + if (!this.claudeConfigs[name].apiKey && !this.claudeConfigs[name].externalCredentialType) { + this.showMessage('请先配置 API Key', 'error'); + return; + } + this.applyClaudeConfig(name); + }, + + saveClaudeConfigs() { + localStorage.setItem('claudeConfigs', JSON.stringify(this.claudeConfigs)); + }, + + openEditConfigModal(name) { + const config = this.claudeConfigs[name]; + this.editingConfig = { + name: name, + apiKey: config.apiKey || '', + baseUrl: config.baseUrl || '', + model: config.model || '' + }; + this.showEditConfigModal = true; + }, + + updateConfig() { + const name = this.editingConfig.name; + this.claudeConfigs[name] = this.mergeClaudeConfig(this.claudeConfigs[name], this.editingConfig); + this.saveClaudeConfigs(); + this.showMessage('操作成功', 'success'); + this.closeEditConfigModal(); + if (name === this.currentClaudeConfig) { + this.refreshClaudeModelContext(); + } + }, + + closeEditConfigModal() { + this.showEditConfigModal = false; + this.editingConfig = { name: '', apiKey: '', baseUrl: '', model: '' }; + }, + + async saveAndApplyConfig() { + const name = this.editingConfig.name; + this.claudeConfigs[name] = this.mergeClaudeConfig(this.claudeConfigs[name], this.editingConfig); + this.saveClaudeConfigs(); + + const config = this.claudeConfigs[name]; + if (!config.apiKey) { + this.showMessage('已保存,未应用', 'info'); + this.closeEditConfigModal(); + if (name === this.currentClaudeConfig) { + this.refreshClaudeModelContext(); + } + return; + } + + try { + const res = await api('apply-claude-config', { config }); + if (res.error || res.success === false) { + this.showMessage(res.error || '应用配置失败', 'error'); + } else { + this.currentClaudeConfig = name; + const targetTip = res.targetPath ? `(${res.targetPath})` : ''; + this.showMessage(`已保存并应用到 Claude 配置${targetTip}`, 'success'); + this.closeEditConfigModal(); + this.refreshClaudeModelContext(); + } + } catch (_) { + this.showMessage('应用配置失败', 'error'); + } + }, + + addClaudeConfig() { + if (!this.newClaudeConfig.name || !this.newClaudeConfig.name.trim()) { + return this.showMessage('请输入名称', 'error'); + } + const name = this.newClaudeConfig.name.trim(); + if (this.claudeConfigs[name]) { + return this.showMessage('名称已存在', 'error'); + } + const duplicateName = this.findDuplicateClaudeConfigName(this.newClaudeConfig); + if (duplicateName) { + return this.showMessage('配置已存在', 'info'); + } + + this.claudeConfigs[name] = this.mergeClaudeConfig({}, this.newClaudeConfig); + + this.currentClaudeConfig = name; + this.saveClaudeConfigs(); + this.showMessage('操作成功', 'success'); + this.closeClaudeConfigModal(); + this.refreshClaudeModelContext(); + }, + + async deleteClaudeConfig(name) { + if (Object.keys(this.claudeConfigs).length <= 1) { + return this.showMessage('至少保留一项', 'error'); + } + const confirmed = await this.requestConfirmDialog({ + title: '删除 Claude 配置', + message: `确定删除配置 "${name}"?`, + confirmText: '删除', + cancelText: '取消', + danger: true + }); + if (!confirmed) return; + + delete this.claudeConfigs[name]; + if (this.currentClaudeConfig === name) { + this.currentClaudeConfig = Object.keys(this.claudeConfigs)[0]; + } + this.saveClaudeConfigs(); + this.showMessage('操作成功', 'success'); + this.refreshClaudeModelContext(); + }, + + async applyClaudeConfig(name) { + this.currentClaudeConfig = name; + this.refreshClaudeModelContext(); + const config = this.claudeConfigs[name]; + + if (!config.apiKey) { + if (config.externalCredentialType) { + return this.showMessage('检测到外部 Claude 认证状态;当前仅支持展示,若需由 codexmate 接管请补充 API Key', 'info'); + } + return this.showMessage('请先配置 API Key', 'error'); + } + + try { + const res = await api('apply-claude-config', { config }); + if (res.error || res.success === false) { + this.showMessage(res.error || '应用配置失败', 'error'); + } else { + const targetTip = res.targetPath ? `(${res.targetPath})` : ''; + this.showMessage(`已应用配置到 Claude 设置: ${name}${targetTip}`, 'success'); + } + } catch (_) { + this.showMessage('应用配置失败', 'error'); + } + }, + + closeClaudeConfigModal() { + this.showClaudeConfigModal = false; + this.newClaudeConfig = { + name: '', + apiKey: '', + baseUrl: 'https://open.bigmodel.cn/api/anthropic', + model: 'glm-4.7' + }; + } + }; +} diff --git a/web-ui/modules/app.methods.codex-config.mjs b/web-ui/modules/app.methods.codex-config.mjs new file mode 100644 index 0000000..f6fc83b --- /dev/null +++ b/web-ui/modules/app.methods.codex-config.mjs @@ -0,0 +1,517 @@ +import { runLatestOnlyQueue } from '../logic.mjs'; + +function hasResponseError(response) { + if (!response || typeof response !== 'object') { + return false; + } + if (typeof response.error === 'string') { + return response.error.trim().length > 0; + } + return response.error !== undefined && response.error !== null && response.error !== false; +} + +function getResponseMessage(response, fallback) { + if (!response || typeof response !== 'object') { + return fallback; + } + for (const key of ['error', 'message', 'detail']) { + const value = response[key]; + if (typeof value === 'string' && value.trim()) { + return value.trim(); + } + } + return fallback; +} + +export function createCodexConfigMethods(options = {}) { + const { + api, + defaultModelContextWindow = 190000, + defaultModelAutoCompactTokenLimit = 185000, + getProviderConfigModeMeta + } = options; + + return { + downloadTextFile(fileName, content, mimeType = 'text/markdown;charset=utf-8') { + const BOM = '\uFEFF'; + const blob = new Blob([BOM + content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = fileName; + link.click(); + URL.revokeObjectURL(url); + }, + + async exportSession(session) { + const key = this.getSessionExportKey(session); + if (this.sessionExporting[key]) return; + + this.sessionExporting[key] = true; + try { + const res = await api('export-session', { + source: session.source, + sessionId: session.sessionId, + filePath: session.filePath + }); + if (res.error) { + this.showMessage(res.error, 'error'); + return; + } + + const fileName = res.fileName || `${session.source || 'session'}-${session.sessionId || Date.now()}.md`; + this.downloadTextFile(fileName, res.content || ''); + if (res.truncated) { + const maxLabel = res.maxMessages === 'all' ? 'all' : res.maxMessages; + this.showMessage(`会话导出完成(已截断:最多 ${maxLabel} 条消息)`, 'info'); + } else { + this.showMessage('操作成功', 'success'); + } + } catch (e) { + this.showMessage('导出失败', 'error'); + } finally { + this.sessionExporting[key] = false; + } + }, + + async quickSwitchProvider(name) { + const target = String(name || '').trim(); + const visualTarget = String(this.providerSwitchDisplayTarget || '').trim(); + if (!target || target === visualTarget || target === this.pendingProviderSwitch) { + return; + } + if (!this.providerSwitchInProgress && target === this.currentProvider) { + return; + } + await this.switchProvider(target); + }, + + async waitForCodexApplyIdle(maxWaitMs = 20000) { + const startedAt = Date.now(); + while (this.codexApplying) { + if ((Date.now() - startedAt) > maxWaitMs) { + throw new Error('等待配置应用完成超时'); + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + }, + + async performProviderSwitch(name) { + await this.waitForCodexApplyIdle(); + const previousProvider = this.currentProvider; + const previousModel = this.currentModel; + const previousModels = Array.isArray(this.models) ? [...this.models] : []; + const previousModelsSource = this.modelsSource; + const previousModelsHasCurrent = this.modelsHasCurrent; + this.currentProvider = name; + await this.loadModelsForProvider(name); + if (this.modelsSource === 'error') { + this.currentProvider = previousProvider; + this.currentModel = previousModel; + this.models = previousModels; + this.modelsSource = previousModelsSource; + this.modelsHasCurrent = previousModelsHasCurrent; + return; + } + if (this.modelsSource === 'remote' && this.models.length > 0 && !this.models.includes(this.currentModel)) { + this.currentModel = this.models[0]; + this.modelsHasCurrent = true; + } + if (getProviderConfigModeMeta(this.configMode)) { + await this.waitForCodexApplyIdle(); + await this.applyCodexConfigDirect({ silent: true }); + } + }, + + async switchProvider(name) { + const target = String(name || '').trim(); + if (!target) { + return; + } + if (target === String(this.providerSwitchDisplayTarget || '').trim()) { + return; + } + this.providerSwitchDisplayTarget = target; + if (this.providerSwitchInProgress) { + this.pendingProviderSwitch = target; + return; + } + this.providerSwitchInProgress = true; + let lastError = ''; + try { + this.pendingProviderSwitch = ''; + const result = await runLatestOnlyQueue(target, { + perform: async (queuedTarget) => { + this.providerSwitchDisplayTarget = queuedTarget; + await this.performProviderSwitch(queuedTarget); + }, + consumePending: () => { + const queued = this.pendingProviderSwitch; + this.pendingProviderSwitch = ''; + return queued; + } + }); + if (result && typeof result.lastError === 'string') { + lastError = result.lastError; + } + } finally { + this.providerSwitchInProgress = false; + this.pendingProviderSwitch = ''; + this.providerSwitchDisplayTarget = ''; + } + if (lastError) { + this.showMessage(lastError, 'error'); + } + }, + + async onModelChange() { + await this.applyCodexConfigDirect(); + }, + + async onServiceTierChange() { + await this.applyCodexConfigDirect({ silent: true }); + }, + + async onReasoningEffortChange() { + await this.applyCodexConfigDirect({ silent: true }); + }, + + sanitizePositiveIntegerDraft(field) { + if (!field || typeof this[field] === 'undefined') return; + const current = typeof this[field] === 'string' + ? this[field] + : String(this[field] || ''); + const sanitized = current.replace(/[^\d]/g, ''); + if (sanitized !== current) { + this[field] = sanitized; + } + }, + + normalizePositiveIntegerInput(value, label, fallback = '') { + const fallbackText = fallback === '' ? '' : String(fallback).trim(); + const raw = typeof value === 'string' + ? value.trim() + : String(value ?? '').trim(); + const text = raw || fallbackText; + if (!text) { + return { ok: true, value: null, text: '' }; + } + if (!/^\d+$/.test(text)) { + return { ok: false, error: `${label} 请输入正整数` }; + } + const num = Number.parseInt(text, 10); + if (!Number.isSafeInteger(num) || num <= 0) { + return { ok: false, error: `${label} 请输入正整数` }; + } + return { ok: true, value: num, text: String(num) }; + }, + + async onModelContextWindowBlur() { + this.editingCodexBudgetField = ''; + const normalized = this.normalizePositiveIntegerInput( + this.modelContextWindowInput, + 'model_context_window', + defaultModelContextWindow + ); + if (!normalized.ok) { + this.showMessage(normalized.error, 'error'); + return; + } + this.modelContextWindowInput = normalized.text; + await this.applyCodexConfigDirect({ + silent: true, + modelContextWindow: normalized.value + }); + }, + + async onModelAutoCompactTokenLimitBlur() { + this.editingCodexBudgetField = ''; + const normalized = this.normalizePositiveIntegerInput( + this.modelAutoCompactTokenLimitInput, + 'model_auto_compact_token_limit', + defaultModelAutoCompactTokenLimit + ); + if (!normalized.ok) { + this.showMessage(normalized.error, 'error'); + return; + } + this.modelAutoCompactTokenLimitInput = normalized.text; + await this.applyCodexConfigDirect({ + silent: true, + modelAutoCompactTokenLimit: normalized.value + }); + }, + + async resetCodexContextBudgetDefaults() { + this.modelContextWindowInput = String(defaultModelContextWindow); + this.modelAutoCompactTokenLimitInput = String(defaultModelAutoCompactTokenLimit); + await this.applyCodexConfigDirect({ + modelContextWindow: defaultModelContextWindow, + modelAutoCompactTokenLimit: defaultModelAutoCompactTokenLimit + }); + }, + + async runHealthCheck() { + this.healthCheckLoading = true; + this.healthCheckResult = null; + let shouldRunClaudeSpeedTests = false; + try { + const res = await api('config-health-check', { + remote: false + }); + if (hasResponseError(res)) { + this.healthCheckResult = null; + this.showMessage(getResponseMessage(res, '检查失败'), 'error'); + } else if (res && typeof res === 'object') { + shouldRunClaudeSpeedTests = true; + const issues = Array.isArray(res.issues) ? [...res.issues] : []; + let remote = res.remote || null; + { + const providers = (this.providersList || []) + .map((provider) => typeof provider === 'string' + ? provider.trim() + : String((provider && provider.name) || '').trim()) + .filter(Boolean); + const tasks = providers.map(provider => + this.runSpeedTest(provider, { silent: true }) + .then(result => ({ name: provider, result })) + .catch(err => ({ + name: provider, + result: { ok: false, error: err && err.message ? err.message : 'Speed test failed' } + })) + ); + const pairs = await Promise.all(tasks); + const results = {}; + for (const pair of pairs) { + results[pair.name] = pair.result || null; + const issue = this.buildSpeedTestIssue(pair.name, pair.result); + if (issue) issues.push(issue); + } + remote = { + type: 'speed-test', + results + }; + } + + const ok = issues.length === 0; + this.healthCheckResult = { + ...res, + ok, + issues, + remote + }; + if (ok) { + this.showMessage('检查通过', 'success'); + } + } else { + this.healthCheckResult = null; + this.showMessage('检查失败', 'error'); + } + } catch (e) { + this.healthCheckResult = null; + this.showMessage('检查失败', 'error'); + } finally { + if (shouldRunClaudeSpeedTests && this.configMode === 'claude') { + try { + const entries = Object.entries(this.claudeConfigs || {}); + await Promise.all(entries.map(([name, config]) => this.runClaudeSpeedTest(name, config))); + } catch (e) {} + } + this.healthCheckLoading = false; + } + }, + + escapeTomlString(value) { + return String(value || '') + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"'); + }, + + async openConfigTemplateEditor(options = {}) { + const modelContextWindow = this.normalizePositiveIntegerInput( + this.modelContextWindowInput, + 'model_context_window', + defaultModelContextWindow + ); + if (!modelContextWindow.ok) { + this.showMessage(modelContextWindow.error, 'error'); + return; + } + const modelAutoCompactTokenLimit = this.normalizePositiveIntegerInput( + this.modelAutoCompactTokenLimitInput, + 'model_auto_compact_token_limit', + defaultModelAutoCompactTokenLimit + ); + if (!modelAutoCompactTokenLimit.ok) { + this.showMessage(modelAutoCompactTokenLimit.error, 'error'); + return; + } + try { + const res = await api('get-config-template', { + provider: this.currentProvider, + model: this.currentModel, + serviceTier: this.serviceTier, + reasoningEffort: this.modelReasoningEffort, + modelContextWindow: modelContextWindow.value, + modelAutoCompactTokenLimit: modelAutoCompactTokenLimit.value + }); + if (res.error) { + this.showMessage(res.error, 'error'); + return; + } + let template = res.template || ''; + const appendHint = typeof options.appendHint === 'string' ? options.appendHint.trim() : ''; + const appendBlock = typeof options.appendBlock === 'string' ? options.appendBlock.trim() : ''; + if (appendHint) { + template = `${template.trimEnd()}\n\n# -------------------------------\n# ${appendHint}\n# -------------------------------\n`; + } + if (appendBlock) { + template = `${template.trimEnd()}\n\n${appendBlock}\n`; + } + this.configTemplateContent = template; + this.showConfigTemplateModal = true; + } catch (e) { + this.showMessage('加载模板失败', 'error'); + } + }, + + async applyCodexConfigDirect(options = {}) { + if (this.codexApplying) { + this._pendingCodexApplyOptions = { + ...(this._pendingCodexApplyOptions || {}), + ...options + }; + return; + } + + const provider = (this.currentProvider || '').trim(); + const model = (this.currentModel || '').trim(); + if (!provider || !model) { + this.showMessage('请选择提供商和模型', 'error'); + return; + } + + const modelContextWindow = this.normalizePositiveIntegerInput( + options.modelContextWindow !== undefined ? options.modelContextWindow : this.modelContextWindowInput, + 'model_context_window', + defaultModelContextWindow + ); + if (!modelContextWindow.ok) { + this.showMessage(modelContextWindow.error, 'error'); + return; + } + const modelAutoCompactTokenLimit = this.normalizePositiveIntegerInput( + options.modelAutoCompactTokenLimit !== undefined + ? options.modelAutoCompactTokenLimit + : this.modelAutoCompactTokenLimitInput, + 'model_auto_compact_token_limit', + defaultModelAutoCompactTokenLimit + ); + if (!modelAutoCompactTokenLimit.ok) { + this.showMessage(modelAutoCompactTokenLimit.error, 'error'); + return; + } + this.modelContextWindowInput = modelContextWindow.text; + this.modelAutoCompactTokenLimitInput = modelAutoCompactTokenLimit.text; + + this.codexApplying = true; + try { + const tplRes = await api('get-config-template', { + provider, + model, + serviceTier: this.serviceTier, + reasoningEffort: this.modelReasoningEffort, + modelContextWindow: modelContextWindow.value, + modelAutoCompactTokenLimit: modelAutoCompactTokenLimit.value + }); + if (tplRes.error) { + this.showMessage( + (typeof tplRes.error === 'string' && tplRes.error.trim()) + || (typeof tplRes.message === 'string' && tplRes.message.trim()) + || (typeof tplRes.detail === 'string' && tplRes.detail.trim()) + || '获取模板失败', + 'error' + ); + return; + } + + const applyRes = await api('apply-config-template', { + template: tplRes.template + }); + if (applyRes.error) { + this.showMessage( + (typeof applyRes.error === 'string' && applyRes.error.trim()) + || (typeof applyRes.message === 'string' && applyRes.message.trim()) + || (typeof applyRes.detail === 'string' && applyRes.detail.trim()) + || '应用模板失败', + 'error' + ); + return; + } + + if (options.silent !== true) { + this.showMessage('配置已应用', 'success'); + } + + const refreshOptions = options.silent === true + ? { preserveLoading: true } + : {}; + try { + await this.loadAll(refreshOptions); + } catch (_) { + this.showMessage('配置已应用,但界面刷新失败,请手动刷新', 'error'); + } + } catch (e) { + this.showMessage('应用失败', 'error'); + } finally { + this.codexApplying = false; + const pendingOptions = this._pendingCodexApplyOptions; + this._pendingCodexApplyOptions = null; + if (pendingOptions) { + await this.applyCodexConfigDirect(pendingOptions); + } + } + }, + + closeConfigTemplateModal(options = {}) { + const force = !!options.force; + if (!force && this.configTemplateApplying) { + return; + } + this.showConfigTemplateModal = false; + this.configTemplateContent = ''; + }, + + async applyConfigTemplate() { + if (this.configTemplateApplying) { + return; + } + if (!this.configTemplateContent || !this.configTemplateContent.trim()) { + this.showMessage('模板不能为空', 'error'); + return; + } + + this.configTemplateApplying = true; + try { + const res = await api('apply-config-template', { + template: this.configTemplateContent + }); + if (res.error) { + this.showMessage(res.error, 'error'); + return; + } + this.showMessage('模板已应用', 'success'); + this.closeConfigTemplateModal({ force: true }); + try { + await this.loadAll(); + } catch (_) { + this.showMessage('模板已应用,但界面刷新失败,请手动刷新', 'error'); + } + } catch (e) { + this.showMessage('应用模板失败', 'error'); + } finally { + this.configTemplateApplying = false; + } + } + }; +} diff --git a/web-ui/modules/app.methods.index.mjs b/web-ui/modules/app.methods.index.mjs new file mode 100644 index 0000000..c8e2555 --- /dev/null +++ b/web-ui/modules/app.methods.index.mjs @@ -0,0 +1,86 @@ +import { + API_BASE, + api, + apiWithMeta +} from './api.mjs'; +import { + DEFAULT_MODEL_AUTO_COMPACT_TOKEN_LIMIT, + DEFAULT_MODEL_CONTEXT_WINDOW, + DEFAULT_OPENCLAW_TEMPLATE, + SESSION_TRASH_LIST_LIMIT, + SESSION_TRASH_PAGE_SIZE +} from './app.constants.mjs'; +import { createAgentsMethods } from './app.methods.agents.mjs'; +import { createClaudeConfigMethods } from './app.methods.claude-config.mjs'; +import { createCodexConfigMethods } from './app.methods.codex-config.mjs'; +import { createInstallMethods } from './app.methods.install.mjs'; +import { createNavigationMethods } from './app.methods.navigation.mjs'; +import { createOpenclawCoreMethods } from './app.methods.openclaw-core.mjs'; +import { createOpenclawEditingMethods } from './app.methods.openclaw-editing.mjs'; +import { createOpenclawPersistMethods } from './app.methods.openclaw-persist.mjs'; +import { createProvidersMethods } from './app.methods.providers.mjs'; +import { createRuntimeMethods } from './app.methods.runtime.mjs'; +import { createSessionActionMethods } from './app.methods.session-actions.mjs'; +import { createSessionBrowserMethods } from './app.methods.session-browser.mjs'; +import { createSessionTimelineMethods } from './app.methods.session-timeline.mjs'; +import { createSessionTrashMethods } from './app.methods.session-trash.mjs'; +import { createStartupClaudeMethods } from './app.methods.startup-claude.mjs'; +import { createSkillsMethods } from './skills.methods.mjs'; +import { + CONFIG_MODE_SET, + getProviderConfigModeMeta +} from './config-mode.computed.mjs'; +import { + loadActiveSessionDetail as loadActiveSessionDetailHelper, + loadMoreSessionMessages as loadMoreSessionMessagesHelper, + loadSessions as loadSessionsHelper, + switchMainTab as switchMainTabHelper +} from '../session-helpers.mjs'; + +export function createAppMethods() { + return { + ...createStartupClaudeMethods({ + api, + defaultModelContextWindow: DEFAULT_MODEL_CONTEXT_WINDOW, + defaultModelAutoCompactTokenLimit: DEFAULT_MODEL_AUTO_COMPACT_TOKEN_LIMIT + }), + ...createNavigationMethods({ + configModeSet: CONFIG_MODE_SET, + switchMainTabHelper, + loadMoreSessionMessagesHelper + }), + ...createSessionActionMethods({ + api, + apiBase: API_BASE + }), + ...createSessionTrashMethods({ + api, + sessionTrashListLimit: SESSION_TRASH_LIST_LIMIT, + sessionTrashPageSize: SESSION_TRASH_PAGE_SIZE + }), + ...createSessionBrowserMethods({ + api, + loadSessionsHelper, + loadActiveSessionDetailHelper + }), + ...createSessionTimelineMethods(), + ...createCodexConfigMethods({ + api, + defaultModelContextWindow: DEFAULT_MODEL_CONTEXT_WINDOW, + defaultModelAutoCompactTokenLimit: DEFAULT_MODEL_AUTO_COMPACT_TOKEN_LIMIT, + getProviderConfigModeMeta + }), + ...createSkillsMethods({ api }), + ...createAgentsMethods({ api, apiWithMeta }), + ...createProvidersMethods({ api }), + ...createClaudeConfigMethods({ api }), + ...createOpenclawCoreMethods(), + ...createOpenclawEditingMethods(), + ...createOpenclawPersistMethods({ + api, + defaultOpenclawTemplate: DEFAULT_OPENCLAW_TEMPLATE + }), + ...createInstallMethods(), + ...createRuntimeMethods({ api }) + }; +} diff --git a/web-ui/modules/app.methods.install.mjs b/web-ui/modules/app.methods.install.mjs new file mode 100644 index 0000000..e02d3f2 --- /dev/null +++ b/web-ui/modules/app.methods.install.mjs @@ -0,0 +1,157 @@ +export function createInstallMethods() { + return { + normalizeInstallPackageManager(value) { + const normalized = typeof value === 'string' ? value.trim().toLowerCase() : ''; + if (normalized === 'pnpm' || normalized === 'bun' || normalized === 'npm') { + return normalized; + } + return 'npm'; + }, + + normalizeInstallAction(value) { + const normalized = typeof value === 'string' ? value.trim().toLowerCase() : ''; + if (normalized === 'update' || normalized === 'uninstall' || normalized === 'install') { + return normalized; + } + return 'install'; + }, + + normalizeInstallRegistryPreset(value) { + const normalized = typeof value === 'string' ? value.trim().toLowerCase() : ''; + if (normalized === 'default' || normalized === 'npmmirror' || normalized === 'tencent' || normalized === 'custom') { + return normalized; + } + return 'default'; + }, + + normalizeInstallRegistryUrl(value) { + const normalized = typeof value === 'string' ? value.trim() : ''; + if (!normalized) return ''; + if (!/^https?:\/\//i.test(normalized)) { + return ''; + } + const afterScheme = normalized.replace(/^https?:\/\//i, ''); + if (!afterScheme || /^[/?#]/.test(afterScheme)) { + return ''; + } + const trimmed = normalized.replace(/\/+$/, ''); + try { + const parsed = new URL(trimmed); + if (!/^https?:$/i.test(parsed.protocol) || !parsed.hostname) { + return ''; + } + } catch { + return ''; + } + return trimmed; + }, + + resolveInstallRegistryUrl(presetValue, customValue) { + const preset = this.normalizeInstallRegistryPreset(presetValue); + if (preset === 'npmmirror') { + return 'https://registry.npmmirror.com'; + } + if (preset === 'tencent') { + return 'https://mirrors.cloud.tencent.com/npm'; + } + if (preset === 'custom') { + return this.normalizeInstallRegistryUrl(customValue); + } + return ''; + }, + + appendInstallRegistryOption(command, actionName) { + const base = typeof command === 'string' ? command.trim() : ''; + if (!base) return ''; + const action = this.normalizeInstallAction(actionName); + if (action === 'uninstall') { + return base; + } + const registry = this.resolveInstallRegistryUrl(this.installRegistryPreset, this.installRegistryCustom); + if (!registry) { + return base; + } + const quoteArg = typeof this.quoteShellArg === 'function' + ? this.quoteShellArg(registry) + : `'${registry.replace(/'/g, `'\\''`)}'`; + return `${base} --registry=${quoteArg}`; + }, + + resolveInstallPlatform() { + const navPlatform = typeof navigator !== 'undefined' && typeof navigator.platform === 'string' + ? navigator.platform.trim().toLowerCase() + : ''; + if (navPlatform.includes('win')) return 'win32'; + if (navPlatform.includes('mac')) return 'darwin'; + return 'linux'; + }, + + buildInstallCommandMatrix(packageManager) { + const manager = this.normalizeInstallPackageManager(packageManager); + const matrix = { + claude: { + install: '', + update: '', + uninstall: '' + }, + codex: { + install: '', + update: '', + uninstall: '' + } + }; + if (manager === 'pnpm') { + matrix.claude.install = 'pnpm add -g @anthropic-ai/claude-code'; + matrix.claude.update = 'pnpm up -g @anthropic-ai/claude-code'; + matrix.claude.uninstall = 'pnpm remove -g @anthropic-ai/claude-code'; + matrix.codex.install = 'pnpm add -g @openai/codex'; + matrix.codex.update = 'pnpm up -g @openai/codex'; + matrix.codex.uninstall = 'pnpm remove -g @openai/codex'; + return matrix; + } + if (manager === 'bun') { + matrix.claude.install = 'bun add -g @anthropic-ai/claude-code'; + matrix.claude.update = 'bun update -g @anthropic-ai/claude-code'; + matrix.claude.uninstall = 'bun remove -g @anthropic-ai/claude-code'; + matrix.codex.install = 'bun add -g @openai/codex'; + matrix.codex.update = 'bun update -g @openai/codex'; + matrix.codex.uninstall = 'bun remove -g @openai/codex'; + return matrix; + } + matrix.claude.install = 'npm install -g @anthropic-ai/claude-code'; + matrix.claude.update = 'npm update -g @anthropic-ai/claude-code'; + matrix.claude.uninstall = 'npm uninstall -g @anthropic-ai/claude-code'; + matrix.codex.install = 'npm install -g @openai/codex'; + matrix.codex.update = 'npm update -g @openai/codex'; + matrix.codex.uninstall = 'npm uninstall -g @openai/codex'; + return matrix; + }, + + getInstallCommand(targetId, actionName) { + const targetKey = typeof targetId === 'string' ? targetId.trim() : ''; + if (!targetKey) return ''; + const action = this.normalizeInstallAction(actionName); + const currentMap = this.buildInstallCommandMatrix(this.installPackageManager); + const current = currentMap[targetKey] && typeof currentMap[targetKey][action] === 'string' + ? currentMap[targetKey][action] + : ''; + return this.appendInstallRegistryOption(current, action); + }, + + setInstallCommandAction(actionName) { + this.installCommandAction = this.normalizeInstallAction(actionName); + }, + + setInstallRegistryPreset(presetName) { + this.installRegistryPreset = this.normalizeInstallRegistryPreset(presetName); + }, + + openInstallModal() { + this.showInstallModal = true; + }, + + closeInstallModal() { + this.showInstallModal = false; + } + }; +} diff --git a/web-ui/modules/app.methods.navigation.mjs b/web-ui/modules/app.methods.navigation.mjs new file mode 100644 index 0000000..95d6650 --- /dev/null +++ b/web-ui/modules/app.methods.navigation.mjs @@ -0,0 +1,478 @@ +export function createNavigationMethods(options = {}) { + const { + configModeSet, + switchMainTabHelper, + loadMoreSessionMessagesHelper + } = options; + + return { + switchConfigMode(mode) { + const normalizedMode = typeof mode === 'string' + ? mode.trim().toLowerCase() + : ''; + this.cancelTouchNavIntentReset(); + if (typeof this.ensureMainTabSwitchState === 'function') { + this.ensureMainTabSwitchState().pendingConfigMode = ''; + } + this.configMode = configModeSet.has(normalizedMode) ? normalizedMode : 'codex'; + if (this.mainTab === 'config') { + if (this.configMode === 'claude') { + const expectedMainTab = 'config'; + const expectedConfigMode = 'claude'; + const refresh = () => { + if (this.mainTab !== expectedMainTab || this.configMode !== expectedConfigMode) { + return; + } + this.refreshClaudeModelContext(); + }; + if (typeof this.scheduleAfterFrame === 'function') { + this.scheduleAfterFrame(refresh); + } else { + refresh(); + } + } + this.scheduleAfterFrame(() => { + this.clearMainTabSwitchIntent('config'); + }); + return; + } + this.switchMainTab('config'); + }, + + ensureMainTabSwitchState() { + if (this.__mainTabSwitchState) { + return this.__mainTabSwitchState; + } + this.__mainTabSwitchState = { + intent: '', + pendingTarget: '', + pendingConfigMode: '', + ticket: 0 + }; + return this.__mainTabSwitchState; + }, + ensureImmediateNavDomState() { + if (typeof document === 'undefined') { + return { + navNodes: [], + sessionPanelEl: null + }; + } + if (!this.__immediateNavDomState) { + this.__immediateNavDomState = { + navNodes: [], + sessionPanelEl: null + }; + } + const state = this.__immediateNavDomState; + const needsNavRefresh = !Array.isArray(state.navNodes) + || !state.navNodes.length + || state.navNodes.some((node) => !node || !node.isConnected); + if (needsNavRefresh) { + state.navNodes = Array.from(document.querySelectorAll('[data-main-tab]')); + } + if (!state.sessionPanelEl || !state.sessionPanelEl.isConnected) { + state.sessionPanelEl = document.getElementById('panel-sessions'); + } + return state; + }, + setMainTabSwitchIntent(tab) { + const normalizedTab = typeof tab === 'string' + ? tab.trim().toLowerCase() + : ''; + if (!normalizedTab) return; + const state = this.ensureMainTabSwitchState(); + state.intent = normalizedTab; + }, + cancelTouchNavIntentReset() { + if (this.__touchNavIntentResetTimer) { + clearTimeout(this.__touchNavIntentResetTimer); + this.__touchNavIntentResetTimer = null; + } + this.__touchNavIntentResetToken = 0; + }, + scheduleTouchNavIntentReset(kind, value) { + const normalizedKind = typeof kind === 'string' ? kind.trim().toLowerCase() : ''; + const normalizedValue = typeof value === 'string' ? value.trim().toLowerCase() : ''; + if (!normalizedKind || !normalizedValue) { + return; + } + const expectedIntent = normalizedKind === 'config' ? 'config' : normalizedValue; + this.cancelTouchNavIntentReset(); + const token = (Number(this.__touchNavIntentResetToken) || 0) + 1; + this.__touchNavIntentResetToken = token; + this.__touchNavIntentResetTimer = setTimeout(() => { + if (this.__touchNavIntentResetToken !== token) { + return; + } + this.__touchNavIntentResetTimer = null; + this.__touchNavIntentResetToken = 0; + const liveIntent = String(this.ensureMainTabSwitchState().intent || '').trim().toLowerCase(); + if (liveIntent !== expectedIntent) { + return; + } + this.clearMainTabSwitchIntent(expectedIntent); + }, 1000); + }, + applyImmediateNavIntent(tab, configMode = '') { + if (typeof document === 'undefined') return; + const normalizedTab = typeof tab === 'string' ? tab.trim().toLowerCase() : ''; + if (!normalizedTab) return; + const normalizedMode = typeof configMode === 'string' ? configMode.trim().toLowerCase() : ''; + const domState = this.ensureImmediateNavDomState(); + const nodes = Array.isArray(domState.navNodes) ? domState.navNodes : []; + for (const node of nodes) { + if (!node || !node.classList) continue; + const nodeTab = String(node.getAttribute('data-main-tab') || '').trim().toLowerCase(); + const nodeMode = String(node.getAttribute('data-config-mode') || '').trim().toLowerCase(); + let shouldActivate = nodeTab === normalizedTab; + if (shouldActivate && normalizedTab === 'config') { + shouldActivate = nodeMode ? nodeMode === normalizedMode : false; + } + node.classList.toggle('nav-intent-active', !!shouldActivate); + node.classList.toggle('nav-intent-inactive', !shouldActivate); + } + }, + clearImmediateNavIntent() { + if (typeof document === 'undefined') return; + const domState = this.ensureImmediateNavDomState(); + const nodes = Array.isArray(domState.navNodes) ? domState.navNodes : []; + for (const node of nodes) { + if (!node || !node.classList) continue; + node.classList.remove('nav-intent-active'); + node.classList.remove('nav-intent-inactive'); + } + }, + setSessionPanelFastHidden(hidden) { + if (typeof document === 'undefined') return; + const domState = this.ensureImmediateNavDomState(); + const panel = domState.sessionPanelEl; + if (!panel || !panel.classList) return; + panel.classList.toggle('session-panel-fast-hidden', !!hidden); + }, + isSessionPanelFastHidden() { + if (typeof document === 'undefined') return false; + const domState = this.ensureImmediateNavDomState(); + const panel = domState.sessionPanelEl; + return !!(panel && panel.classList && panel.classList.contains('session-panel-fast-hidden')); + }, + recordPointerNavCommit(kind, value) { + const normalizedKind = typeof kind === 'string' ? kind.trim().toLowerCase() : ''; + const normalizedValue = typeof value === 'string' ? value.trim().toLowerCase() : ''; + if (!normalizedKind || !normalizedValue) { + this.__pointerNavCommitState = null; + return; + } + this.__pointerNavCommitState = { + kind: normalizedKind, + value: normalizedValue, + at: Date.now() + }; + }, + consumePointerNavCommit(kind, value) { + const normalizedKind = typeof kind === 'string' ? kind.trim().toLowerCase() : ''; + const normalizedValue = typeof value === 'string' ? value.trim().toLowerCase() : ''; + const state = this.__pointerNavCommitState; + this.__pointerNavCommitState = null; + if (!state || !normalizedKind || !normalizedValue) { + return false; + } + if (state.kind !== normalizedKind || state.value !== normalizedValue) { + return false; + } + return (Date.now() - Number(state.at || 0)) <= 1000; + }, + onMainTabPointerDown(tab) { + const event = arguments.length > 1 ? arguments[1] : null; + if (event && typeof event.button === 'number' && event.button !== 0) { + return; + } + const normalizedTab = typeof tab === 'string' ? tab.trim().toLowerCase() : ''; + if (!normalizedTab) return; + this.setMainTabSwitchIntent(normalizedTab); + this.applyImmediateNavIntent(normalizedTab); + const shouldHideSessionPanel = this.mainTab === 'sessions' && normalizedTab !== 'sessions'; + this.setSessionPanelFastHidden(shouldHideSessionPanel); + const pointerType = event && typeof event.pointerType === 'string' + ? event.pointerType.trim().toLowerCase() + : ''; + if (pointerType === 'touch') { + this.scheduleTouchNavIntentReset('main', normalizedTab); + return; + } + this.recordPointerNavCommit('main', normalizedTab); + this.switchMainTab(normalizedTab); + }, + onConfigTabPointerDown(mode) { + const event = arguments.length > 1 ? arguments[1] : null; + if (event && typeof event.button === 'number' && event.button !== 0) { + return; + } + const normalizedMode = typeof mode === 'string' ? mode.trim().toLowerCase() : ''; + if (!normalizedMode) return; + this.setMainTabSwitchIntent('config'); + if (typeof this.ensureMainTabSwitchState === 'function') { + this.ensureMainTabSwitchState().pendingConfigMode = normalizedMode; + } + this.applyImmediateNavIntent('config', normalizedMode); + const shouldHideSessionPanel = this.mainTab === 'sessions'; + this.setSessionPanelFastHidden(shouldHideSessionPanel); + const pointerType = event && typeof event.pointerType === 'string' + ? event.pointerType.trim().toLowerCase() + : ''; + if (pointerType === 'touch') { + this.scheduleTouchNavIntentReset('config', normalizedMode); + return; + } + this.recordPointerNavCommit('config', normalizedMode); + this.switchConfigMode(normalizedMode); + }, + onMainTabClick(tab) { + const normalizedTab = typeof tab === 'string' ? tab.trim().toLowerCase() : ''; + if (!normalizedTab) return; + if (this.consumePointerNavCommit('main', normalizedTab)) return; + this.switchMainTab(normalizedTab); + }, + onConfigTabClick(mode) { + const normalizedMode = typeof mode === 'string' ? mode.trim().toLowerCase() : ''; + if (!normalizedMode) return; + if (this.consumePointerNavCommit('config', normalizedMode)) return; + this.switchConfigMode(normalizedMode); + }, + clearMainTabSwitchIntent(expectedTab = '') { + const state = this.ensureMainTabSwitchState(); + if (expectedTab && state.intent && state.intent !== expectedTab) { + return; + } + this.cancelTouchNavIntentReset(); + state.intent = ''; + state.pendingTarget = ''; + state.pendingConfigMode = ''; + this.clearImmediateNavIntent(); + this.setSessionPanelFastHidden(false); + }, + getMainTabForNav() { + const state = this.ensureMainTabSwitchState(); + return state.intent || this.mainTab; + }, + isMainTabNavActive(tab) { + return this.getMainTabForNav() === tab; + }, + isConfigModeNavActive(mode) { + if (!this.isMainTabNavActive('config')) { + return false; + } + const state = this.ensureMainTabSwitchState(); + const pendingMode = typeof state.pendingConfigMode === 'string' + ? state.pendingConfigMode.trim().toLowerCase() + : ''; + if (state.intent === 'config' && pendingMode) { + return pendingMode === mode; + } + return this.configMode === mode; + }, + switchMainTab(tab) { + const normalizedTab = typeof tab === 'string' + ? tab.trim().toLowerCase() + : ''; + const targetTab = normalizedTab || tab; + if (!targetTab) return; + this.cancelTouchNavIntentReset(); + if (targetTab === 'sessions') { + this.cancelScheduledSessionTabDeferredTeardown(); + } + + this.setMainTabSwitchIntent(targetTab); + if (targetTab === 'config') { + this.applyImmediateNavIntent('config', this.configMode); + } else { + this.applyImmediateNavIntent(targetTab); + } + + const previousTab = this.mainTab; + const switchState = this.ensureMainTabSwitchState(); + if (targetTab !== 'config') { + switchState.pendingConfigMode = ''; + } + if (targetTab === previousTab) { + switchState.ticket += 1; + switchState.pendingTarget = ''; + this.scheduleAfterFrame(() => { + this.clearMainTabSwitchIntent(normalizedTab); + }); + return; + } + const isLeavingSessions = previousTab === 'sessions' && targetTab !== 'sessions'; + const shouldDeferApply = isLeavingSessions; + if (isLeavingSessions && !this.isSessionPanelFastHidden()) { + this.setSessionPanelFastHidden(true); + } + if (!shouldDeferApply) { + switchState.ticket += 1; + switchState.pendingTarget = ''; + const result = switchMainTabHelper.call(this, targetTab); + this.scheduleAfterFrame(() => { + this.clearMainTabSwitchIntent(normalizedTab); + }); + return result; + } + + const ticket = ++switchState.ticket; + switchState.pendingTarget = targetTab; + this.scheduleAfterFrame(() => { + const liveState = this.ensureMainTabSwitchState(); + if (ticket !== liveState.ticket) return; + const pendingTarget = liveState.pendingTarget || targetTab; + liveState.pendingTarget = ''; + switchMainTabHelper.call(this, pendingTarget); + this.clearMainTabSwitchIntent(normalizedTab); + }); + }, + + scheduleAfterFrame(task) { + const callback = typeof task === 'function' ? task : () => {}; + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(callback); + return; + } + setTimeout(callback, 16); + }, + scheduleIdleTask(task, timeoutMs = 160) { + const callback = typeof task === 'function' ? task : () => {}; + const timeout = Number.isFinite(timeoutMs) + ? Math.max(16, Math.floor(timeoutMs)) + : 160; + if (typeof requestIdleCallback === 'function') { + const id = requestIdleCallback(callback, { timeout }); + return { + type: 'idle', + id + }; + } + const id = setTimeout(callback, timeout); + return { + type: 'timeout', + id + }; + }, + cancelIdleTask(handle) { + if (!handle || typeof handle !== 'object') return; + const type = handle.type; + const id = handle.id; + if (type === 'idle') { + if (typeof cancelIdleCallback === 'function') { + cancelIdleCallback(id); + } else { + clearTimeout(id); + } + return; + } + if (type === 'timeout') { + clearTimeout(id); + } + }, + scheduleSessionTabDeferredTeardown(task) { + const callback = typeof task === 'function' ? task : () => {}; + this.cancelScheduledSessionTabDeferredTeardown(); + this.__sessionTabDeferredTeardownHandle = this.scheduleIdleTask(() => { + this.__sessionTabDeferredTeardownHandle = null; + callback(); + }, 180); + }, + cancelScheduledSessionTabDeferredTeardown() { + const handle = this.__sessionTabDeferredTeardownHandle || null; + if (!handle) return; + this.cancelIdleTask(handle); + this.__sessionTabDeferredTeardownHandle = null; + }, + + resetSessionPreviewMessageRender() { + this.sessionPreviewVisibleCount = 0; + this.invalidateSessionTimelineMeasurementCache(); + }, + + resetSessionDetailPagination() { + const initialLimit = Number.isFinite(this.sessionDetailInitialMessageLimit) + ? Math.max(1, Math.floor(this.sessionDetailInitialMessageLimit)) + : 80; + this.sessionDetailMessageLimit = initialLimit; + this.sessionPreviewPendingVisibleCount = 0; + }, + + primeSessionPreviewMessageRender() { + this.sessionPreviewVisibleCount = 0; + this.invalidateSessionTimelineMeasurementCache(); + if (this.mainTab !== 'sessions' || !this.sessionPreviewRenderEnabled) { + return; + } + const total = Array.isArray(this.activeSessionMessages) + ? this.activeSessionMessages.length + : 0; + if (total <= 0) return; + const baseSize = Number.isFinite(this.sessionPreviewInitialBatchSize) + ? Math.max(1, Math.floor(this.sessionPreviewInitialBatchSize)) + : 40; + this.sessionPreviewVisibleCount = Math.min(baseSize, total); + this.invalidateSessionTimelineMeasurementCache(); + }, + + async loadMoreSessionMessages(stepSize) { + return loadMoreSessionMessagesHelper.call(this, stepSize); + }, + + suspendSessionTabRender() { + this.sessionTabRenderTicket += 1; + this.sessionListRenderEnabled = false; + this.sessionPreviewRenderEnabled = false; + this.cancelSessionTimelineSync(); + this.sessionTimelineActiveKey = ''; + this.sessionTimelineLastSyncAt = 0; + this.sessionTimelineLastScrollTop = 0; + this.sessionTimelineLastAnchorY = 0; + this.sessionTimelineLastDirection = 0; + this.sessionPreviewScrollEl = null; + this.sessionPreviewContainerEl = null; + this.sessionPreviewHeaderEl = null; + }, + + finalizeSessionTabTeardown() { + this.resetSessionPreviewMessageRender(); + this.sessionPreviewPendingVisibleCount = 0; + this.clearSessionTimelineRefs(); + }, + + teardownSessionTabRender() { + this.suspendSessionTabRender(); + this.finalizeSessionTabTeardown(); + }, + + prepareSessionTabRender() { + const ticket = ++this.sessionTabRenderTicket; + this.sessionListRenderEnabled = false; + this.sessionPreviewRenderEnabled = false; + this.resetSessionPreviewMessageRender(); + + this.scheduleAfterFrame(() => { + if (ticket !== this.sessionTabRenderTicket || this.mainTab !== 'sessions') { + return; + } + this.sessionListRenderEnabled = true; + + this.scheduleAfterFrame(() => { + if (ticket !== this.sessionTabRenderTicket || this.mainTab !== 'sessions') { + return; + } + this.sessionPreviewRenderEnabled = true; + this.$nextTick(() => { + if (ticket !== this.sessionTabRenderTicket || this.mainTab !== 'sessions') { + return; + } + this.primeSessionPreviewMessageRender(); + this.updateSessionTimelineOffset(); + this.scheduleSessionTimelineSync(); + }); + }); + }); + } + }; +} diff --git a/web-ui/modules/app.methods.openclaw-core.mjs b/web-ui/modules/app.methods.openclaw-core.mjs new file mode 100644 index 0000000..5013f3b --- /dev/null +++ b/web-ui/modules/app.methods.openclaw-core.mjs @@ -0,0 +1,514 @@ +export function createOpenclawCoreMethods() { + return { + getOpenclawParser() { + const globalWindow = typeof window !== 'undefined' ? window : null; + if (globalWindow && globalWindow.JSON5 + && typeof globalWindow.JSON5.parse === 'function' + && typeof globalWindow.JSON5.stringify === 'function') { + return { + parse: globalWindow.JSON5.parse, + stringify: globalWindow.JSON5.stringify + }; + } + return { + parse: JSON.parse, + stringify: JSON.stringify + }; + }, + + parseOpenclawContent(content, options = {}) { + const allowEmpty = !!options.allowEmpty; + const raw = typeof content === 'string' ? content.trim() : ''; + if (!raw) { + if (allowEmpty) { + return { ok: true, data: {} }; + } + return { ok: false, error: '配置内容为空' }; + } + try { + const parser = this.getOpenclawParser(); + const data = parser.parse(raw); + if (!data || typeof data !== 'object' || Array.isArray(data)) { + return { ok: false, error: '配置格式错误(根节点必须是对象)' }; + } + return { ok: true, data }; + } catch (e) { + return { ok: false, error: e.message || '解析失败' }; + } + }, + + stringifyOpenclawConfig(data) { + const parser = this.getOpenclawParser(); + try { + return parser.stringify(data, null, 2); + } catch (e) { + return JSON.stringify(data, null, 2); + } + }, + + resetOpenclawStructured() { + this.openclawStructured = { + agentPrimary: '', + agentFallbacks: [''], + workspace: '', + timeout: '', + contextTokens: '', + maxConcurrent: '', + envItems: [{ key: '', value: '', show: false }], + toolsProfile: 'default', + toolsAllow: [''], + toolsDeny: [''] + }; + this.openclawAgentsList = []; + this.openclawProviders = []; + this.openclawMissingProviders = []; + }, + + getOpenclawQuickDefaults() { + return { + providerName: '', + baseUrl: '', + apiKey: '', + apiType: 'openai-responses', + modelId: '', + modelName: '', + contextWindow: '', + maxTokens: '', + setPrimary: true, + overrideProvider: true, + overrideModels: true, + showKey: false + }; + }, + + resetOpenclawQuick() { + this.openclawQuick = this.getOpenclawQuickDefaults(); + }, + + toggleOpenclawQuickKey() { + this.openclawQuick.showKey = !this.openclawQuick.showKey; + }, + + fillOpenclawQuickFromConfig(config) { + const defaults = this.getOpenclawQuickDefaults(); + if (!config || typeof config !== 'object' || Array.isArray(config)) { + this.openclawQuick = defaults; + return; + } + + const agentDefaults = config.agents && typeof config.agents === 'object' && !Array.isArray(config.agents) + && config.agents.defaults && typeof config.agents.defaults === 'object' && !Array.isArray(config.agents.defaults) + ? config.agents.defaults + : {}; + const modelConfig = agentDefaults.model; + const legacyAgent = config.agent && typeof config.agent === 'object' && !Array.isArray(config.agent) + ? config.agent + : {}; + + let primaryRef = ''; + if (modelConfig && typeof modelConfig === 'object' && !Array.isArray(modelConfig) && typeof modelConfig.primary === 'string') { + primaryRef = modelConfig.primary; + } else if (typeof modelConfig === 'string') { + primaryRef = modelConfig; + } + if (!primaryRef) { + if (typeof legacyAgent.model === 'string') { + primaryRef = legacyAgent.model; + } else if (legacyAgent.model && typeof legacyAgent.model === 'object' && typeof legacyAgent.model.primary === 'string') { + primaryRef = legacyAgent.model.primary; + } + } + + let providerName = ''; + let modelId = ''; + if (primaryRef) { + const parts = primaryRef.split('/'); + if (parts.length >= 2) { + providerName = parts.shift().trim(); + modelId = parts.join('/').trim(); + } + } + + const providers = config.models && typeof config.models === 'object' && !Array.isArray(config.models) + && config.models.providers && typeof config.models.providers === 'object' && !Array.isArray(config.models.providers) + ? config.models.providers + : null; + let providerConfig = providerName && providers ? providers[providerName] : null; + if (!providerName && providers) { + const providerKeys = Object.keys(providers); + if (providerKeys.length === 1) { + providerName = providerKeys[0]; + providerConfig = providers[providerName]; + } + } + + let modelEntry = null; + if (providerConfig && typeof providerConfig === 'object' && Array.isArray(providerConfig.models)) { + if (modelId) { + modelEntry = providerConfig.models.find(item => item && item.id === modelId); + } + if (!modelEntry && providerConfig.models.length === 1) { + modelEntry = providerConfig.models[0]; + if (!modelId && modelEntry && typeof modelEntry.id === 'string') { + modelId = modelEntry.id; + } + } + } + + const baseUrl = providerConfig && typeof providerConfig === 'object' && typeof providerConfig.baseUrl === 'string' + ? providerConfig.baseUrl + : ''; + const apiKey = providerConfig && typeof providerConfig === 'object' && typeof providerConfig.apiKey === 'string' + ? providerConfig.apiKey + : ''; + const apiType = providerConfig && typeof providerConfig === 'object' && typeof providerConfig.api === 'string' + ? providerConfig.api + : defaults.apiType; + + this.openclawQuick = { + ...defaults, + providerName, + baseUrl, + apiKey, + apiType, + modelId: modelId || '', + modelName: modelEntry && typeof modelEntry.name === 'string' ? modelEntry.name : '', + contextWindow: modelEntry && typeof modelEntry.contextWindow === 'number' + ? String(modelEntry.contextWindow) + : '', + maxTokens: modelEntry && typeof modelEntry.maxTokens === 'number' + ? String(modelEntry.maxTokens) + : '' + }; + }, + + syncOpenclawQuickFromText(options = {}) { + const silent = !!options.silent; + const parsed = this.parseOpenclawContent(this.openclawEditing.content, { allowEmpty: true }); + if (!parsed.ok) { + this.resetOpenclawQuick(); + if (!silent) { + this.showMessage('解析 OpenClaw 配置失败: ' + parsed.error, 'error'); + } + return false; + } + this.fillOpenclawQuickFromConfig(parsed.data); + if (!silent) { + this.showMessage('已读取配置', 'success'); + } + return true; + }, + + mergeOpenclawModelEntry(existing, incoming, overwrite = false) { + if (!existing || typeof existing !== 'object' || Array.isArray(existing)) { + return { ...incoming }; + } + if (overwrite) { + return { ...incoming }; + } + const merged = { ...existing }; + for (const [key, value] of Object.entries(incoming || {})) { + if (merged[key] === undefined || merged[key] === null || merged[key] === '') { + merged[key] = value; + } + } + return merged; + }, + + fillOpenclawStructured(config) { + const defaults = config && config.agents && typeof config.agents === 'object' && !Array.isArray(config.agents) + && config.agents.defaults && typeof config.agents.defaults === 'object' && !Array.isArray(config.agents.defaults) + ? config.agents.defaults + : {}; + const model = defaults.model && typeof defaults.model === 'object' && !Array.isArray(defaults.model) + ? defaults.model + : {}; + const legacyAgent = config && config.agent && typeof config.agent === 'object' && !Array.isArray(config.agent) + ? config.agent + : {}; + const fallbackSource = Array.isArray(model.fallbacks) + ? model.fallbacks + : (legacyAgent.model && typeof legacyAgent.model === 'object' && !Array.isArray(legacyAgent.model) && Array.isArray(legacyAgent.model.fallbacks) + ? legacyAgent.model.fallbacks + : []); + const fallbackList = fallbackSource + .filter(item => typeof item === 'string' && item.trim()) + .map(item => item.trim()); + const env = config && config.env && typeof config.env === 'object' && !Array.isArray(config.env) + ? config.env + : {}; + const envItems = Object.entries(env).map(([key, value]) => ({ + key, + value: value == null ? '' : String(value), + show: false + })); + const tools = config && config.tools && typeof config.tools === 'object' && !Array.isArray(config.tools) + ? config.tools + : {}; + + let primary = typeof model.primary === 'string' ? model.primary : ''; + if (!primary) { + if (typeof legacyAgent.model === 'string') { + primary = legacyAgent.model; + } else if (legacyAgent.model && typeof legacyAgent.model === 'object' && typeof legacyAgent.model.primary === 'string') { + primary = legacyAgent.model.primary; + } + } + + this.openclawStructured = { + agentPrimary: primary, + agentFallbacks: fallbackList.length ? fallbackList : [''], + workspace: typeof defaults.workspace === 'string' ? defaults.workspace : '', + timeout: typeof defaults.timeout === 'number' && Number.isFinite(defaults.timeout) + ? String(defaults.timeout) + : '', + contextTokens: typeof defaults.contextTokens === 'number' && Number.isFinite(defaults.contextTokens) + ? String(defaults.contextTokens) + : '', + maxConcurrent: typeof defaults.maxConcurrent === 'number' && Number.isFinite(defaults.maxConcurrent) + ? String(defaults.maxConcurrent) + : '', + envItems: envItems.length ? envItems : [{ key: '', value: '', show: false }], + toolsProfile: typeof tools.profile === 'string' && tools.profile.trim() ? tools.profile : 'default', + toolsAllow: Array.isArray(tools.allow) && tools.allow.length + ? tools.allow.filter(item => typeof item === 'string' && item.trim()).map(item => item.trim()) + : [''], + toolsDeny: Array.isArray(tools.deny) && tools.deny.length + ? tools.deny.filter(item => typeof item === 'string' && item.trim()).map(item => item.trim()) + : [''] + }; + }, + + syncOpenclawStructuredFromText(options = {}) { + const silent = !!options.silent; + const parsed = this.parseOpenclawContent(this.openclawEditing.content, { allowEmpty: true }); + if (!parsed.ok) { + this.resetOpenclawStructured(); + this.resetOpenclawQuick(); + if (!silent) { + this.showMessage('解析 OpenClaw 配置失败: ' + parsed.error, 'error'); + } + return false; + } + this.fillOpenclawStructured(parsed.data); + this.fillOpenclawQuickFromConfig(parsed.data); + this.refreshOpenclawProviders(parsed.data); + this.refreshOpenclawAgentsList(parsed.data); + if (!silent) { + this.showMessage('已刷新配置', 'success'); + } + return true; + }, + + getOpenclawActiveProviders(config) { + const active = new Set(); + const addProvider = (ref) => { + if (typeof ref !== 'string') return; + const text = ref.trim(); + if (!text) return; + const parts = text.split('/'); + if (parts.length < 2) return; + const provider = parts[0].trim(); + if (provider) active.add(provider); + }; + const defaults = config && config.agents && config.agents.defaults + ? config.agents.defaults + : {}; + const model = defaults && defaults.model; + if (model && typeof model === 'object' && !Array.isArray(model)) { + addProvider(model.primary); + if (Array.isArray(model.fallbacks)) { + for (const item of model.fallbacks) { + addProvider(item); + } + } + } else if (typeof model === 'string') { + addProvider(model); + } + const legacyAgent = config && config.agent && typeof config.agent === 'object' && !Array.isArray(config.agent) + ? config.agent + : {}; + if (typeof legacyAgent.model === 'string') { + addProvider(legacyAgent.model); + } else if (legacyAgent.model && typeof legacyAgent.model === 'object' && !Array.isArray(legacyAgent.model)) { + addProvider(legacyAgent.model.primary); + if (Array.isArray(legacyAgent.model.fallbacks)) { + for (const item of legacyAgent.model.fallbacks) { + addProvider(item); + } + } + } + const modelsDefaults = config && config.models && config.models.defaults + ? config.models.defaults + : {}; + if (modelsDefaults && typeof modelsDefaults.provider === 'string' && modelsDefaults.provider.trim()) { + active.add(modelsDefaults.provider.trim()); + } + if (modelsDefaults && typeof modelsDefaults.model === 'string') { + addProvider(modelsDefaults.model); + } + return active; + }, + + maskProviderValue(value) { + const text = value == null ? '' : String(value); + if (!text) return '****'; + if (text.length <= 6) return '****'; + return `${text.slice(0, 3)}****${text.slice(-3)}`; + }, + + formatProviderValue(key, value) { + if (typeof value === 'undefined' || value === null) { + return ''; + } + let text = ''; + if (typeof value === 'string') { + text = value; + } else if (typeof value === 'number' || typeof value === 'boolean') { + text = String(value); + } else { + try { + text = JSON.stringify(value); + } catch (_) { + text = String(value); + } + } + if (!text) return ''; + if (/key|token|secret|password/i.test(key)) { + return this.maskProviderValue(text); + } + if (text.length > 160) { + return `${text.slice(0, 157)}...`; + } + return text; + }, + + collectOpenclawProviders(source, providerMap, activeProviders, entries) { + if (!providerMap || typeof providerMap !== 'object' || Array.isArray(providerMap)) { + return; + } + const keys = Object.keys(providerMap).sort(); + for (const key of keys) { + const value = providerMap[key]; + const fields = []; + if (value && typeof value === 'object' && !Array.isArray(value)) { + const fieldKeys = Object.keys(value).sort(); + for (const fieldKey of fieldKeys) { + const fieldValue = this.formatProviderValue(fieldKey, value[fieldKey]); + if (fieldValue === '') continue; + fields.push({ key: fieldKey, value: fieldValue }); + } + } else { + const fieldValue = this.formatProviderValue('value', value); + if (fieldValue !== '') { + fields.push({ key: 'value', value: fieldValue }); + } + } + entries.push({ + key, + source, + fields, + isActive: activeProviders.has(key) + }); + } + }, + + refreshOpenclawProviders(config) { + const activeProviders = this.getOpenclawActiveProviders(config || {}); + const entries = []; + const modelsProviders = config && config.models ? config.models.providers : null; + const rootProviders = config && config.providers ? config.providers : null; + this.collectOpenclawProviders('models.providers', modelsProviders, activeProviders, entries); + this.collectOpenclawProviders('providers', rootProviders, activeProviders, entries); + const existing = new Set(entries.map(item => item.key)); + const missing = []; + for (const provider of activeProviders) { + if (!existing.has(provider)) { + missing.push(provider); + } + } + this.openclawProviders = entries; + this.openclawMissingProviders = missing; + }, + + refreshOpenclawAgentsList(config) { + const list = config && config.agents && typeof config.agents === 'object' && !Array.isArray(config.agents) + ? config.agents.list + : null; + if (!Array.isArray(list)) { + this.openclawAgentsList = []; + return; + } + const entries = []; + list.forEach((item, index) => { + if (!item || typeof item !== 'object') return; + const id = typeof item.id === 'string' && item.id.trim() ? item.id.trim() : `agent-${index + 1}`; + const identity = item.identity && typeof item.identity === 'object' && !Array.isArray(item.identity) + ? item.identity + : {}; + const name = typeof identity.name === 'string' && identity.name.trim() + ? identity.name.trim() + : id; + entries.push({ + key: `${id}-${index}`, + id, + name, + theme: typeof identity.theme === 'string' ? identity.theme : '', + emoji: typeof identity.emoji === 'string' ? identity.emoji : '', + avatar: typeof identity.avatar === 'string' ? identity.avatar : '' + }); + }); + this.openclawAgentsList = entries; + }, + + normalizeStringList(list) { + if (!Array.isArray(list)) return []; + const result = []; + const seen = new Set(); + for (const item of list) { + const value = typeof item === 'string' ? item.trim() : String(item || '').trim(); + if (!value) continue; + const key = value; + if (seen.has(key)) continue; + seen.add(key); + result.push(value); + } + return result; + }, + + normalizeEnvItems(items) { + if (!Array.isArray(items)) { + return { ok: true, items: {} }; + } + const output = {}; + const seen = new Set(); + for (const item of items) { + const key = item && typeof item.key === 'string' ? item.key.trim() : ''; + if (!key) continue; + if (seen.has(key)) { + return { ok: false, error: `环境变量重复: ${key}` }; + } + seen.add(key); + const value = item && typeof item.value !== 'undefined' ? String(item.value) : ''; + output[key] = value; + } + return { ok: true, items: output }; + }, + + parseOptionalNumber(value, label) { + const text = typeof value === 'string' + ? value.trim() + : typeof value === 'number' + ? String(value).trim() + : String(value || '').trim(); + if (!text) { + return { ok: true, value: null }; + } + const num = Number(text); + if (!Number.isFinite(num) || num < 0) { + return { ok: false, error: `${label} 请输入有效数字` }; + } + return { ok: true, value: num }; + } + }; +} diff --git a/web-ui/modules/app.methods.openclaw-editing.mjs b/web-ui/modules/app.methods.openclaw-editing.mjs new file mode 100644 index 0000000..1b86332 --- /dev/null +++ b/web-ui/modules/app.methods.openclaw-editing.mjs @@ -0,0 +1,337 @@ +export function createOpenclawEditingMethods() { + return { + applyOpenclawStructuredToText() { + const parsed = this.parseOpenclawContent(this.openclawEditing.content, { allowEmpty: true }); + if (!parsed.ok) { + this.showMessage('解析 OpenClaw 配置失败: ' + parsed.error, 'error'); + return; + } + + const config = parsed.data; + const agents = config.agents && typeof config.agents === 'object' && !Array.isArray(config.agents) + ? config.agents + : {}; + const defaults = agents.defaults && typeof agents.defaults === 'object' && !Array.isArray(agents.defaults) + ? agents.defaults + : {}; + const model = defaults.model && typeof defaults.model === 'object' && !Array.isArray(defaults.model) + ? defaults.model + : {}; + + const primary = (this.openclawStructured.agentPrimary || '').trim(); + const fallbacks = this.normalizeStringList(this.openclawStructured.agentFallbacks); + if (primary) { + model.primary = primary; + } else { + delete model.primary; + } + if (fallbacks.length) { + model.fallbacks = fallbacks; + } else { + delete model.fallbacks; + } + if (Object.keys(model).length > 0) { + defaults.model = model; + } else { + delete defaults.model; + } + if (config.agent && typeof config.agent === 'object' && !Array.isArray(config.agent)) { + if (primary) { + config.agent.model = primary; + } else { + delete config.agent.model; + } + } + + const workspace = (this.openclawStructured.workspace || '').trim(); + if (workspace) { + defaults.workspace = workspace; + } else { + delete defaults.workspace; + } + + const timeout = this.parseOptionalNumber(this.openclawStructured.timeout, 'Timeout'); + if (!timeout.ok) { + this.showMessage(timeout.error, 'error'); + return; + } + if (timeout.value !== null) { + defaults.timeout = timeout.value; + } else { + delete defaults.timeout; + } + + const contextTokens = this.parseOptionalNumber(this.openclawStructured.contextTokens, 'Context Tokens'); + if (!contextTokens.ok) { + this.showMessage(contextTokens.error, 'error'); + return; + } + if (contextTokens.value !== null) { + defaults.contextTokens = contextTokens.value; + } else { + delete defaults.contextTokens; + } + + const maxConcurrent = this.parseOptionalNumber(this.openclawStructured.maxConcurrent, 'Max Concurrent'); + if (!maxConcurrent.ok) { + this.showMessage(maxConcurrent.error, 'error'); + return; + } + if (maxConcurrent.value !== null) { + defaults.maxConcurrent = maxConcurrent.value; + } else { + delete defaults.maxConcurrent; + } + + if (Object.keys(defaults).length > 0) { + config.agents = agents; + config.agents.defaults = defaults; + } else if (agents.defaults) { + delete agents.defaults; + if (Object.keys(agents).length > 0) { + config.agents = agents; + } else { + delete config.agents; + } + } + + const envResult = this.normalizeEnvItems(this.openclawStructured.envItems); + if (!envResult.ok) { + this.showMessage(envResult.error, 'error'); + return; + } + if (Object.keys(envResult.items).length > 0) { + config.env = envResult.items; + } else if (config.env) { + delete config.env; + } + + const profile = (this.openclawStructured.toolsProfile || '').trim(); + const allowList = this.normalizeStringList(this.openclawStructured.toolsAllow); + const denyList = this.normalizeStringList(this.openclawStructured.toolsDeny); + const hasTools = profile || allowList.length || denyList.length || (config.tools && typeof config.tools === 'object'); + if (hasTools) { + const tools = config.tools && typeof config.tools === 'object' && !Array.isArray(config.tools) + ? config.tools + : {}; + if (profile) { + tools.profile = profile; + } else { + delete tools.profile; + } + if (allowList.length) { + tools.allow = allowList; + } else { + delete tools.allow; + } + if (denyList.length) { + tools.deny = denyList; + } else { + delete tools.deny; + } + if (Object.keys(tools).length > 0) { + config.tools = tools; + } else { + delete config.tools; + } + } + + this.openclawEditing.content = this.stringifyOpenclawConfig(config); + this.refreshOpenclawProviders(config); + this.refreshOpenclawAgentsList(config); + this.fillOpenclawQuickFromConfig(config); + this.showMessage('已写入', 'success'); + }, + + applyOpenclawQuickToText() { + const parsed = this.parseOpenclawContent(this.openclawEditing.content, { allowEmpty: true }); + if (!parsed.ok) { + this.showMessage('解析 OpenClaw 配置失败: ' + parsed.error, 'error'); + return; + } + + const providerName = (this.openclawQuick.providerName || '').trim(); + const modelId = (this.openclawQuick.modelId || '').trim(); + if (!providerName) { + this.showMessage('请填写名称', 'error'); + return; + } + if (providerName.includes('/')) { + this.showMessage('Provider 名称不能包含 "/"', 'error'); + return; + } + if (!modelId) { + this.showMessage('请填写模型', 'error'); + return; + } + + const config = parsed.data; + const ensureObject = (value) => (value && typeof value === 'object' && !Array.isArray(value)) ? value : {}; + const models = ensureObject(config.models); + const providers = ensureObject(models.providers); + const provider = ensureObject(providers[providerName]); + const baseUrl = (this.openclawQuick.baseUrl || '').trim(); + if (!baseUrl && !provider.baseUrl) { + this.showMessage('请填写 URL', 'error'); + return; + } + + const contextWindow = this.parseOptionalNumber(this.openclawQuick.contextWindow, '上下文长度'); + if (!contextWindow.ok) { + this.showMessage(contextWindow.error, 'error'); + return; + } + const maxTokens = this.parseOptionalNumber(this.openclawQuick.maxTokens, '最大输出'); + if (!maxTokens.ok) { + this.showMessage(maxTokens.error, 'error'); + return; + } + + const shouldOverrideProvider = !!this.openclawQuick.overrideProvider; + const apiKey = (this.openclawQuick.apiKey || '').trim(); + const apiType = (this.openclawQuick.apiType || '').trim(); + const setProviderField = (key, value) => { + if (!value) return; + if (shouldOverrideProvider || provider[key] === undefined || provider[key] === null || provider[key] === '') { + provider[key] = value; + } + }; + setProviderField('baseUrl', baseUrl); + setProviderField('api', apiType); + if (apiKey) { + setProviderField('apiKey', apiKey); + } + + const modelName = (this.openclawQuick.modelName || '').trim() || modelId; + const modelEntry = { + id: modelId, + name: modelName, + reasoning: false, + input: ['text'], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0 + } + }; + if (contextWindow.value !== null) { + modelEntry.contextWindow = contextWindow.value; + } + if (maxTokens.value !== null) { + modelEntry.maxTokens = maxTokens.value; + } + + const existingModels = Array.isArray(provider.models) ? [...provider.models] : []; + if (this.openclawQuick.overrideModels || existingModels.length === 0) { + provider.models = [modelEntry]; + } else { + const idx = existingModels.findIndex(item => item && item.id === modelId); + if (idx >= 0) { + existingModels[idx] = this.mergeOpenclawModelEntry(existingModels[idx], modelEntry, false); + } else { + existingModels.push(modelEntry); + } + provider.models = existingModels; + } + + providers[providerName] = provider; + models.providers = providers; + config.models = models; + + if (this.openclawQuick.setPrimary) { + const agents = ensureObject(config.agents); + const defaults = ensureObject(agents.defaults); + const modelConfig = defaults.model && typeof defaults.model === 'object' && !Array.isArray(defaults.model) + ? defaults.model + : {}; + modelConfig.primary = `${providerName}/${modelId}`; + defaults.model = modelConfig; + agents.defaults = defaults; + config.agents = agents; + if (config.agent && typeof config.agent === 'object' && !Array.isArray(config.agent)) { + config.agent.model = modelConfig.primary; + } + } + + this.openclawEditing.content = this.stringifyOpenclawConfig(config); + this.fillOpenclawStructured(config); + this.refreshOpenclawProviders(config); + this.refreshOpenclawAgentsList(config); + this.showMessage('配置已写入', 'success'); + }, + + addOpenclawFallback() { + this.openclawStructured.agentFallbacks.push(''); + }, + + removeOpenclawFallback(index) { + this.openclawStructured.agentFallbacks.splice(index, 1); + if (this.openclawStructured.agentFallbacks.length === 0) { + this.openclawStructured.agentFallbacks.push(''); + } + }, + + addOpenclawEnvItem() { + this.openclawStructured.envItems.push({ key: '', value: '', show: false }); + }, + + removeOpenclawEnvItem(index) { + this.openclawStructured.envItems.splice(index, 1); + if (this.openclawStructured.envItems.length === 0) { + this.openclawStructured.envItems.push({ key: '', value: '', show: false }); + } + }, + + toggleOpenclawEnvItem(index) { + const item = this.openclawStructured.envItems[index]; + if (item) { + item.show = !item.show; + } + }, + + addOpenclawToolsAllow() { + this.openclawStructured.toolsAllow.push(''); + }, + + removeOpenclawToolsAllow(index) { + this.openclawStructured.toolsAllow.splice(index, 1); + if (this.openclawStructured.toolsAllow.length === 0) { + this.openclawStructured.toolsAllow.push(''); + } + }, + + addOpenclawToolsDeny() { + this.openclawStructured.toolsDeny.push(''); + }, + + removeOpenclawToolsDeny(index) { + this.openclawStructured.toolsDeny.splice(index, 1); + if (this.openclawStructured.toolsDeny.length === 0) { + this.openclawStructured.toolsDeny.push(''); + } + }, + + openclawHasContent(config) { + return !!(config && typeof config.content === 'string' && config.content.trim()); + }, + + openclawSubtitle(config) { + if (!this.openclawHasContent(config)) { + return '未设置配置'; + } + const length = config.content.trim().length; + return `已保存 ${length} 字符`; + }, + + saveOpenclawConfigs() { + try { + localStorage.setItem('openclawConfigs', JSON.stringify(this.openclawConfigs)); + return true; + } catch (_) { + this.showMessage('保存本地 OpenClaw 配置失败', 'error'); + return false; + } + } + }; +} diff --git a/web-ui/modules/app.methods.openclaw-persist.mjs b/web-ui/modules/app.methods.openclaw-persist.mjs new file mode 100644 index 0000000..4d6e09d --- /dev/null +++ b/web-ui/modules/app.methods.openclaw-persist.mjs @@ -0,0 +1,251 @@ +export function createOpenclawPersistMethods(options = {}) { + const { + api, + defaultOpenclawTemplate = '' + } = options; + + return { + openOpenclawAddModal() { + const modalToken = (Number(this.openclawModalLoadToken || 0) + 1); + this.openclawModalLoadToken = modalToken; + this.openclawEditorTitle = '添加 OpenClaw 配置'; + this.openclawEditing = { + name: '', + content: '', + lockName: false + }; + this.openclawConfigPath = ''; + this.openclawConfigExists = false; + this.openclawLineEnding = '\n'; + this.showOpenclawConfigModal = true; + void this.loadOpenclawConfigFromFile({ + silent: true, + force: true, + fallbackToTemplate: true, + modalToken, + expectedEditorContent: '' + }); + }, + + openOpenclawEditModal(name) { + const existing = this.openclawConfigs[name]; + const modalToken = (Number(this.openclawModalLoadToken || 0) + 1); + this.openclawModalLoadToken = modalToken; + this.openclawEditorTitle = `编辑 OpenClaw 配置: ${name}`; + this.openclawEditing = { + name, + content: this.openclawHasContent(existing) ? existing.content : '', + lockName: true + }; + this.syncOpenclawStructuredFromText({ silent: true }); + this.showOpenclawConfigModal = true; + void this.loadOpenclawConfigFromFile({ + silent: true, + force: false, + fallbackToTemplate: false, + modalToken, + expectedEditorContent: this.openclawEditing.content + }); + }, + + closeOpenclawConfigModal(options = {}) { + const force = !!options.force; + if (!force && (this.openclawSaving || this.openclawApplying)) { + return; + } + this.openclawModalLoadToken = Number(this.openclawModalLoadToken || 0) + 1; + this.showOpenclawConfigModal = false; + this.openclawEditing = { name: '', content: '', lockName: false }; + this.openclawSaving = false; + this.openclawApplying = false; + this.resetOpenclawStructured(); + this.resetOpenclawQuick(); + }, + + async loadOpenclawConfigFromFile(options = {}) { + const silent = !!options.silent; + const force = !!options.force; + const fallbackToTemplate = options.fallbackToTemplate !== false; + const modalToken = Number(options.modalToken || this.openclawModalLoadToken || 0); + const expectedEditorContent = typeof options.expectedEditorContent === 'string' + ? options.expectedEditorContent + : (typeof this.openclawEditing.content === 'string' ? this.openclawEditing.content : ''); + const requestSeq = (Number(this.openclawFileLoadRequestSeq || 0) + 1); + this.openclawFileLoadRequestSeq = requestSeq; + this.openclawFileLoading = true; + try { + const res = await api('get-openclaw-config'); + if ( + requestSeq !== Number(this.openclawFileLoadRequestSeq || 0) + || modalToken !== Number(this.openclawModalLoadToken || 0) + ) { + return; + } + if (res.error) { + if (!silent) { + this.showMessage(res.error, 'error'); + } + return; + } + this.openclawConfigPath = res.path || ''; + this.openclawConfigExists = !!res.exists; + this.openclawLineEnding = res.lineEnding === '\r\n' ? '\r\n' : '\n'; + const hasContent = !!(res.content && res.content.trim()); + const currentContent = typeof this.openclawEditing.content === 'string' + ? this.openclawEditing.content + : ''; + const editorChangedSinceRequest = currentContent !== expectedEditorContent; + const shouldOverride = force + ? (!currentContent.trim() || !editorChangedSinceRequest) + : (!currentContent || !currentContent.trim()); + if (hasContent && shouldOverride) { + this.openclawEditing.content = res.content; + } else if (!hasContent && shouldOverride && fallbackToTemplate) { + this.openclawEditing.content = defaultOpenclawTemplate; + } + this.syncOpenclawStructuredFromText({ silent: true }); + if (!silent) { + this.showMessage('加载完成', 'success'); + } + } catch (e) { + if ( + requestSeq !== Number(this.openclawFileLoadRequestSeq || 0) + || modalToken !== Number(this.openclawModalLoadToken || 0) + ) { + return; + } + if (!silent) { + this.showMessage('加载配置失败', 'error'); + } + } finally { + if (requestSeq === Number(this.openclawFileLoadRequestSeq || 0)) { + this.openclawFileLoading = false; + } + } + }, + + persistOpenclawConfig({ closeModal = true } = {}) { + if (!this.openclawEditing.name || !this.openclawEditing.name.trim()) { + this.showMessage('请输入名称', 'error'); + return ''; + } + const name = this.openclawEditing.name.trim(); + if (!this.openclawEditing.lockName && this.openclawConfigs[name]) { + this.showMessage('名称已存在', 'error'); + return ''; + } + if (!this.openclawEditing.content || !this.openclawEditing.content.trim()) { + this.showMessage('配置内容不能为空', 'error'); + return ''; + } + + const hadPreviousConfig = Object.prototype.hasOwnProperty.call(this.openclawConfigs, name); + const previousConfig = hadPreviousConfig ? this.openclawConfigs[name] : undefined; + const previousCurrentConfig = this.currentOpenclawConfig; + this.openclawConfigs[name] = { + content: this.openclawEditing.content + }; + this.currentOpenclawConfig = name; + if (this.saveOpenclawConfigs() === false) { + if (hadPreviousConfig) { + this.openclawConfigs[name] = previousConfig; + } else { + delete this.openclawConfigs[name]; + } + this.currentOpenclawConfig = previousCurrentConfig; + return ''; + } + if (closeModal) { + this.closeOpenclawConfigModal({ force: true }); + } + return name; + }, + + async saveOpenclawConfig() { + if (this.openclawSaving || this.openclawApplying) { + return; + } + this.openclawSaving = true; + try { + const name = this.persistOpenclawConfig(); + if (!name) return; + this.showMessage('操作成功', 'success'); + } finally { + this.openclawSaving = false; + } + }, + + async saveAndApplyOpenclawConfig() { + if (this.openclawSaving || this.openclawApplying) { + return; + } + this.openclawApplying = true; + try { + const name = this.persistOpenclawConfig({ closeModal: false }); + if (!name) return; + const config = this.openclawConfigs[name]; + const res = await api('apply-openclaw-config', { + content: config.content, + lineEnding: this.openclawLineEnding + }); + if (res.error || res.success === false) { + this.showMessage(res.error || '应用配置失败', 'error'); + return; + } + this.openclawConfigPath = res.targetPath || this.openclawConfigPath; + this.openclawConfigExists = true; + const targetTip = res.targetPath ? `(${res.targetPath})` : ''; + this.showMessage(`已保存并应用 OpenClaw 配置${targetTip}`, 'success'); + this.closeOpenclawConfigModal({ force: true }); + } catch (e) { + this.showMessage('应用配置失败', 'error'); + } finally { + this.openclawApplying = false; + } + }, + + async deleteOpenclawConfig(name) { + if (Object.keys(this.openclawConfigs).length <= 1) { + return this.showMessage('至少保留一项', 'error'); + } + const confirmed = await this.requestConfirmDialog({ + title: '删除 OpenClaw 配置', + message: `确定删除配置 "${name}"?`, + confirmText: '删除', + cancelText: '取消', + danger: true + }); + if (!confirmed) return; + delete this.openclawConfigs[name]; + if (this.currentOpenclawConfig === name) { + this.currentOpenclawConfig = Object.keys(this.openclawConfigs)[0]; + } + this.saveOpenclawConfigs(); + this.showMessage('操作成功', 'success'); + }, + + async applyOpenclawConfig(name) { + this.currentOpenclawConfig = name; + const config = this.openclawConfigs[name]; + if (!this.openclawHasContent(config)) { + return this.showMessage('配置为空', 'error'); + } + try { + const res = await api('apply-openclaw-config', { + content: config.content, + lineEnding: this.openclawLineEnding + }); + if (res.error || res.success === false) { + this.showMessage(res.error || '应用配置失败', 'error'); + } else { + this.openclawConfigPath = res.targetPath || this.openclawConfigPath; + this.openclawConfigExists = true; + const targetTip = res.targetPath ? `(${res.targetPath})` : ''; + this.showMessage(`已应用 OpenClaw 配置: ${name}${targetTip}`, 'success'); + } + } catch (_) { + this.showMessage('应用配置失败', 'error'); + } + } + }; +} diff --git a/web-ui/modules/app.methods.providers.mjs b/web-ui/modules/app.methods.providers.mjs new file mode 100644 index 0000000..3be2192 --- /dev/null +++ b/web-ui/modules/app.methods.providers.mjs @@ -0,0 +1,265 @@ +export function createProvidersMethods(options = {}) { + const { api } = options; + + return { + async addProvider() { + const rawName = typeof this.newProvider.name === 'string' ? this.newProvider.name : ''; + const rawUrl = typeof this.newProvider.url === 'string' ? this.newProvider.url.trim() : ''; + if (!rawName || !rawUrl) { + return this.showMessage('名称和URL必填', 'error'); + } + const name = rawName.trim(); + if (!name) { + return this.showMessage('名称不能为空', 'error'); + } + if (name.toLowerCase() === 'local') { + return this.showMessage('local provider 为系统保留名称,不可新增', 'error'); + } + if (this.providersList.some(item => item.name === name)) { + return this.showMessage('名称已存在', 'error'); + } + + try { + const res = await api('add-provider', { + name, + url: rawUrl, + key: this.newProvider.key || '' + }); + if (res.error) { + this.showMessage(res.error, 'error'); + return; + } + + this.showMessage('操作成功', 'success'); + this.closeAddModal(); + await this.loadAll(); + } catch (e) { + this.showMessage('添加失败', 'error'); + } + }, + + getCurrentCodexAuthProfile() { + const list = Array.isArray(this.codexAuthProfiles) ? this.codexAuthProfiles : []; + return list.find((item) => !!(item && item.current)) || null; + }, + + isLocalLikeProvider(providerOrName) { + if (!providerOrName) return false; + const rawName = typeof providerOrName === 'object' + ? String(providerOrName.name || '') + : String(providerOrName); + const normalized = rawName.trim().toLowerCase(); + return normalized === 'local'; + }, + + providerPillState(provider) { + if (this.isLocalLikeProvider(provider)) { + const currentProfile = this.getCurrentCodexAuthProfile(); + return currentProfile + ? { configured: true, text: '已登录' } + : { configured: false, text: '未登录' }; + } + const configured = !!(provider && provider.hasKey); + return { + configured, + text: configured ? '已配置' : '未配置' + }; + }, + + providerPillConfigured(provider) { + return this.providerPillState(provider).configured; + }, + + providerPillText(provider) { + return this.providerPillState(provider).text; + }, + + isReadOnlyProvider(providerOrName) { + if (!providerOrName) return false; + if (typeof providerOrName === 'object') { + return !!providerOrName.readOnly; + } + const name = String(providerOrName).trim(); + if (!name) return false; + const target = (this.providersList || []).find((item) => item && item.name === name); + return !!(target && target.readOnly); + }, + + isNonDeletableProvider(providerOrName) { + if (!providerOrName) return false; + if (typeof providerOrName === 'object') { + const directName = String(providerOrName.name || '').trim().toLowerCase(); + if (directName === 'local') { + return true; + } + return !!providerOrName.nonDeletable; + } + const name = String(providerOrName).trim(); + if (!name) return false; + const normalized = name.toLowerCase(); + if (normalized === 'local') { + return true; + } + const target = (this.providersList || []).find((item) => item && item.name === name); + return !!(target && target.nonDeletable); + }, + + shouldShowProviderDelete(provider) { + return !this.isReadOnlyProvider(provider) && !this.isNonDeletableProvider(provider); + }, + + shouldShowProviderEdit(provider) { + return !this.isReadOnlyProvider(provider) && !this.isNonDeletableProvider(provider); + }, + + shouldAllowProviderShare(provider) { + return !this.isReadOnlyProvider(provider) && !this.isLocalLikeProvider(provider); + }, + + async deleteProvider(name) { + if (this.isNonDeletableProvider(name)) { + this.showMessage('该 provider 为保留项,不可删除', 'info'); + return; + } + try { + const res = await api('delete-provider', { name }); + if (res.error) { + this.showMessage(res.error, 'error'); + return; + } + if (res.switched && res.provider) { + this.showMessage(`已删除提供商,自动切换到 ${res.provider}${res.model ? ` / ${res.model}` : ''}`, 'success'); + } else { + this.showMessage('操作成功', 'success'); + } + await this.loadAll(); + } catch (_) { + this.showMessage('删除失败', 'error'); + } + }, + + openEditModal(provider) { + if (!this.shouldShowProviderEdit(provider)) { + this.showMessage('该 provider 为保留项,不可编辑', 'info'); + return; + } + this.editingProvider = { + name: provider.name, + url: provider.url || '', + key: '', + readOnly: !!provider.readOnly, + nonEditable: this.isNonDeletableProvider(provider) + }; + this.showEditModal = true; + }, + + async updateProvider() { + if (this.editingProvider.readOnly || this.editingProvider.nonEditable) { + this.showMessage('该 provider 为保留项,不可编辑', 'error'); + this.closeEditModal(); + return; + } + const url = typeof this.editingProvider.url === 'string' ? this.editingProvider.url.trim() : ''; + if (!url) { + return this.showMessage('URL 必填', 'error'); + } + + const name = this.editingProvider.name; + const params = { name, url }; + if (typeof this.editingProvider.key === 'string' && this.editingProvider.key.trim()) { + params.key = this.editingProvider.key; + } + try { + const res = await api('update-provider', params); + if (res.error) { + this.showMessage(res.error, 'error'); + return; + } + this.closeEditModal(); + this.showMessage('操作成功', 'success'); + await this.loadAll(); + } catch (e) { + this.showMessage('更新失败', 'error'); + } + }, + + closeEditModal() { + this.showEditModal = false; + this.editingProvider = { name: '', url: '', key: '', readOnly: false, nonEditable: false }; + }, + + async resetConfig() { + if (this.resetConfigLoading) return; + this.resetConfigLoading = true; + try { + const res = await api('reset-config'); + if (res.error) { + this.showMessage(res.error, 'error'); + return; + } + const backup = res.backupFile ? `(已备份: ${res.backupFile})` : ''; + this.showMessage(`配置已重装${backup}`, 'success'); + await this.loadAll(); + } catch (e) { + this.showMessage('重装失败', 'error'); + } finally { + this.resetConfigLoading = false; + } + }, + + async addModel() { + if (!this.newModelName || !this.newModelName.trim()) { + return this.showMessage('请输入模型', 'error'); + } + try { + const res = await api('add-model', { model: this.newModelName.trim() }); + if (res.error) { + this.showMessage(res.error, 'error'); + } else { + this.showMessage('操作成功', 'success'); + this.closeModelModal(); + await this.loadAll(); + } + } catch (_) { + this.showMessage('新增模型失败', 'error'); + } + }, + + async removeModel(model) { + try { + const res = await api('delete-model', { model }); + if (res.error) { + this.showMessage(res.error, 'error'); + } else { + this.showMessage('操作成功', 'success'); + await this.loadAll(); + } + } catch (_) { + this.showMessage('删除模型失败', 'error'); + } + }, + + closeAddModal() { + this.showAddModal = false; + this.newProvider = { name: '', url: '', key: '' }; + }, + + closeModelModal() { + this.showModelModal = false; + this.newModelName = ''; + }, + + formatKey(key) { + if (!key) return '(未设置)'; + if (key.length > 10) { + return key.substring(0, 3) + '****' + key.substring(key.length - 3); + } + return '****'; + }, + + displayApiKey(configName) { + const key = this.claudeConfigs[configName]?.apiKey; + return this.formatKey(key); + } + }; +} diff --git a/web-ui/modules/app.methods.runtime.mjs b/web-ui/modules/app.methods.runtime.mjs new file mode 100644 index 0000000..8a0e419 --- /dev/null +++ b/web-ui/modules/app.methods.runtime.mjs @@ -0,0 +1,323 @@ +import { + buildSpeedTestIssue, + formatLatency +} from '../logic.mjs'; + +function clearProgressResetTimer(context, timerKey) { + if (!context || !timerKey || !context[timerKey]) { + return; + } + clearTimeout(context[timerKey]); + context[timerKey] = null; +} + +function scheduleProgressResetTimer(context, timerKey, progressKey, delayMs = 800) { + if (!context || !timerKey || !progressKey) { + return; + } + clearProgressResetTimer(context, timerKey); + context[timerKey] = setTimeout(() => { + context[progressKey] = 0; + context[timerKey] = null; + }, delayMs); +} + +export function createRuntimeMethods(options = {}) { + const { api } = options; + + return { + formatLatency, + + buildSpeedTestIssue(name, result) { + return buildSpeedTestIssue(name, result); + }, + + async runSpeedTest(name, options = {}) { + if (!name || this.speedLoading[name]) return null; + const silent = !!options.silent; + this.speedLoading[name] = true; + try { + const res = await api('speed-test', { name }); + if (res.error) { + this.speedResults[name] = { ok: false, error: res.error }; + if (!silent) { + this.showMessage(res.error, 'error'); + } + return { ok: false, error: res.error }; + } + this.speedResults[name] = res; + if (!silent) { + const status = res.status ? ` (${res.status})` : ''; + this.showMessage(`Speed ${name}: ${this.formatLatency(res)}${status}`, 'success'); + } + return res; + } catch (e) { + const message = e && e.message ? e.message : 'Speed test failed'; + this.speedResults[name] = { ok: false, error: message }; + if (!silent) { + this.showMessage(message, 'error'); + } + return { ok: false, error: message }; + } finally { + this.speedLoading[name] = false; + } + }, + + async runClaudeSpeedTest(name, config) { + if (!name || this.claudeSpeedLoading[name]) return null; + const baseUrl = config && typeof config.baseUrl === 'string' ? config.baseUrl.trim() : ''; + this.claudeSpeedLoading[name] = true; + try { + if (!baseUrl) { + const res = { ok: false, error: 'Missing base URL' }; + this.claudeSpeedResults[name] = res; + return res; + } + const res = await api('speed-test', { url: baseUrl }); + if (res.error) { + this.claudeSpeedResults[name] = { ok: false, error: res.error }; + return { ok: false, error: res.error }; + } + this.claudeSpeedResults[name] = res; + return res; + } catch (e) { + const message = e && e.message ? e.message : 'Speed test failed'; + const res = { ok: false, error: message }; + this.claudeSpeedResults[name] = res; + return res; + } finally { + this.claudeSpeedLoading[name] = false; + } + }, + + async downloadClaudeDirectory() { + if (this.claudeDownloadLoading) return; + clearProgressResetTimer(this, '__claudeDownloadResetTimer'); + this.claudeDownloadLoading = true; + this.claudeDownloadProgress = 5; + this.claudeDownloadTimer = setInterval(() => { + if (this.claudeDownloadProgress < 90) { + this.claudeDownloadProgress += 5; + } + }, 400); + try { + const res = await api('download-claude-dir'); + if (res && res.error) { + this.showMessage(res.error, 'error'); + return; + } + if (!res || res.success !== true || !res.fileName) { + this.showMessage('备份失败', 'error'); + return; + } + this.claudeDownloadProgress = 100; + const downloadUrl = `/download/${encodeURIComponent(res.fileName)}`; + const link = document.createElement('a'); + link.href = downloadUrl; + link.download = res.fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + this.showMessage('备份成功,开始下载', 'success'); + } catch (e) { + this.showMessage('备份失败:' + (e && e.message ? e.message : '未知错误'), 'error'); + } finally { + if (this.claudeDownloadTimer) { + clearInterval(this.claudeDownloadTimer); + this.claudeDownloadTimer = null; + } + this.claudeDownloadLoading = false; + scheduleProgressResetTimer(this, '__claudeDownloadResetTimer', 'claudeDownloadProgress'); + } + }, + + async downloadCodexDirectory() { + if (this.codexDownloadLoading) return; + clearProgressResetTimer(this, '__codexDownloadResetTimer'); + this.codexDownloadLoading = true; + this.codexDownloadProgress = 5; + this.codexDownloadTimer = setInterval(() => { + if (this.codexDownloadProgress < 90) { + this.codexDownloadProgress += 5; + } + }, 400); + try { + const res = await api('download-codex-dir'); + if (res && res.error) { + this.showMessage(res.error, 'error'); + return; + } + if (!res || res.success !== true || !res.fileName) { + this.showMessage('备份失败', 'error'); + return; + } + this.codexDownloadProgress = 100; + const downloadUrl = `/download/${encodeURIComponent(res.fileName)}`; + const link = document.createElement('a'); + link.href = downloadUrl; + link.download = res.fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + this.showMessage('备份成功,开始下载', 'success'); + } catch (e) { + this.showMessage('备份失败:' + (e && e.message ? e.message : '未知错误'), 'error'); + } finally { + if (this.codexDownloadTimer) { + clearInterval(this.codexDownloadTimer); + this.codexDownloadTimer = null; + } + this.codexDownloadLoading = false; + scheduleProgressResetTimer(this, '__codexDownloadResetTimer', 'codexDownloadProgress'); + } + }, + + triggerClaudeImport() { + const input = this.$refs.claudeImportInput; + if (input) { + input.value = ''; + input.click(); + } + }, + + triggerCodexImport() { + const input = this.$refs.codexImportInput; + if (input) { + input.value = ''; + input.click(); + } + }, + + handleClaudeImportChange(event) { + const file = event && event.target && event.target.files ? event.target.files[0] : null; + if (file) { + void this.importBackupFile('claude', file); + } + }, + + handleCodexImportChange(event) { + const file = event && event.target && event.target.files ? event.target.files[0] : null; + if (file) { + void this.importBackupFile('codex', file); + } + }, + + async importBackupFile(type, file) { + const maxSize = 200 * 1024 * 1024; + const loadingKey = type === 'claude' ? 'claudeImportLoading' : 'codexImportLoading'; + if (this[loadingKey]) { + this.resetImportInput(type); + return; + } + if (file.size > maxSize) { + this.showMessage('备份文件过大,限制 200MB', 'error'); + this.resetImportInput(type); + return; + } + this[loadingKey] = true; + try { + const base64 = await this.readFileAsBase64(file); + const action = type === 'claude' ? 'restore-claude-dir' : 'restore-codex-dir'; + const res = await api(action, { + fileName: file.name || `${type}-backup.zip`, + fileBase64: base64 + }); + if (res && res.error) { + this.showMessage(res.error, 'error'); + return; + } + const backupTip = res && res.backupPath ? `,原配置已备份到临时文件:${res.backupPath}` : ''; + this.showMessage(`导入成功${backupTip}`, 'success'); + try { + if (type === 'claude') { + await this.refreshClaudeSelectionFromSettings({ silent: true }); + } else { + await this.loadAll(); + } + } catch (_) { + this.showMessage('导入已完成,但界面刷新失败,请手动刷新', 'error'); + } + } catch (e) { + this.showMessage('导入失败:' + (e && e.message ? e.message : '未知错误'), 'error'); + } finally { + this[loadingKey] = false; + this.resetImportInput(type); + } + }, + + readFileAsBase64(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result; + if (result instanceof ArrayBuffer) { + resolve(this.arrayBufferToBase64(result)); + return; + } + if (typeof result === 'string') { + const idx = result.indexOf('base64,'); + resolve(idx >= 0 ? result.slice(idx + 7) : result); + return; + } + reject(new Error('不支持的文件读取结果')); + }; + reader.onerror = () => reject(new Error('读取文件失败')); + reader.readAsArrayBuffer(file); + }); + }, + + arrayBufferToBase64(buffer) { + const bytes = new Uint8Array(buffer); + const chunkSize = 0x8000; + let binary = ''; + for (let i = 0; i < bytes.byteLength; i += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize)); + } + return btoa(binary); + }, + + resetImportInput(type) { + const refName = type === 'claude' ? 'claudeImportInput' : 'codexImportInput'; + const el = this.$refs[refName]; + if (el) { + el.value = ''; + } + }, + + async loadCodexAuthProfiles(options = {}) { + const silent = !!options.silent; + try { + const res = await api('list-auth-profiles'); + if (res && res.error) { + if (!silent) { + this.showMessage(res.error, 'error'); + } + return; + } + const list = Array.isArray(res && res.profiles) ? res.profiles : []; + this.codexAuthProfiles = list.sort((a, b) => { + if (!!a.current !== !!b.current) { + return a.current ? -1 : 1; + } + return String(a.name || '').localeCompare(String(b.name || '')); + }); + } catch (e) { + if (!silent) { + this.showMessage('读取认证列表失败', 'error'); + } + } + }, + + showMessage(text, type) { + if (this._messageTimer) { + clearTimeout(this._messageTimer); + } + this.message = text; + this.messageType = type || 'info'; + this._messageTimer = setTimeout(() => { + this.message = ''; + this._messageTimer = null; + }, 3000); + } + }; +} diff --git a/web-ui/modules/app.methods.session-actions.mjs b/web-ui/modules/app.methods.session-actions.mjs new file mode 100644 index 0000000..97be8c2 --- /dev/null +++ b/web-ui/modules/app.methods.session-actions.mjs @@ -0,0 +1,457 @@ +export function createSessionActionMethods(options = {}) { + const { + api, + apiBase + } = options; + + return { + getSessionStandaloneContext() { + try { + const url = new URL(window.location.href); + if (url.pathname !== '/session') { + return { requested: false, params: null, error: '' }; + } + + const source = (url.searchParams.get('source') || '').trim().toLowerCase(); + const sessionId = (url.searchParams.get('sessionId') || url.searchParams.get('id') || '').trim(); + const filePath = (url.searchParams.get('filePath') || url.searchParams.get('path') || '').trim(); + let error = ''; + if (!source) { + error = '缺少 source 参数'; + } else if (source !== 'codex' && source !== 'claude') { + error = 'source 仅支持 codex 或 claude'; + } + if (!sessionId && !filePath) { + error = error ? `${error},还缺少 sessionId 或 filePath` : '缺少 sessionId 或 filePath 参数'; + } + + if (error) { + return { requested: true, params: null, error }; + } + + return { + requested: true, + params: { + source, + sessionId, + filePath + }, + error: '' + }; + } catch (_) { + return { requested: false, params: null, error: '' }; + } + }, + + initSessionStandalone() { + const context = this.getSessionStandaloneContext(); + if (!context.requested) return; + + this.sessionStandalone = true; + this.mainTab = 'sessions'; + this.prepareSessionTabRender(); + + if (context.error || !context.params) { + this.sessionStandaloneError = `会话链接参数不完整:${context.error || '参数解析失败'}`; + return; + } + + const sourceLabel = context.params.source === 'codex' ? 'Codex' : 'Claude Code'; + this.activeSession = { + source: context.params.source, + sourceLabel, + sessionId: context.params.sessionId, + filePath: context.params.filePath, + title: context.params.sessionId || context.params.filePath || '会话' + }; + this.activeSessionMessages = []; + this.activeSessionDetailError = ''; + this.activeSessionDetailClipped = false; + this.cancelSessionTimelineSync(); + this.sessionTimelineActiveKey = ''; + this.clearSessionTimelineRefs(); + this.sessionStandaloneError = ''; + this.sessionStandaloneText = ''; + this.sessionStandaloneTitle = this.activeSession.title || '会话'; + this.sessionStandaloneSourceLabel = sourceLabel; + this.loadSessionStandalonePlain(); + }, + + buildSessionStandaloneUrl(session) { + if (!session) return ''; + const source = typeof session.source === 'string' ? session.source.trim().toLowerCase() : ''; + if (!source || (source !== 'codex' && source !== 'claude')) return ''; + const sessionId = typeof session.sessionId === 'string' ? session.sessionId.trim() : ''; + const filePath = typeof session.filePath === 'string' ? session.filePath.trim() : ''; + if (!sessionId && !filePath) return ''; + const origin = window.location.origin && window.location.origin !== 'null' + ? window.location.origin + : (typeof apiBase === 'string' ? apiBase.trim() : ''); + if (!origin) return ''; + const params = new URLSearchParams(); + params.set('source', source); + if (sessionId) params.set('sessionId', sessionId); + if (filePath) params.set('filePath', filePath); + return `${origin}/session?${params.toString()}`; + }, + + openSessionStandalone(session) { + const url = this.buildSessionStandaloneUrl(session); + if (!url) { + this.showMessage('无法生成链接', 'error'); + return; + } + window.open(url, '_blank', 'noopener'); + }, + + getSessionExportKey(session) { + return `${session.source || 'unknown'}:${session.sessionId || ''}:${session.filePath || ''}`; + }, + + isResumeCommandAvailable(session) { + if (!session) return false; + const source = String(session.source || '').trim().toLowerCase(); + const sessionId = typeof session.sessionId === 'string' ? session.sessionId.trim() : ''; + return source === 'codex' && !!sessionId; + }, + + isCloneAvailable(session) { + if (!session) return false; + const source = String(session.source || '').trim().toLowerCase(); + const sessionId = typeof session.sessionId === 'string' ? session.sessionId.trim() : ''; + const filePath = typeof session.filePath === 'string' ? session.filePath.trim() : ''; + return source === 'codex' && (!!sessionId || !!filePath); + }, + + isDeleteAvailable(session) { + if (!session) return false; + const source = String(session.source || '').trim().toLowerCase(); + if (source !== 'codex' && source !== 'claude') return false; + const sessionId = typeof session.sessionId === 'string' ? session.sessionId.trim() : ''; + const filePath = typeof session.filePath === 'string' ? session.filePath.trim() : ''; + return !!sessionId || !!filePath; + }, + + buildResumeCommand(session) { + const sessionId = session && session.sessionId ? String(session.sessionId).trim() : ''; + const arg = this.quoteResumeArg(sessionId); + if (this.sessionResumeWithYolo) { + return `codex --yolo resume ${arg}`; + } + return `codex resume ${arg}`; + }, + + quoteShellArg(value) { + const text = typeof value === 'string' ? value : String(value || ''); + if (!text) return "''"; + if (/^[a-zA-Z0-9._-]+$/.test(text)) return text; + const escaped = text.replace(/'/g, "'\\''"); + return `'${escaped}'`; + }, + + quoteResumeArg(value) { + return this.quoteShellArg(value); + }, + + fallbackCopyText(text) { + let textarea = null; + try { + textarea = document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.top = '-9999px'; + textarea.style.left = '-9999px'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + textarea.setSelectionRange(0, textarea.value.length); + return document.execCommand('copy'); + } catch (_) { + return false; + } finally { + if (textarea && textarea.parentNode) { + textarea.parentNode.removeChild(textarea); + } + } + }, + + copyAgentsContent() { + const text = typeof this.agentsContent === 'string' ? this.agentsContent : ''; + if (!text) { + this.showMessage('没有可复制内容', 'info'); + return; + } + const ok = this.fallbackCopyText(text); + if (ok) { + this.showMessage('已复制', 'success'); + return; + } + this.showMessage('复制失败', 'error'); + }, + + exportAgentsContent() { + const text = typeof this.agentsContent === 'string' ? this.agentsContent : ''; + if (!text) { + this.showMessage('没有可导出内容', 'info'); + return; + } + const now = new Date(); + const year = String(now.getFullYear()); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + const hour = String(now.getHours()).padStart(2, '0'); + const minute = String(now.getMinutes()).padStart(2, '0'); + const second = String(now.getSeconds()).padStart(2, '0'); + const fileName = `agent-${year}${month}${day}-${hour}${minute}${second}.txt`; + this.downloadTextFile(fileName, text, 'text/plain;charset=utf-8'); + this.showMessage(`已导出 ${fileName}`, 'success'); + }, + + async copyInstallCommand(cmd) { + const text = typeof cmd === 'string' ? cmd.trim() : ''; + if (!text) { + this.showMessage('没有可复制内容', 'info'); + return; + } + try { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text); + this.showMessage('已复制命令', 'success'); + return; + } + } catch (_) {} + const ok = this.fallbackCopyText(text); + if (ok) { + this.showMessage('已复制命令', 'success'); + return; + } + this.showMessage('复制失败', 'error'); + }, + + async copyResumeCommand(session) { + if (!this.isResumeCommandAvailable(session)) { + this.showMessage('不支持此操作', 'error'); + return; + } + const command = this.buildResumeCommand(session); + const ok = this.fallbackCopyText(command); + if (ok) { + this.showMessage('已复制', 'success'); + return; + } + try { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(command); + this.showMessage('已复制', 'success'); + return; + } + } catch (_) {} + this.showMessage('复制失败', 'error'); + }, + + buildProviderShareCommand(payload) { + if (!payload || typeof payload !== 'object') return ''; + const name = typeof payload.name === 'string' ? payload.name.trim() : ''; + const baseUrl = typeof payload.baseUrl === 'string' ? payload.baseUrl.trim() : ''; + const apiKey = typeof payload.apiKey === 'string' ? payload.apiKey : ''; + const model = typeof payload.model === 'string' ? payload.model.trim() : ''; + if (!name || !baseUrl) return ''; + + const nameArg = this.quoteShellArg(name); + const urlArg = this.quoteShellArg(baseUrl); + const keyArg = apiKey ? this.quoteShellArg(apiKey) : ''; + const switchCmd = `codexmate switch ${nameArg}`; + const addCmd = apiKey + ? `codexmate add ${nameArg} ${urlArg} ${keyArg}` + : `codexmate add ${nameArg} ${urlArg}`; + const modelCmd = model ? ` && codexmate use ${this.quoteShellArg(model)}` : ''; + return `${addCmd} && ${switchCmd}${modelCmd}`; + }, + + buildClaudeShareCommand(payload) { + if (!payload || typeof payload !== 'object') return ''; + const baseUrl = typeof payload.baseUrl === 'string' ? payload.baseUrl.trim() : ''; + const apiKey = typeof payload.apiKey === 'string' ? payload.apiKey : ''; + const model = typeof payload.model === 'string' && payload.model.trim() + ? payload.model.trim() + : 'glm-4.7'; + if (!baseUrl || !apiKey) return ''; + const urlArg = this.quoteShellArg(baseUrl); + const keyArg = this.quoteShellArg(apiKey); + const modelArg = this.quoteShellArg(model); + return `codexmate claude ${urlArg} ${keyArg} ${modelArg}`; + }, + + async copyProviderShareCommand(provider) { + const name = provider && typeof provider.name === 'string' ? provider.name.trim() : ''; + if (!name) { + this.showMessage('参数无效', 'error'); + return; + } + if (!this.shouldAllowProviderShare(provider)) { + this.showMessage('本地入口不可分享', 'info'); + return; + } + if (this.providerShareLoading[name]) { + return; + } + this.providerShareLoading[name] = true; + try { + const res = await api('export-provider', { name }); + if (res && res.error) { + this.showMessage(res.error, 'error'); + return; + } + const command = this.buildProviderShareCommand(res && res.payload ? res.payload : null); + if (!command) { + this.showMessage('生成命令失败', 'error'); + return; + } + const ok = this.fallbackCopyText(command); + if (ok) { + this.showMessage('已复制', 'success'); + return; + } + try { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(command); + this.showMessage('已复制', 'success'); + return; + } + } catch (_) {} + this.showMessage('复制失败', 'error'); + } catch (_) { + this.showMessage('生成命令失败', 'error'); + } finally { + this.providerShareLoading[name] = false; + } + }, + + async copyClaudeShareCommand(name) { + const config = this.claudeConfigs[name]; + if (!config) { + this.showMessage('配置不存在', 'error'); + return; + } + if (this.claudeShareLoading[name]) return; + this.claudeShareLoading[name] = true; + try { + const res = await api('export-claude-share', { config }); + if (res && res.error) { + this.showMessage(res.error, 'error'); + return; + } + const command = this.buildClaudeShareCommand(res && res.payload ? res.payload : null); + if (!command) { + this.showMessage('生成命令失败', 'error'); + return; + } + const ok = this.fallbackCopyText(command); + if (ok) { + this.showMessage('已复制', 'success'); + return; + } + try { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(command); + this.showMessage('已复制', 'success'); + return; + } + } catch (_) {} + this.showMessage('复制失败', 'error'); + } catch (_) { + this.showMessage('生成命令失败', 'error'); + } finally { + this.claudeShareLoading[name] = false; + } + }, + + async cloneSession(session) { + if (!this.isCloneAvailable(session)) { + this.showMessage('不支持此操作', 'error'); + return; + } + const key = this.getSessionExportKey(session); + if (this.sessionCloning[key]) { + return; + } + this.sessionCloning[key] = true; + try { + const res = await api('clone-session', { + source: session.source, + sessionId: session.sessionId, + filePath: session.filePath + }); + if (res.error) { + this.showMessage(res.error, 'error'); + return; + } + + this.showMessage('操作成功', 'success'); + try { + await this.loadSessions(); + if (res.sessionId) { + const matched = this.sessionsList.find(item => item.source === 'codex' && item.sessionId === res.sessionId); + if (matched) { + await this.selectSession(matched); + } + } + } catch (_) { + // The clone already succeeded remotely; keep the success result. + } + } catch (_) { + this.showMessage('克隆失败', 'error'); + } finally { + this.sessionCloning[key] = false; + } + }, + + async deleteSession(session) { + if (!this.isDeleteAvailable(session)) { + this.showMessage('不支持此操作', 'error'); + return; + } + const key = this.getSessionExportKey(session); + if (this.sessionDeleting[key]) { + return; + } + this.sessionDeleting[key] = true; + try { + const res = await api('trash-session', { + source: session.source, + sessionId: session.sessionId, + filePath: session.filePath + }); + if (!res || res.error) { + this.showMessage((res && res.error) || '删除失败', 'error'); + return; + } + this.removeSessionPin(session); + this.invalidateSessionTrashRequests(); + this.showMessage('已移入回收站', 'success'); + if (this.sessionTrashLoadedOnce) { + this.prependSessionTrashItem(this.buildSessionTrashItemFromSession(session, res), { + totalCount: res && res.totalCount !== undefined ? res.totalCount : undefined + }); + } else { + this.sessionTrashTotalCount = this.normalizeSessionTrashTotalCount( + res && res.totalCount !== undefined + ? res.totalCount + : (this.normalizeSessionTrashTotalCount(this.sessionTrashTotalCount, this.sessionTrashItems) + 1), + this.sessionTrashItems + ); + } + try { + await this.removeSessionFromCurrentList(session); + } catch (_) { + // The delete already succeeded remotely; keep the success result. + } + } catch (_) { + this.showMessage('删除失败', 'error'); + } finally { + this.sessionDeleting[key] = false; + } + } + }; +} diff --git a/web-ui/modules/app.methods.session-browser.mjs b/web-ui/modules/app.methods.session-browser.mjs new file mode 100644 index 0000000..62c4e39 --- /dev/null +++ b/web-ui/modules/app.methods.session-browser.mjs @@ -0,0 +1,435 @@ +import { + buildSessionFilterCacheState, + isSessionQueryEnabled, + normalizeSessionMessageRole, + normalizeSessionPathFilter +} from '../logic.mjs'; + +export function createSessionBrowserMethods(options = {}) { + const { + api, + loadSessionsHelper, + loadActiveSessionDetailHelper + } = options; + + return { + normalizeSessionPathValue(value) { + return normalizeSessionPathFilter(value); + }, + + mergeSessionPathOptions(baseList = [], incomingList = []) { + const merged = []; + const seen = new Set(); + const append = (items) => { + if (!Array.isArray(items)) return; + for (const item of items) { + const value = this.normalizeSessionPathValue(item); + if (!value) continue; + const key = value.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + merged.push(value); + } + }; + + append(baseList); + append(incomingList); + return merged; + }, + + extractPathOptionsFromSessions(sessions) { + const paths = []; + if (!Array.isArray(sessions)) { + return paths; + } + + const seen = new Set(); + for (const session of sessions) { + const value = this.normalizeSessionPathValue(session && session.cwd ? session.cwd : ''); + if (!value) continue; + const key = value.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + paths.push(value); + } + return paths; + }, + + syncSessionPathOptionsForSource(source, nextOptions, mergeWithExisting = false) { + const targetSource = source === 'claude' ? 'claude' : (source === 'all' ? 'all' : 'codex'); + const current = Array.isArray(this.sessionPathOptionsMap[targetSource]) + ? this.sessionPathOptionsMap[targetSource] + : []; + const merged = mergeWithExisting + ? this.mergeSessionPathOptions(current, nextOptions) + : this.mergeSessionPathOptions([], nextOptions); + this.sessionPathOptionsMap = { + ...this.sessionPathOptionsMap, + [targetSource]: merged + }; + this.refreshSessionPathOptions(targetSource); + }, + + refreshSessionPathOptions(source) { + const targetSource = source === 'claude' ? 'claude' : (source === 'all' ? 'all' : 'codex'); + const base = Array.isArray(this.sessionPathOptionsMap[targetSource]) + ? [...this.sessionPathOptionsMap[targetSource]] + : []; + const selected = this.normalizeSessionPathValue(this.sessionPathFilter); + if (selected && !base.some(item => item.toLowerCase() === selected.toLowerCase())) { + base.unshift(selected); + } + if (targetSource === this.sessionFilterSource) { + this.sessionPathOptions = base; + } + }, + + async loadSessionPathOptions(options = {}) { + const source = options.source === 'claude' ? 'claude' : (options.source === 'all' ? 'all' : 'codex'); + const forceRefresh = !!options.forceRefresh; + const loaded = !!this.sessionPathOptionsLoadedMap[source]; + if (!forceRefresh && loaded) { + if (source === this.sessionFilterSource) { + this.sessionPathOptionsLoading = false; + } + return; + } + + const nextSeqMap = { + ...(this.sessionPathRequestSeqMap || {}) + }; + const requestSeq = (Number(nextSeqMap[source]) || 0) + 1; + nextSeqMap[source] = requestSeq; + this.sessionPathRequestSeqMap = nextSeqMap; + if (source === this.sessionFilterSource) { + this.sessionPathOptionsLoading = true; + } + try { + const res = await api('list-session-paths', { + source, + limit: 500, + forceRefresh + }); + if (requestSeq !== Number(((this.sessionPathRequestSeqMap || {})[source]) || 0)) { + return; + } + if (res && !res.error && Array.isArray(res.paths)) { + this.syncSessionPathOptionsForSource(source, res.paths, true); + this.sessionPathOptionsLoadedMap = { + ...this.sessionPathOptionsLoadedMap, + [source]: true + }; + } + } catch (_) { + // 路径补全失败不影响会话主流程 + } finally { + if ( + source === this.sessionFilterSource + && requestSeq === Number(((this.sessionPathRequestSeqMap || {})[source]) || 0) + ) { + this.sessionPathOptionsLoading = false; + } + } + }, + + onSessionResumeYoloChange() { + const value = this.sessionResumeWithYolo ? '1' : '0'; + localStorage.setItem('codexmateSessionResumeYolo', value); + }, + + restoreSessionFilterCache() { + const sourceCache = localStorage.getItem('codexmateSessionFilterSource'); + const pathCache = localStorage.getItem('codexmateSessionPathFilter'); + const cached = buildSessionFilterCacheState(sourceCache, pathCache); + this.sessionFilterSource = cached.source; + this.sessionPathFilter = cached.pathFilter; + this.refreshSessionPathOptions(this.sessionFilterSource); + }, + + persistSessionFilterCache() { + const cached = buildSessionFilterCacheState(this.sessionFilterSource, this.sessionPathFilter); + localStorage.setItem('codexmateSessionFilterSource', cached.source); + if (cached.pathFilter) { + localStorage.setItem('codexmateSessionPathFilter', cached.pathFilter); + } else { + localStorage.removeItem('codexmateSessionPathFilter'); + } + }, + + normalizeSessionPinnedMap(raw) { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return {}; + } + const next = {}; + for (const [key, value] of Object.entries(raw)) { + if (!key) continue; + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric <= 0) continue; + next[key] = Math.floor(numeric); + } + return next; + }, + + restoreSessionPinnedMap() { + const cached = localStorage.getItem('codexmateSessionPinnedMap'); + if (!cached) { + this.sessionPinnedMap = {}; + return; + } + try { + const parsed = JSON.parse(cached); + this.sessionPinnedMap = this.normalizeSessionPinnedMap(parsed); + } catch (_) { + this.sessionPinnedMap = {}; + localStorage.removeItem('codexmateSessionPinnedMap'); + } + }, + + persistSessionPinnedMap() { + const payload = (this.sessionPinnedMap && typeof this.sessionPinnedMap === 'object') + ? this.sessionPinnedMap + : {}; + localStorage.setItem('codexmateSessionPinnedMap', JSON.stringify(payload)); + }, + + shouldPruneSessionPinnedMap(sessions = this.sessionsList) { + if (!Array.isArray(sessions) || sessions.length === 0) { + return false; + } + if (this.sessionFilterSource !== 'all') { + return false; + } + if (this.sessionPathFilter) { + return false; + } + if (this.sessionQuery && isSessionQueryEnabled(this.sessionFilterSource)) { + return false; + } + if (this.sessionRoleFilter && this.sessionRoleFilter !== 'all') { + return false; + } + if (this.sessionTimePreset && this.sessionTimePreset !== 'all') { + return false; + } + return true; + }, + + pruneSessionPinnedMap(sessions = this.sessionsList) { + const current = (this.sessionPinnedMap && typeof this.sessionPinnedMap === 'object') + ? this.sessionPinnedMap + : {}; + const list = Array.isArray(sessions) ? sessions : []; + if (Object.keys(current).length === 0 || !this.shouldPruneSessionPinnedMap(list)) { + return; + } + const validKeys = new Set(list.map((session) => this.getSessionExportKey(session)).filter(Boolean)); + const next = {}; + let changed = false; + for (const [key, value] of Object.entries(current)) { + if (!validKeys.has(key)) { + changed = true; + continue; + } + next[key] = value; + } + if (!changed) { + return; + } + this.sessionPinnedMap = next; + this.persistSessionPinnedMap(); + }, + + getSessionPinTimestamp(session) { + if (!session) return 0; + const key = this.getSessionExportKey(session); + if (!key) return 0; + const raw = this.sessionPinnedMap && this.sessionPinnedMap[key]; + const numeric = Number(raw); + return Number.isFinite(numeric) && numeric > 0 ? Math.floor(numeric) : 0; + }, + + isSessionPinned(session) { + return this.getSessionPinTimestamp(session) > 0; + }, + + toggleSessionPin(session) { + if (!session) return; + const key = this.getSessionExportKey(session); + if (!key) return; + const current = (this.sessionPinnedMap && typeof this.sessionPinnedMap === 'object') + ? this.sessionPinnedMap + : {}; + const next = { ...current }; + if (next[key]) { + delete next[key]; + } else { + next[key] = Date.now(); + } + this.sessionPinnedMap = next; + this.persistSessionPinnedMap(); + }, + + removeSessionPin(session) { + if (!session) return; + const key = this.getSessionExportKey(session); + if (!key) return; + const current = (this.sessionPinnedMap && typeof this.sessionPinnedMap === 'object') + ? this.sessionPinnedMap + : {}; + if (!current[key]) return; + const next = { ...current }; + delete next[key]; + this.sessionPinnedMap = next; + this.persistSessionPinnedMap(); + }, + + async onSessionSourceChange() { + this.refreshSessionPathOptions(this.sessionFilterSource); + this.persistSessionFilterCache(); + await this.loadSessions(); + }, + + async onSessionPathFilterChange() { + this.persistSessionFilterCache(); + await this.loadSessions(); + }, + + async onSessionFilterChange() { + await this.loadSessions(); + }, + + async clearSessionFilters() { + this.sessionFilterSource = 'all'; + this.sessionPathFilter = ''; + this.sessionQuery = ''; + this.sessionRoleFilter = 'all'; + this.sessionTimePreset = 'all'; + this.persistSessionFilterCache(); + await this.onSessionSourceChange(); + }, + + normalizeSessionMessage(message) { + const fallback = { + role: 'assistant', + normalizedRole: 'assistant', + roleLabel: 'Assistant', + text: typeof message === 'string' ? message : '', + timestamp: '' + }; + const safeMessage = message && typeof message === 'object' ? message : fallback; + const normalizedRole = normalizeSessionMessageRole( + safeMessage.normalizedRole || safeMessage.role + ); + const roleLabel = normalizedRole === 'user' + ? 'User' + : (normalizedRole === 'system' ? 'System' : 'Assistant'); + return { + ...safeMessage, + role: normalizedRole, + normalizedRole, + roleLabel + }; + }, + + getRecordKey(message) { + if (!message || !Number.isInteger(message.recordLineIndex) || message.recordLineIndex < 0) { + return ''; + } + return String(message.recordLineIndex); + }, + + getRecordRenderKey(message, idx) { + const recordKey = this.getRecordKey(message); + if (recordKey) { + return `record-${recordKey}`; + } + return `record-fallback-${idx}-${message && message.timestamp ? message.timestamp : ''}`; + }, + + syncActiveSessionMessageCount(messageCount) { + if (!Number.isFinite(messageCount) || messageCount < 0) return; + if (this.activeSession) { + this.activeSession.messageCount = messageCount; + } + const activeKey = this.activeSession ? this.getSessionExportKey(this.activeSession) : ''; + if (!activeKey) return; + const matched = this.sessionsList.find(item => this.getSessionExportKey(item) === activeKey); + if (matched) { + matched.messageCount = messageCount; + } + }, + + async loadSessions() { + const result = await loadSessionsHelper.call(this, api); + this.pruneSessionPinnedMap(this.sessionsList); + return result; + }, + + async selectSession(session) { + if (!session) return; + if (this.activeSession && this.getSessionExportKey(this.activeSession) === this.getSessionExportKey(session)) return; + this.activeSession = session; + this.activeSessionMessages = []; + this.resetSessionDetailPagination(); + this.resetSessionPreviewMessageRender(); + this.activeSessionDetailError = ''; + this.activeSessionDetailClipped = false; + this.cancelSessionTimelineSync(); + this.sessionTimelineActiveKey = ''; + this.clearSessionTimelineRefs(); + await this.loadActiveSessionDetail(); + }, + + async loadSessionStandalonePlain() { + if (!this.activeSession) { + this.sessionStandaloneRequestSeq += 1; + this.sessionStandaloneLoading = false; + this.sessionStandaloneText = ''; + this.sessionStandaloneTitle = '会话'; + this.sessionStandaloneSourceLabel = ''; + this.sessionStandaloneError = ''; + return; + } + + const requestSeq = ++this.sessionStandaloneRequestSeq; + const sessionSnapshot = this.activeSession; + this.sessionStandaloneLoading = true; + this.sessionStandaloneError = ''; + try { + const res = await api('session-plain', { + source: sessionSnapshot.source, + sessionId: sessionSnapshot.sessionId, + filePath: sessionSnapshot.filePath + }); + + if (requestSeq !== this.sessionStandaloneRequestSeq) { + return; + } + + if (res.error) { + this.sessionStandaloneText = ''; + this.sessionStandaloneError = res.error; + return; + } + + this.sessionStandaloneSourceLabel = res.sourceLabel || sessionSnapshot.sourceLabel || ''; + this.sessionStandaloneTitle = res.sessionId || sessionSnapshot.title || '会话'; + this.sessionStandaloneText = typeof res.text === 'string' ? res.text : ''; + } catch (e) { + if (requestSeq !== this.sessionStandaloneRequestSeq) { + return; + } + this.sessionStandaloneText = ''; + this.sessionStandaloneError = '加载会话内容失败: ' + e.message; + } finally { + if (requestSeq === this.sessionStandaloneRequestSeq) { + this.sessionStandaloneLoading = false; + } + } + }, + + async loadActiveSessionDetail(options = {}) { + return loadActiveSessionDetailHelper.call(this, api, options); + } + }; +} diff --git a/web-ui/modules/app.methods.session-timeline.mjs b/web-ui/modules/app.methods.session-timeline.mjs new file mode 100644 index 0000000..1a422ab --- /dev/null +++ b/web-ui/modules/app.methods.session-timeline.mjs @@ -0,0 +1,441 @@ +import { shouldForceCompactLayoutMode } from '../logic.mjs'; + +export function createSessionTimelineMethods() { + const getSessionPreviewHeaderElement = (context, scrollEl = context.sessionPreviewScrollEl || context.$refs.sessionPreviewScroll) => { + const container = context.sessionPreviewContainerEl || context.$refs.sessionPreviewContainer; + return context.sessionPreviewHeaderEl + || (scrollEl && typeof scrollEl.querySelector === 'function' + ? scrollEl.querySelector('.session-preview-header') + : null) + || (container && typeof container.querySelector === 'function' + ? container.querySelector('.session-preview-header') + : null) + || null; + }; + + const getSessionPreviewHeaderOffset = (context, scrollEl = context.sessionPreviewScrollEl || context.$refs.sessionPreviewScroll) => { + const header = getSessionPreviewHeaderElement(context, scrollEl); + const headerHeight = header && typeof header.getBoundingClientRect === 'function' + ? Math.ceil(header.getBoundingClientRect().height) + : 0; + return headerHeight > 0 ? (headerHeight + 12) : 72; + }; + + return { + setSessionPreviewContainerRef(el) { + this.sessionPreviewContainerEl = el || null; + this.updateSessionTimelineOffset(); + }, + disconnectSessionPreviewHeaderResizeObserver() { + if (!this.sessionPreviewHeaderResizeObserver) return; + this.sessionPreviewHeaderResizeObserver.disconnect(); + this.sessionPreviewHeaderResizeObserver = null; + }, + observeSessionPreviewHeaderResize() { + this.disconnectSessionPreviewHeaderResizeObserver(); + if (!this.sessionPreviewHeaderEl || typeof ResizeObserver !== 'function') return; + this.sessionPreviewHeaderResizeObserver = new ResizeObserver(() => { + this.updateSessionTimelineOffset(); + this.invalidateSessionTimelineMeasurementCache(); + if ( + this.sessionTimelineEnabled + && this.mainTab === 'sessions' + && this.getMainTabForNav() === 'sessions' + && this.sessionPreviewRenderEnabled + && this.sessionTimelineNodes.length + ) { + this.scheduleSessionTimelineSync(); + } + }); + this.sessionPreviewHeaderResizeObserver.observe(this.sessionPreviewHeaderEl); + }, + setSessionPreviewHeaderRef(el) { + this.disconnectSessionPreviewHeaderResizeObserver(); + this.sessionPreviewHeaderEl = el || null; + this.observeSessionPreviewHeaderResize(); + this.updateSessionTimelineOffset(); + }, + setSessionPreviewScrollRef(el) { + this.sessionPreviewScrollEl = el || null; + this.invalidateSessionTimelineMeasurementCache(); + const shouldSync = !!( + this.sessionPreviewScrollEl + && this.mainTab === 'sessions' + && this.getMainTabForNav() === 'sessions' + && this.sessionPreviewRenderEnabled + && this.sessionTimelineNodes.length + ); + if (!shouldSync) { + this.cancelSessionTimelineSync(); + this.updateSessionTimelineOffset(); + return; + } + const boundScrollEl = this.sessionPreviewScrollEl; + this.$nextTick(() => { + if (this.sessionPreviewScrollEl !== boundScrollEl) return; + if (this.mainTab !== 'sessions' || !this.sessionPreviewRenderEnabled) return; + if (!this.sessionTimelineNodes.length) return; + this.scheduleSessionTimelineSync(); + }); + this.updateSessionTimelineOffset(); + }, + clearSessionTimelineRefs() { + this.sessionMessageRefMap = Object.create(null); + this.sessionMessageRefBinderMap = Object.create(null); + this.sessionTimelineLastAnchorY = 0; + this.sessionTimelineLastDirection = 0; + this.invalidateSessionTimelineMeasurementCache(true); + }, + ensureSessionTimelineMeasurementCache() { + if (this.__sessionTimelineMeasurementCache) { + return this.__sessionTimelineMeasurementCache; + } + this.__sessionTimelineMeasurementCache = { + offsetByKey: Object.create(null), + dirty: true + }; + return this.__sessionTimelineMeasurementCache; + }, + invalidateSessionTimelineMeasurementCache(resetOffset = false) { + const cache = this.ensureSessionTimelineMeasurementCache(); + if (resetOffset) { + cache.offsetByKey = Object.create(null); + } + cache.dirty = true; + }, + refreshSessionTimelineMeasurementCache(nodes = null) { + const cache = this.ensureSessionTimelineMeasurementCache(); + const nodeList = Array.isArray(nodes) ? nodes : (Array.isArray(this.sessionTimelineNodes) ? this.sessionTimelineNodes : []); + if (!nodeList.length) { + cache.offsetByKey = Object.create(null); + cache.dirty = false; + return cache.offsetByKey; + } + const scrollEl = this.sessionPreviewScrollEl || this.$refs.sessionPreviewScroll; + const scrollRect = scrollEl && typeof scrollEl.getBoundingClientRect === 'function' + ? scrollEl.getBoundingClientRect() + : null; + const scrollTop = scrollEl ? Number(scrollEl.scrollTop || 0) : 0; + const nextOffsetByKey = Object.create(null); + for (const node of nodeList) { + if (!node || !node.key) continue; + const messageEl = this.sessionMessageRefMap[node.key]; + if (!messageEl) continue; + let top = Number.NaN; + if ( + scrollRect + && typeof messageEl.getBoundingClientRect === 'function' + ) { + const messageRect = messageEl.getBoundingClientRect(); + top = scrollTop + (messageRect.top - scrollRect.top); + } else { + top = Number(messageEl.offsetTop || 0); + } + if (!Number.isFinite(top)) continue; + nextOffsetByKey[node.key] = top; + } + cache.offsetByKey = nextOffsetByKey; + cache.dirty = false; + return cache.offsetByKey; + }, + getCachedSessionTimelineMeasuredNodes(nodes) { + const nodeList = Array.isArray(nodes) ? nodes : []; + if (!nodeList.length) { + return []; + } + const cache = this.ensureSessionTimelineMeasurementCache(); + if (cache.dirty) { + this.refreshSessionTimelineMeasurementCache(nodeList); + } + const offsetByKey = cache.offsetByKey || Object.create(null); + const measuredNodes = []; + for (const node of nodeList) { + if (!node || !node.key) continue; + const top = Number(offsetByKey[node.key]); + if (!Number.isFinite(top)) continue; + measuredNodes.push({ + key: node.key, + top + }); + } + if (measuredNodes.length >= nodeList.length) { + return measuredNodes; + } + const refreshedOffsetByKey = this.refreshSessionTimelineMeasurementCache(nodeList); + const refreshedNodes = []; + for (const node of nodeList) { + if (!node || !node.key) continue; + const top = Number(refreshedOffsetByKey[node.key]); + if (!Number.isFinite(top)) continue; + refreshedNodes.push({ + key: node.key, + top + }); + } + return refreshedNodes; + }, + getSessionMessageRefBinder(messageKey) { + if (!this.isSessionTimelineNodeKey(messageKey)) return null; + const current = this.sessionMessageRefBinderMap[messageKey]; + if (!current || current.ticket !== this.sessionTabRenderTicket) { + const ticket = this.sessionTabRenderTicket; + this.sessionMessageRefBinderMap[messageKey] = { + ticket, + bind: (el) => { + this.bindSessionMessageRef(messageKey, el, ticket); + } + }; + } + return this.sessionMessageRefBinderMap[messageKey].bind; + }, + updateSessionTimelineOffset() { + const container = this.sessionPreviewContainerEl || this.$refs.sessionPreviewContainer; + if (!container || !container.style) return; + const offset = getSessionPreviewHeaderOffset(this); + container.style.setProperty('--session-preview-header-offset', `${offset}px`); + }, + bindSessionMessageRef(messageKey, el, ticket = this.sessionTabRenderTicket) { + if (!this.sessionTimelineEnabled) return; + if (!messageKey) return; + if (ticket !== this.sessionTabRenderTicket) return; + if (el) { + if (!this.isSessionTimelineNodeKey(messageKey)) return; + if (this.sessionMessageRefMap[messageKey] === el) return; + this.sessionMessageRefMap[messageKey] = el; + this.invalidateSessionTimelineMeasurementCache(); + } else { + if (!this.sessionMessageRefMap[messageKey]) return; + delete this.sessionMessageRefMap[messageKey]; + this.invalidateSessionTimelineMeasurementCache(); + } + }, + isSessionTimelineNodeKey(messageKey) { + if (!messageKey) return false; + return !!(this.sessionTimelineNodeKeyMap && this.sessionTimelineNodeKeyMap[messageKey]); + }, + pruneSessionMessageRefs() { + const nodeKeyMap = this.sessionTimelineNodeKeyMap || Object.create(null); + let removed = false; + for (const key of Object.keys(this.sessionMessageRefMap)) { + if (nodeKeyMap[key]) continue; + delete this.sessionMessageRefMap[key]; + removed = true; + } + for (const key of Object.keys(this.sessionMessageRefBinderMap)) { + if (nodeKeyMap[key]) continue; + delete this.sessionMessageRefBinderMap[key]; + } + if (removed) { + this.invalidateSessionTimelineMeasurementCache(); + } + }, + cancelSessionTimelineSync() { + if (!this.sessionTimelineRafId) return; + if (typeof cancelAnimationFrame === 'function') { + cancelAnimationFrame(this.sessionTimelineRafId); + } + this.sessionTimelineRafId = 0; + }, + scheduleSessionTimelineSync() { + if (this.sessionTimelineRafId) return; + if (typeof requestAnimationFrame === 'function') { + this.sessionTimelineRafId = requestAnimationFrame(() => { + this.sessionTimelineRafId = 0; + this.syncSessionTimelineActiveFromScroll(); + }); + return; + } + this.syncSessionTimelineActiveFromScroll(); + }, + onSessionPreviewScroll() { + if ( + !this.sessionTimelineEnabled + || this.mainTab !== 'sessions' + || this.getMainTabForNav() !== 'sessions' + || !this.sessionPreviewRenderEnabled + ) return; + if (!this.sessionTimelineNodes.length) return; + const scrollEl = this.sessionPreviewScrollEl || this.$refs.sessionPreviewScroll; + if (!scrollEl) return; + const now = Date.now(); + const currentTop = Number(scrollEl.scrollTop || 0); + const delta = Math.abs(currentTop - Number(this.sessionTimelineLastScrollTop || 0)); + const elapsed = now - Number(this.sessionTimelineLastSyncAt || 0); + if (delta < 48 && elapsed < 120) { + return; + } + this.sessionTimelineLastScrollTop = currentTop; + this.sessionTimelineLastSyncAt = now; + this.scheduleSessionTimelineSync(); + }, + onWindowResize() { + this.updateCompactLayoutMode(); + if ( + !this.sessionTimelineEnabled + || this.mainTab !== 'sessions' + || this.getMainTabForNav() !== 'sessions' + || !this.sessionPreviewRenderEnabled + ) { + return; + } + if (!this.sessionTimelineNodes.length) return; + this.updateSessionTimelineOffset(); + this.invalidateSessionTimelineMeasurementCache(); + this.scheduleSessionTimelineSync(); + }, + shouldForceCompactLayout() { + if (typeof window === 'undefined' || typeof navigator === 'undefined') { + return false; + } + const doc = typeof document !== 'undefined' ? document : null; + const viewportWidth = Math.max( + 0, + Number(window.innerWidth || 0), + Number(doc && doc.documentElement ? doc.documentElement.clientWidth : 0) + ); + const screenWidth = Number(window.screen && window.screen.width ? window.screen.width : 0); + const screenHeight = Number(window.screen && window.screen.height ? window.screen.height : 0); + const shortEdge = screenWidth > 0 && screenHeight > 0 + ? Math.min(screenWidth, screenHeight) + : 0; + const touchPoints = Number(navigator.maxTouchPoints || 0); + const userAgent = String(navigator.userAgent || ''); + const isMobileUa = /(Android|iPhone|iPad|iPod|Mobile)/i.test(userAgent); + let coarsePointer = false; + let noHover = false; + try { + coarsePointer = !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches); + } catch (_) {} + try { + noHover = !!(window.matchMedia && window.matchMedia('(hover: none)').matches); + } catch (_) {} + return shouldForceCompactLayoutMode({ + viewportWidth, + screenWidth, + screenHeight, + shortEdge, + maxTouchPoints: touchPoints, + userAgent, + isMobileUa, + coarsePointer, + noHover + }); + }, + applyCompactLayoutClass(enabled) { + if (typeof document === 'undefined' || !document.body) { + return; + } + document.body.classList.toggle('force-compact', !!enabled); + }, + updateCompactLayoutMode() { + const enabled = this.shouldForceCompactLayout(); + this.forceCompactLayout = enabled; + this.applyCompactLayoutClass(enabled); + }, + syncSessionTimelineActiveFromScroll() { + if ( + !this.sessionTimelineEnabled + || this.mainTab !== 'sessions' + || this.getMainTabForNav() !== 'sessions' + || !this.sessionPreviewRenderEnabled + ) { + if (this.sessionTimelineActiveKey) { + this.sessionTimelineActiveKey = ''; + } + return; + } + const nodes = Array.isArray(this.sessionTimelineNodes) ? this.sessionTimelineNodes : []; + if (!nodes.length) { + if (this.sessionTimelineActiveKey) { + this.sessionTimelineActiveKey = ''; + } + return; + } + this.pruneSessionMessageRefs(); + const scrollEl = this.sessionPreviewScrollEl || this.$refs.sessionPreviewScroll; + if (!scrollEl) { + if (!this.isSessionTimelineNodeKey(this.sessionTimelineActiveKey)) { + const fallbackKey = nodes[0].key; + if (this.sessionTimelineActiveKey !== fallbackKey) { + this.sessionTimelineActiveKey = fallbackKey; + } + } + return; + } + const stickyOffset = getSessionPreviewHeaderOffset(this, scrollEl); + const rawAnchorY = Number(scrollEl.scrollTop || 0) + stickyOffset; + const previousAnchorY = Number(this.sessionTimelineLastAnchorY || 0); + let direction = rawAnchorY - previousAnchorY; + if (Math.abs(direction) < 1) { + direction = Number(this.sessionTimelineLastDirection || 0); + } else { + this.sessionTimelineLastDirection = direction > 0 ? 1 : -1; + } + this.sessionTimelineLastAnchorY = rawAnchorY; + const hysteresisPx = 18; + const hysteresis = direction > 0 ? -hysteresisPx : (direction < 0 ? hysteresisPx : 0); + const anchorY = rawAnchorY + hysteresis; + const measuredNodes = this.getCachedSessionTimelineMeasuredNodes(nodes); + if (!measuredNodes.length) { + if (!this.isSessionTimelineNodeKey(this.sessionTimelineActiveKey)) { + this.sessionTimelineActiveKey = nodes[0].key; + } + return; + } + let low = 0; + let high = measuredNodes.length - 1; + let candidateIndex = 0; + while (low <= high) { + const mid = Math.floor((low + high) / 2); + if (measuredNodes[mid].top <= anchorY) { + candidateIndex = mid; + low = mid + 1; + } else { + high = mid - 1; + } + } + let currentIndex = -1; + if (this.sessionTimelineActiveKey) { + for (let i = 0; i < measuredNodes.length; i += 1) { + if (measuredNodes[i].key === this.sessionTimelineActiveKey) { + currentIndex = i; + break; + } + } + } + if (currentIndex >= 0) { + if (direction > 0 && candidateIndex < currentIndex) { + candidateIndex = currentIndex; + } else if (direction < 0 && candidateIndex > currentIndex) { + candidateIndex = currentIndex; + } + } + const activeKey = measuredNodes[candidateIndex].key; + if (this.sessionTimelineActiveKey !== activeKey) { + this.sessionTimelineActiveKey = activeKey; + } + }, + jumpToSessionTimelineNode(messageKey) { + if (!this.sessionTimelineEnabled || this.mainTab !== 'sessions' || !this.sessionPreviewRenderEnabled) return; + if (!messageKey) return; + if (!this.isSessionTimelineNodeKey(messageKey)) return; + const scrollEl = this.sessionPreviewScrollEl || this.$refs.sessionPreviewScroll; + if (!scrollEl) return; + const messageEl = this.sessionMessageRefMap[messageKey]; + if (!messageEl) return; + const stickyOffset = getSessionPreviewHeaderOffset(this, scrollEl); + const scrollRect = scrollEl.getBoundingClientRect(); + const messageRect = messageEl.getBoundingClientRect(); + const targetScrollTop = scrollEl.scrollTop + (messageRect.top - scrollRect.top) - stickyOffset; + this.sessionTimelineActiveKey = messageKey; + if (typeof scrollEl.scrollTo === 'function') { + scrollEl.scrollTo({ + top: Math.max(0, targetScrollTop), + behavior: 'smooth' + }); + } else { + messageEl.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + } + }; +} diff --git a/web-ui/modules/app.methods.session-trash.mjs b/web-ui/modules/app.methods.session-trash.mjs new file mode 100644 index 0000000..828b9ae --- /dev/null +++ b/web-ui/modules/app.methods.session-trash.mjs @@ -0,0 +1,416 @@ +export function createSessionTrashMethods(options = {}) { + const { + api, + sessionTrashListLimit = 500, + sessionTrashPageSize = 200 + } = options; + + return { + buildSessionTrashItemFromSession(session, result = {}) { + const deletedAt = typeof result.deletedAt === 'string' && result.deletedAt + ? result.deletedAt + : new Date().toISOString(); + const source = session && session.source === 'claude' ? 'claude' : 'codex'; + return { + trashId: typeof result.trashId === 'string' ? result.trashId : '', + source, + sourceLabel: session && typeof session.sourceLabel === 'string' && session.sourceLabel + ? session.sourceLabel + : (source === 'claude' ? 'Claude Code' : 'Codex'), + sessionId: session && typeof session.sessionId === 'string' ? session.sessionId : '', + title: session && typeof session.title === 'string' && session.title + ? session.title + : (session && typeof session.sessionId === 'string' ? session.sessionId : ''), + cwd: session && typeof session.cwd === 'string' ? session.cwd : '', + createdAt: session && typeof session.createdAt === 'string' ? session.createdAt : '', + updatedAt: session && typeof session.updatedAt === 'string' ? session.updatedAt : '', + deletedAt, + messageCount: Number.isFinite(Number(result && result.messageCount)) + ? Math.max(0, Math.floor(Number(result.messageCount))) + : (Number.isFinite(Number(session && session.messageCount)) + ? Math.max(0, Math.floor(Number(session.messageCount))) + : 0), + originalFilePath: session && typeof session.filePath === 'string' ? session.filePath : '', + provider: session && typeof session.provider === 'string' ? session.provider : source, + keywords: Array.isArray(session && session.keywords) ? session.keywords : [], + capabilities: session && typeof session.capabilities === 'object' && session.capabilities + ? session.capabilities + : {}, + claudeIndexPath: '', + claudeIndexEntry: null, + trashFilePath: '' + }; + }, + + prependSessionTrashItem(item, options = {}) { + if (!item || !item.trashId) { + return; + } + const existing = Array.isArray(this.sessionTrashItems) ? this.sessionTrashItems : []; + const filtered = existing.filter((entry) => this.getSessionTrashActionKey(entry) !== item.trashId); + const nextItems = [item, ...filtered].slice(0, sessionTrashListLimit); + const previousTotalCount = Number(this.sessionTrashTotalCount); + const normalizedPreviousTotal = Number.isFinite(previousTotalCount) && previousTotalCount >= 0 + ? Math.max(existing.length, Math.floor(previousTotalCount)) + : existing.length; + this.sessionTrashItems = nextItems; + const previousVisibleCount = Number(this.sessionTrashVisibleCount); + const normalizedPreviousVisibleCount = Number.isFinite(previousVisibleCount) && previousVisibleCount > 0 + ? Math.floor(previousVisibleCount) + : sessionTrashPageSize; + const wasFullyExpanded = normalizedPreviousVisibleCount >= existing.length + || normalizedPreviousVisibleCount >= normalizedPreviousTotal; + if (wasFullyExpanded) { + this.sessionTrashVisibleCount = Math.min( + normalizedPreviousVisibleCount + 1, + nextItems.length || (normalizedPreviousVisibleCount + 1) + ); + } + const fallbackTotalCount = filtered.length === existing.length + ? normalizedPreviousTotal + 1 + : normalizedPreviousTotal; + this.sessionTrashTotalCount = this.normalizeSessionTrashTotalCount( + options && options.totalCount !== undefined + ? options.totalCount + : fallbackTotalCount, + nextItems + ); + }, + + normalizeSessionTrashTotalCount(totalCount, fallbackItems = this.sessionTrashItems) { + const fallbackCount = Array.isArray(fallbackItems) ? fallbackItems.length : 0; + const numericTotal = Number(totalCount); + if (!Number.isFinite(numericTotal) || numericTotal < 0) { + return fallbackCount; + } + return Math.floor(numericTotal); + }, + + getSessionTrashViewState() { + if (this.sessionTrashLoading && !this.sessionTrashLoadedOnce) { + return 'loading'; + } + const totalCount = Number(this.sessionTrashCount); + const normalizedTotalCount = Number.isFinite(totalCount) && totalCount >= 0 + ? Math.floor(totalCount) + : 0; + const hasVisibleItems = Array.isArray(this.sessionTrashItems) && this.sessionTrashItems.length > 0; + if (this.sessionTrashLastLoadFailed && (!this.sessionTrashLoadedOnce || !hasVisibleItems)) { + return 'retry'; + } + if (!this.sessionTrashLoadedOnce) { + return normalizedTotalCount > 0 ? 'retry' : 'empty'; + } + if (normalizedTotalCount === 0) { + return 'empty'; + } + return hasVisibleItems ? 'list' : 'retry'; + }, + + issueSessionTrashCountRequestToken() { + const currentToken = Number(this.sessionTrashCountRequestToken); + const nextToken = Number.isFinite(currentToken) && currentToken >= 0 + ? Math.floor(currentToken) + 1 + : 1; + this.sessionTrashCountRequestToken = nextToken; + return nextToken; + }, + + issueSessionTrashListRequestToken() { + const currentToken = Number(this.sessionTrashListRequestToken); + const nextToken = Number.isFinite(currentToken) && currentToken >= 0 + ? Math.floor(currentToken) + 1 + : 1; + this.sessionTrashListRequestToken = nextToken; + return nextToken; + }, + + invalidateSessionTrashRequests() { + this.issueSessionTrashCountRequestToken(); + return this.issueSessionTrashListRequestToken(); + }, + + isLatestSessionTrashCountRequestToken(token) { + return Number(token) === Number(this.sessionTrashCountRequestToken); + }, + + isLatestSessionTrashListRequestToken(token) { + return Number(token) === Number(this.sessionTrashListRequestToken); + }, + + resetSessionTrashVisibleCount() { + const totalItems = Array.isArray(this.sessionTrashItems) ? this.sessionTrashItems.length : 0; + this.sessionTrashVisibleCount = Math.min(totalItems, sessionTrashPageSize) || sessionTrashPageSize; + }, + + loadMoreSessionTrashItems() { + const totalItems = Array.isArray(this.sessionTrashItems) ? this.sessionTrashItems.length : 0; + const visibleCount = Number(this.sessionTrashVisibleCount); + const safeVisibleCount = Number.isFinite(visibleCount) && visibleCount > 0 + ? Math.floor(visibleCount) + : sessionTrashPageSize; + this.sessionTrashVisibleCount = Math.min(totalItems, safeVisibleCount + sessionTrashPageSize); + }, + + clearActiveSessionState() { + this.activeSession = null; + this.activeSessionMessages = []; + this.resetSessionDetailPagination(); + this.resetSessionPreviewMessageRender(); + this.activeSessionDetailError = ''; + this.activeSessionDetailClipped = false; + this.cancelSessionTimelineSync(); + this.sessionTimelineActiveKey = ''; + this.clearSessionTimelineRefs(); + }, + + async removeSessionFromCurrentList(session) { + const sessionKey = this.getSessionExportKey(session); + if (!sessionKey) { + return; + } + const currentList = Array.isArray(this.sessionsList) ? [...this.sessionsList] : []; + const removedIndex = currentList.findIndex((item) => this.getSessionExportKey(item) === sessionKey); + if (removedIndex < 0) { + return; + } + const activeKey = this.activeSession ? this.getSessionExportKey(this.activeSession) : ''; + const renderedList = Array.isArray(this.sortedSessionsList) ? this.sortedSessionsList : []; + const renderedIndex = renderedList.findIndex((item) => this.getSessionExportKey(item) === sessionKey); + let nextActiveKey = ''; + if (activeKey === sessionKey && renderedIndex >= 0) { + const fallbackSession = renderedList[renderedIndex - 1] || renderedList[renderedIndex + 1] || null; + nextActiveKey = fallbackSession ? this.getSessionExportKey(fallbackSession) : ''; + } + currentList.splice(removedIndex, 1); + this.sessionsList = currentList; + this.syncSessionPathOptionsForSource( + this.sessionFilterSource, + this.extractPathOptionsFromSessions(currentList), + false + ); + if (activeKey !== sessionKey) { + return; + } + if (currentList.length === 0) { + this.clearActiveSessionState(); + return; + } + const nextSession = currentList.find((item) => this.getSessionExportKey(item) === nextActiveKey) + || currentList[Math.min(removedIndex, currentList.length - 1)]; + if (!nextSession) { + this.clearActiveSessionState(); + return; + } + await this.selectSession(nextSession); + }, + + normalizeSettingsTab(tab) { + return tab === 'trash' ? 'trash' : 'backup'; + }, + + async onSettingsTabClick(tab) { + await this.switchSettingsTab(tab); + }, + + async switchSettingsTab(tab, options = {}) { + const nextTab = this.normalizeSettingsTab(tab); + this.settingsTab = nextTab; + if (nextTab !== 'trash') { + return; + } + const forceRefresh = options.forceRefresh === true; + if (forceRefresh || !this.sessionTrashLoadedOnce) { + await this.loadSessionTrash({ forceRefresh }); + } + }, + + async loadSessionTrashCount(options = {}) { + if (this.sessionTrashCountLoading) { + this.sessionTrashCountPendingOptions = { + ...(this.sessionTrashCountPendingOptions || {}), + ...(options || {}) + }; + return; + } + const requestToken = this.issueSessionTrashCountRequestToken(); + this.sessionTrashCountLoading = true; + try { + const res = await api('list-session-trash', { countOnly: true }); + if (!this.isLatestSessionTrashCountRequestToken(requestToken)) { + return; + } + if (res.error) { + if (options.silent !== true) { + this.showMessage(res.error, 'error'); + } + return; + } + this.sessionTrashTotalCount = this.normalizeSessionTrashTotalCount( + res.totalCount, + this.sessionTrashItems + ); + this.sessionTrashCountLoadedOnce = true; + } catch (e) { + if (this.isLatestSessionTrashCountRequestToken(requestToken) && options.silent !== true) { + this.showMessage('加载回收站数量失败', 'error'); + } + } finally { + this.sessionTrashCountLoading = false; + const pendingOptions = this.sessionTrashCountPendingOptions; + this.sessionTrashCountPendingOptions = null; + if (pendingOptions) { + await this.loadSessionTrashCount(pendingOptions); + } + } + }, + + getSessionTrashActionKey(item) { + return item && typeof item.trashId === 'string' ? item.trashId : ''; + }, + + isSessionTrashActionBusy(item) { + const key = typeof item === 'string' ? item : this.getSessionTrashActionKey(item); + return !!(key && (this.sessionTrashRestoring[key] || this.sessionTrashPurging[key])); + }, + + async loadSessionTrash(options = {}) { + if (this.sessionTrashLoading) { + this.sessionTrashPendingOptions = { + ...(this.sessionTrashPendingOptions || {}), + ...(options || {}) + }; + return; + } + const requestToken = this.issueSessionTrashListRequestToken(); + this.sessionTrashLoading = true; + this.sessionTrashLastLoadFailed = false; + let loadSucceeded = false; + try { + const res = await api('list-session-trash', { + limit: sessionTrashListLimit, + forceRefresh: !!options.forceRefresh + }); + if (!this.isLatestSessionTrashListRequestToken(requestToken)) { + return; + } + if (res.error) { + this.sessionTrashLastLoadFailed = true; + this.showMessage(res.error, 'error'); + return; + } + const nextItems = Array.isArray(res.items) ? res.items : []; + this.sessionTrashItems = nextItems; + this.resetSessionTrashVisibleCount(); + this.sessionTrashTotalCount = this.normalizeSessionTrashTotalCount(res.totalCount, nextItems); + this.sessionTrashCountLoadedOnce = true; + this.sessionTrashLastLoadFailed = false; + loadSucceeded = true; + } catch (e) { + if (this.isLatestSessionTrashListRequestToken(requestToken)) { + this.sessionTrashLastLoadFailed = true; + this.showMessage('加载回收站失败', 'error'); + } + } finally { + this.sessionTrashLoading = false; + if (loadSucceeded) { + this.sessionTrashLoadedOnce = true; + } + const pendingOptions = this.sessionTrashPendingOptions; + this.sessionTrashPendingOptions = null; + if (pendingOptions) { + await this.loadSessionTrash(pendingOptions); + } + } + }, + + async restoreSessionTrash(item) { + const key = this.getSessionTrashActionKey(item); + if (!key || this.isSessionTrashActionBusy(key) || this.sessionTrashClearing) { + return; + } + this.sessionTrashRestoring[key] = true; + try { + const res = await api('restore-session-trash', { trashId: key }); + if (res.error) { + this.showMessage(res.error, 'error'); + return; + } + this.showMessage('会话已恢复', 'success'); + this.invalidateSessionTrashRequests(); + await this.loadSessionTrash({ forceRefresh: true }); + if (this.sessionsLoadedOnce) { + await this.loadSessions(); + } + } catch (e) { + this.showMessage('恢复失败', 'error'); + } finally { + this.sessionTrashRestoring[key] = false; + } + }, + + async purgeSessionTrash(item) { + const key = this.getSessionTrashActionKey(item); + if (!key || this.isSessionTrashActionBusy(key) || this.sessionTrashClearing) { + return; + } + const confirmed = await this.requestConfirmDialog({ + title: '彻底删除回收站记录', + message: '该会话将从回收站永久删除,且无法恢复。', + confirmText: '彻底删除', + cancelText: '取消', + danger: true + }); + if (!confirmed) { + return; + } + this.sessionTrashPurging[key] = true; + try { + const res = await api('purge-session-trash', { trashId: key }); + if (res.error) { + this.showMessage(res.error, 'error'); + return; + } + this.showMessage('已彻底删除', 'success'); + this.invalidateSessionTrashRequests(); + await this.loadSessionTrash({ forceRefresh: true }); + } catch (e) { + this.showMessage('彻底删除失败', 'error'); + } finally { + this.sessionTrashPurging[key] = false; + } + }, + + async clearSessionTrash() { + const normalizedCount = Number(this.sessionTrashCount); + if (this.sessionTrashClearing || !Number.isFinite(normalizedCount) || normalizedCount <= 0) { + return; + } + const confirmed = await this.requestConfirmDialog({ + title: '清空回收站', + message: '该操作会永久删除回收站中的全部会话,且无法恢复。', + confirmText: '全部清空', + cancelText: '取消', + danger: true + }); + if (!confirmed) { + return; + } + this.sessionTrashClearing = true; + try { + const res = await api('purge-session-trash', { all: true }); + if (res.error) { + this.showMessage(res.error, 'error'); + return; + } + this.showMessage('回收站已清空', 'success'); + this.invalidateSessionTrashRequests(); + await this.loadSessionTrash({ forceRefresh: true }); + } catch (e) { + this.showMessage('清空回收站失败', 'error'); + } finally { + this.sessionTrashClearing = false; + } + } + }; +} diff --git a/web-ui/modules/app.methods.startup-claude.mjs b/web-ui/modules/app.methods.startup-claude.mjs new file mode 100644 index 0000000..1e3afa7 --- /dev/null +++ b/web-ui/modules/app.methods.startup-claude.mjs @@ -0,0 +1,405 @@ +import { + findDuplicateClaudeConfigName, + matchClaudeConfigFromSettings, + normalizeClaudeConfig, + normalizeClaudeSettingsEnv, + normalizeClaudeValue +} from '../logic.mjs'; + +export function createStartupClaudeMethods(options = {}) { + const { + api, + defaultModelContextWindow = 190000, + defaultModelAutoCompactTokenLimit = 185000 + } = options; + + return { + async loadAll(options = {}) { + const preserveLoading = !!options.preserveLoading; + let startupOk = false; + if (!preserveLoading) { + this.loading = true; + } + this.initError = ''; + try { + const [statusRes, listRes] = await Promise.all([api('status'), api('list')]); + + if (statusRes.error || (listRes && listRes.error)) { + this.initError = statusRes.error || listRes.error; + } else { + startupOk = true; + this.currentProvider = statusRes.provider; + this.currentModel = statusRes.model; + { + const tier = typeof statusRes.serviceTier === 'string' + ? statusRes.serviceTier.trim().toLowerCase() + : ''; + this.serviceTier = tier === 'fast' ? 'fast' : (tier ? 'standard' : 'fast'); + } + { + const effort = typeof statusRes.modelReasoningEffort === 'string' + ? statusRes.modelReasoningEffort.trim().toLowerCase() + : ''; + this.modelReasoningEffort = effort || 'high'; + } + { + const contextWindow = this.normalizePositiveIntegerInput( + statusRes.modelContextWindow, + 'model_context_window', + defaultModelContextWindow + ); + if (this.editingCodexBudgetField !== 'modelContextWindowInput') { + this.modelContextWindowInput = contextWindow.ok && contextWindow.text + ? contextWindow.text + : String(defaultModelContextWindow); + } + } + { + const autoCompactTokenLimit = this.normalizePositiveIntegerInput( + statusRes.modelAutoCompactTokenLimit, + 'model_auto_compact_token_limit', + defaultModelAutoCompactTokenLimit + ); + if (this.editingCodexBudgetField !== 'modelAutoCompactTokenLimitInput') { + this.modelAutoCompactTokenLimitInput = autoCompactTokenLimit.ok && autoCompactTokenLimit.text + ? autoCompactTokenLimit.text + : String(defaultModelAutoCompactTokenLimit); + } + } + this.providersList = listRes.providers; + if (statusRes.configReady === false) { + this.showMessage('配置已加载', 'info'); + } + if (statusRes.initNotice) { + this.showMessage('配置就绪', 'info'); + } + this.maybeShowStarPrompt(); + } + } catch (e) { + this.initError = '连接失败: ' + e.message; + } finally { + if (!preserveLoading) { + this.loading = false; + } + } + + if (startupOk) { + try { + await this.loadModelsForProvider(this.currentProvider); + } catch (_) {} + } + + try { + await this.loadCodexAuthProfiles(); + } catch (_) {} + }, + + async loadModelsForProvider(providerName, options = {}) { + const silentError = !!options.silentError; + const targetProvider = typeof providerName === 'string' ? providerName.trim() : ''; + const requestSeq = (Number(this.codexModelsRequestSeq) || 0) + 1; + this.codexModelsRequestSeq = requestSeq; + this.codexModelsLoading = true; + if (!targetProvider) { + this.models = []; + this.modelsSource = 'unlimited'; + this.modelsHasCurrent = true; + this.codexModelsLoading = false; + return; + } + const isLatestRequest = () => { + const currentProvider = typeof this.currentProvider === 'string' ? this.currentProvider.trim() : ''; + return requestSeq === Number(this.codexModelsRequestSeq || 0) + && (!currentProvider || currentProvider === targetProvider); + }; + try { + const res = await api('models', { provider: targetProvider }); + if (!isLatestRequest()) { + return; + } + if (res.unlimited) { + this.models = []; + this.modelsSource = 'unlimited'; + this.modelsHasCurrent = true; + return; + } + if (res.error) { + if (!silentError) { + this.showMessage('获取模型列表失败', 'error'); + } + this.models = []; + this.modelsSource = 'error'; + this.modelsHasCurrent = true; + return; + } + const list = Array.isArray(res.models) ? res.models : []; + this.models = list; + this.modelsSource = res.source || 'remote'; + this.modelsHasCurrent = !!this.currentModel && list.includes(this.currentModel); + } catch (_) { + if (!isLatestRequest()) { + return; + } + if (!silentError) { + this.showMessage('获取模型列表失败', 'error'); + } + this.models = []; + this.modelsSource = 'error'; + this.modelsHasCurrent = true; + } finally { + if (requestSeq === Number(this.codexModelsRequestSeq || 0)) { + this.codexModelsLoading = false; + } + } + }, + + getCurrentClaudeConfig() { + if (!this.currentClaudeConfig) return null; + return this.claudeConfigs[this.currentClaudeConfig] || null; + }, + + normalizeClaudeValue, + + normalizeClaudeConfig(config) { + return normalizeClaudeConfig(config); + }, + + normalizeClaudeSettingsEnv(env) { + return normalizeClaudeSettingsEnv(env); + }, + + matchClaudeConfigFromSettings(env) { + return matchClaudeConfigFromSettings(this.claudeConfigs, env); + }, + + findDuplicateClaudeConfigName(config) { + return findDuplicateClaudeConfigName(this.claudeConfigs, config); + }, + + mergeClaudeConfig(existing = {}, updates = {}) { + const previous = this.normalizeClaudeConfig(existing); + const next = this.normalizeClaudeConfig({ ...existing, ...updates }); + const externalCredentialType = next.apiKey + ? '' + : (next.externalCredentialType || previous.externalCredentialType || ''); + return { + apiKey: next.apiKey, + baseUrl: next.baseUrl, + model: next.model || previous.model || 'glm-4.7', + hasKey: !!(next.apiKey || externalCredentialType), + externalCredentialType + }; + }, + + buildClaudeImportedConfigName(baseUrl) { + const normalizedUrl = typeof baseUrl === 'string' ? baseUrl.trim() : ''; + if (!normalizedUrl) return '导入配置'; + try { + const parsed = new URL(normalizedUrl); + const host = typeof parsed.host === 'string' ? parsed.host.trim() : ''; + if (host) return `导入-${host}`; + } catch (_) {} + return '导入配置'; + }, + + ensureClaudeConfigFromSettings(env = {}) { + const normalized = this.normalizeClaudeSettingsEnv(env); + const hasCredential = !!(normalized.apiKey || normalized.authToken || normalized.useKey); + if (!normalized.baseUrl || !hasCredential) return ''; + + const duplicateName = this.findDuplicateClaudeConfigName(normalized); + if (duplicateName) return duplicateName; + + const preferredName = this.buildClaudeImportedConfigName(normalized.baseUrl); + let candidateName = preferredName; + let suffix = 2; + const maxAttempts = 1000; + while (this.claudeConfigs[candidateName] && suffix <= maxAttempts) { + candidateName = `${preferredName}-${suffix}`; + suffix += 1; + } + if (this.claudeConfigs[candidateName]) { + return ''; + } + + this.claudeConfigs[candidateName] = this.mergeClaudeConfig({}, normalized); + this.saveClaudeConfigs(); + return candidateName; + }, + + async refreshClaudeSelectionFromSettings(options = {}) { + const silent = !!options.silent; + const silentModelError = !!options.silentModelError || silent; + try { + const res = await api('get-claude-settings'); + if (res && res.error) { + if (!silent) { + this.showMessage('读取配置失败', 'error'); + } + return; + } + const matchName = this.matchClaudeConfigFromSettings((res && res.env) || {}); + if (matchName) { + if (this.currentClaudeConfig !== matchName) { + this.currentClaudeConfig = matchName; + } + this.refreshClaudeModelContext({ silentError: silentModelError }); + return; + } + const importedName = this.ensureClaudeConfigFromSettings((res && res.env) || {}); + if (importedName) { + if (this.currentClaudeConfig !== importedName) { + this.currentClaudeConfig = importedName; + } + this.refreshClaudeModelContext({ silentError: silentModelError }); + if (!silent) { + this.showMessage(`检测到外部 Claude 配置,已自动导入:${importedName}`, 'success'); + } + return; + } + this.currentClaudeConfig = ''; + this.currentClaudeModel = ''; + this.resetClaudeModelsState(); + if (!silent) { + const tip = res && res.exists + ? '当前 Claude settings.json 与本地配置不匹配,已取消选中' + : '未检测到 Claude settings.json,已取消选中'; + this.showMessage(tip, 'info'); + } + } catch (_) { + if (!silent) { + this.showMessage('读取配置失败', 'error'); + } + } + }, + + syncClaudeModelFromConfig() { + const config = this.getCurrentClaudeConfig(); + this.currentClaudeModel = config && config.model ? config.model : ''; + }, + + refreshClaudeModelContext(options = {}) { + this.syncClaudeModelFromConfig(); + return this.loadClaudeModels(options); + }, + + resetClaudeModelsState() { + this.claudeModels = []; + this.claudeModelsSource = 'idle'; + this.claudeModelsHasCurrent = true; + this.claudeModelsLoading = false; + }, + + updateClaudeModelsCurrent() { + const currentModel = (this.currentClaudeModel || '').trim(); + this.claudeModelsHasCurrent = !!currentModel && this.claudeModels.includes(currentModel); + }, + + async loadClaudeModels(options = {}) { + const silentError = !!options.silentError; + const config = this.getCurrentClaudeConfig(); + const requestSeq = (Number(this.claudeModelsRequestSeq) || 0) + 1; + this.claudeModelsRequestSeq = requestSeq; + if (!config) { + this.resetClaudeModelsState(); + return; + } + const currentConfigName = typeof this.currentClaudeConfig === 'string' ? this.currentClaudeConfig.trim() : ''; + const baseUrl = (config.baseUrl || '').trim(); + const apiKey = (config.apiKey || '').trim(); + const externalCredentialType = typeof config.externalCredentialType === 'string' + ? config.externalCredentialType.trim() + : ''; + + if (!baseUrl) { + this.resetClaudeModelsState(); + return; + } + if (!apiKey && externalCredentialType) { + this.claudeModels = []; + this.claudeModelsSource = 'unlimited'; + this.claudeModelsHasCurrent = true; + this.claudeModelsLoading = false; + return; + } + + this.claudeModelsLoading = true; + const isLatestRequest = () => { + if (requestSeq !== Number(this.claudeModelsRequestSeq || 0)) { + return false; + } + const liveConfigName = typeof this.currentClaudeConfig === 'string' ? this.currentClaudeConfig.trim() : ''; + if (currentConfigName && liveConfigName && liveConfigName !== currentConfigName) { + return false; + } + const latestConfig = this.getCurrentClaudeConfig(); + if (!latestConfig) { + return false; + } + return (latestConfig.baseUrl || '').trim() === baseUrl + && (latestConfig.apiKey || '').trim() === apiKey + && (typeof latestConfig.externalCredentialType === 'string' ? latestConfig.externalCredentialType.trim() : '') === externalCredentialType; + }; + try { + const res = await api('models-by-url', { baseUrl, apiKey }); + if (!isLatestRequest()) { + return; + } + if (res.unlimited) { + this.claudeModels = []; + this.claudeModelsSource = 'unlimited'; + this.claudeModelsHasCurrent = true; + return; + } + if (res.error) { + if (!silentError) { + this.showMessage('获取模型列表失败', 'error'); + } + this.claudeModels = []; + this.claudeModelsSource = 'error'; + this.claudeModelsHasCurrent = true; + return; + } + const list = Array.isArray(res.models) ? res.models : []; + this.claudeModels = list; + this.claudeModelsSource = res.source || 'remote'; + this.updateClaudeModelsCurrent(); + } catch (_) { + if (!isLatestRequest()) { + return; + } + if (!silentError) { + this.showMessage('获取模型列表失败', 'error'); + } + this.claudeModels = []; + this.claudeModelsSource = 'error'; + this.claudeModelsHasCurrent = true; + } finally { + if (requestSeq === Number(this.claudeModelsRequestSeq || 0)) { + this.claudeModelsLoading = false; + } + } + }, + + openClaudeConfigModal() { + this.showClaudeConfigModal = true; + }, + + maybeShowStarPrompt() { + const storageKey = 'codexmateStarPrompted'; + let shown = false; + try { + if (localStorage.getItem(storageKey)) { + return; + } + this.showMessage('欢迎到 GitHub 点 Star', 'info'); + shown = true; + localStorage.setItem(storageKey, '1'); + } catch (_) { + if (!shown) { + this.showMessage('欢迎到 GitHub 点 Star', 'info'); + } + } + } + }; +} diff --git a/web-ui/partials/index/layout-footer.html b/web-ui/partials/index/layout-footer.html new file mode 100644 index 0000000..10336b3 --- /dev/null +++ b/web-ui/partials/index/layout-footer.html @@ -0,0 +1,69 @@ + +
+ 加载配置中... +
+ +
+ + {{ initError }} +
+ + + + + diff --git a/web-ui/partials/index/layout-header.html b/web-ui/partials/index/layout-header.html new file mode 100644 index 0000000..20bb409 --- /dev/null +++ b/web-ui/partials/index/layout-header.html @@ -0,0 +1,337 @@ +
+ + +
+ +
+ Codex Mate. +
+
+ 配置中枢:管理 Codex / Claude / OpenClaw / 会话 + 本地配置中枢,统一管理 Codex / Claude Code / OpenClaw / 会话。 +
+
+
+ + + + GitHub + + + SakuraByteCore + / + codexmate + + +
+ +
+ + + + + + +
+ +
+ +
+
+

+ {{ mainTab === 'config' ? '配置中心' : (mainTab === 'sessions' ? '会话浏览' : (mainTab === 'market' ? '技能市场' : '设置')) }} +

+

+ 配置中枢:管理 Codex / Claude / OpenClaw + 本地配置中枢,统一管理 Codex / Claude Code / OpenClaw。 +

+

+ 浏览、导出或独立查看 Codex / Claude 会话记录。 +

+

+ 统一管理 Codex / Claude Skills,并聚焦本地导入与分发。 +

+
+ +
+ + + + +
+
+
+ 当前来源 + + {{ sessionFilterSource === 'all' ? '全部' : (sessionFilterSource === 'claude' ? 'Claude Code' : 'Codex') }} + +
+
+ 会话数 + {{ sessionsList.length }} +
+
+
+
+ 当前目标 + {{ skillsTargetLabel }} +
+
+ 本地 Skills + {{ skillsList.length }} +
+
+ 可导入 + {{ skillsImportList.length }} +
+
+ 可直接导入 + {{ skillsImportConfiguredCount }} +
+
+
+ + +
+ + +
diff --git a/web-ui/partials/index/modal-config-template-agents.html b/web-ui/partials/index/modal-config-template-agents.html new file mode 100644 index 0000000..5d0a23a --- /dev/null +++ b/web-ui/partials/index/modal-config-template-agents.html @@ -0,0 +1,125 @@ + + + diff --git a/web-ui/partials/index/modal-confirm-toast.html b/web-ui/partials/index/modal-confirm-toast.html new file mode 100644 index 0000000..0b6c87f --- /dev/null +++ b/web-ui/partials/index/modal-confirm-toast.html @@ -0,0 +1,32 @@ + + + + + +
{{ message }}
+
diff --git a/web-ui/partials/index/modal-openclaw-config.html b/web-ui/partials/index/modal-openclaw-config.html new file mode 100644 index 0000000..9af826e --- /dev/null +++ b/web-ui/partials/index/modal-openclaw-config.html @@ -0,0 +1,275 @@ + diff --git a/web-ui/partials/index/modal-skills.html b/web-ui/partials/index/modal-skills.html new file mode 100644 index 0000000..3d5f68f --- /dev/null +++ b/web-ui/partials/index/modal-skills.html @@ -0,0 +1,184 @@ + + + diff --git a/web-ui/partials/index/modals-basic.html b/web-ui/partials/index/modals-basic.html new file mode 100644 index 0000000..fe7ef09 --- /dev/null +++ b/web-ui/partials/index/modals-basic.html @@ -0,0 +1,196 @@ + + + + + + + + + + + + + + + + + + + diff --git a/web-ui/partials/index/panel-config-claude.html b/web-ui/partials/index/panel-config-claude.html new file mode 100644 index 0000000..7e425d0 --- /dev/null +++ b/web-ui/partials/index/panel-config-claude.html @@ -0,0 +1,100 @@ + +
+ + +
+ 默认应用到 ~/.claude/settings.json。 +
+ +
+
+ 模型 +
+ + +
+ 模型修改后会自动保存并应用到当前配置。 +
+
+ +
+
+ 配置健康检查 +
+ +
+ +
+
+
+
{{ name.charAt(0).toUpperCase() }}
+
+
{{ name }}
+
{{ config.model || '未设置模型' }}
+
+
+
+ + {{ config.hasKey ? '已配置' : '未配置' }} + + + {{ formatLatency(claudeSpeedResults[name]) }} + +
+ + + +
+
+
+
+
diff --git a/web-ui/partials/index/panel-config-codex.html b/web-ui/partials/index/panel-config-codex.html new file mode 100644 index 0000000..0fe00a4 --- /dev/null +++ b/web-ui/partials/index/panel-config-codex.html @@ -0,0 +1,255 @@ + +
+ + + + +
+
+ 模型 +
+ + +
+
+ + +
+ 当前提供商未提供模型列表,视为不限。模型可手动输入。 +
+
+ 模型列表获取失败,请检查接口或手动输入。 +
+
+ {{ isCodexConfigMode ? '当前模型不在接口列表中,请手动输入或在模板中调整。' : '当前模型不在接口列表中,请手动输入。' }} +
+
+ Codex 配置需先改模板,再手动应用。 +
+
+ {{ activeProviderBridgeHint }} 模板仅在 Codex 模式下可编辑。 +
+ +
+ + + +
+
+ 配置健康检查 +
+ +
+ +
+
+
+
{{ provider.name.charAt(0).toUpperCase() }}
+
+
+ {{ provider.name }} + 系统 +
+
+ {{ provider.url || '未设置 URL' }} +
+
+
+
+ + {{ providerPillText(provider) }} + + + {{ formatLatency(speedResults[provider.name]) }} + +
+ + + + +
+
+
+
+
diff --git a/web-ui/partials/index/panel-config-openclaw.html b/web-ui/partials/index/panel-config-openclaw.html new file mode 100644 index 0000000..746f1a8 --- /dev/null +++ b/web-ui/partials/index/panel-config-openclaw.html @@ -0,0 +1,84 @@ + +
+ +
+ 默认应用到 ~/.openclaw/openclaw.json。支持 JSON5(注释/尾逗号)。 +
+ +
+
+ AGENTS.md +
+
+ 管理 OpenClaw Workspace 指令文件,默认读写 ~/.openclaw/workspace/AGENTS.md。 +
+ +
+ +
+
+ +
+ +
+ 仅支持 OpenClaw Workspace 内的 .md 文件。 +
+ +
+ +
+
+
+
{{ name.charAt(0).toUpperCase() }}
+
+
{{ name }}
+
{{ openclawSubtitle(config) }}
+
+
+
+ + {{ openclawHasContent(config) ? '已配置' : '未配置' }} + +
+ + +
+
+
+
+
diff --git a/web-ui/partials/index/panel-market.html b/web-ui/partials/index/panel-market.html new file mode 100644 index 0000000..effbe17 --- /dev/null +++ b/web-ui/partials/index/panel-market.html @@ -0,0 +1,174 @@ +
+
+
+
+ Skills 市场概览 +
当前市场页只保留本地 skills 能力:切换 Codex / Claude Code 安装目标,查看已安装项,执行跨应用导入与 ZIP 分发。
+
+
+ + +
+
+ +
+ + +
+ +
{{ skillsRootPath || skillsDefaultRootPath }}
+ +
+
+ 安装目标 + {{ skillsTargetLabel }} +
+
+ 本地总数 + {{ skillsList.length }} +
+
+ 含 SKILL.md + {{ skillsConfiguredCount }} +
+
+ 缺少 SKILL.md + {{ skillsMissingSkillFileCount }} +
+
+ 可导入 + {{ skillsImportList.length }} +
+
+ 可直接导入 + {{ skillsImportConfiguredCount }} +
+
+
+ +
+
+
+
+
已安装 Skills
+
展示当前已落地到 {{ skillsRootPath || skillsDefaultRootPath }} 的前 6 个目录,可继续进入管理弹窗做筛选、导出和删除。
+
+ +
+
正在加载本地 Skills...
+
当前还没有已安装 skill,可通过 ZIP 或跨应用导入补充。
+
+
+
+
{{ skill.displayName || skill.name }}
+
{{ skill.description || skill.path }}
+
+ + {{ skill.hasSkillFile ? '已验证' : '待补 SKILL.md' }} + +
+
+
+ +
+
+
+
可导入来源
+
扫描其他应用下未托管的 skill,先确认来源和目录,再批量导入到当前 {{ skillsTargetLabel }} skills 目录。
+
+ +
+
正在扫描可导入 skill...
+
还没有扫描到可导入 skill,可点击“扫描来源”重新读取。
+
+
+
+
{{ skill.displayName || skill.name }}
+
{{ skill.sourceLabel }} · {{ skill.sourcePath }}
+
+ + {{ skill.hasSkillFile ? '可直接导入' : '缺少 SKILL.md' }} + +
+
+
+ +
+
+
+
分发入口
+
市场页聚焦本地落地:本地管理、跨应用导入、ZIP 分发,全部作用到当前安装目标。
+
+
+
+ + + +
+
+ +
+
+
+
市场说明
+
+
+
+
+
+
目标宿主切换
+
在 Codex 和 Claude Code 之间切换后,后续扫描、导入、导出、删除都会落到当前 {{ skillsTargetLabel }} 目录。
+
+
+
+
+
跨应用导入
+
扫描 `Codex`、`Claude Code` 与 `Agents` 目录里的未托管 skills,筛选后批量导入到当前宿主。
+
+
+
+
+
ZIP 分发
+
通过压缩包在不同环境间分发技能目录,保持本地可控,不依赖外部目录服务。
+
+
+
+
+
+
diff --git a/web-ui/partials/index/panel-sessions.html b/web-ui/partials/index/panel-sessions.html new file mode 100644 index 0000000..048e472 --- /dev/null +++ b/web-ui/partials/index/panel-sessions.html @@ -0,0 +1,288 @@ + +
+
+
+ 加载中... +
+
+ {{ sessionStandaloneError }} +
+
+
+ {{ sessionStandaloneTitle }} + · {{ sessionStandaloneSourceLabel }} +
+
{{ sessionStandaloneText }}
+
+
+ +
+
+
+ 会话来源 +
+ +
+
+
+
+ + +
+
+ +
+
+ + +
+
+ +
+ +
+ 会话加载中... +
+ +
+ 暂无可用会话记录 +
+ +
+
+
+
+
+
{{ session.title || session.sessionId }}
+ {{ session.messageCount ?? 0 }} +
+
+ + +
+
+
+ {{ session.sourceLabel }} + {{ session.updatedAt || 'unknown time' }} +
+
+
+
+ +
+ + +
+ 请先在左侧选择一个会话 +
+
+
+
+
diff --git a/web-ui/partials/index/panel-settings.html b/web-ui/partials/index/panel-settings.html new file mode 100644 index 0000000..5c71c07 --- /dev/null +++ b/web-ui/partials/index/panel-settings.html @@ -0,0 +1,140 @@ + +
+
+ + +
+ +
+
+
+ Claude 配置 +
+ + + +
+
+
+ Codex 配置 +
+ + + +
+
+ +
+
+
+
+ + +
+
+ +
+ 正在加载回收站... +
+
+ 回收站为空 +
+
+ 回收站列表加载失败,请刷新重试 +
+
+
+
+
+
+
{{ item.title || item.sessionId }}
+ {{ item.messageCount ?? 0 }} +
+
+ {{ item.sourceLabel }} +
+
+
+
+ + +
+
{{ item.deletedAt || item.updatedAt || 'unknown time' }}
+
+
+
+ 工作区 + {{ item.cwd }} +
+
+ 原文件 + {{ item.originalFilePath }} +
+
+ +
+
+
+
diff --git a/web-ui/source-bundle.cjs b/web-ui/source-bundle.cjs new file mode 100644 index 0000000..3af95a4 --- /dev/null +++ b/web-ui/source-bundle.cjs @@ -0,0 +1,233 @@ +const fs = require('fs'); +const path = require('path'); + +const HTML_INCLUDE_RE = /^[ \t]*\s*$/gm; +const CSS_IMPORT_RE = /^[ \t]*@import\s+(?:url\(\s*)?(['"]?)([^'")]+)\1\s*\)?\s*;/gm; +const JS_IMPORT_RE = /(?:^|\n)\s*import\s+(?:[\s\S]*?\s+from\s+)?['"](\.[^'"]+)['"]\s*;?/g; +const JS_EXPORT_FROM_RE = /(?:^|\n)\s*export\s+\*\s+from\s+['"](\.[^'"]+)['"]\s*;?/g; +const JS_RELATIVE_IMPORT_STATEMENT_RE = /(^|\n)([ \t]*)import\s+([\s\S]*?)\s+from\s+['"](\.[^'"]+)['"]\s*;?[ \t]*/g; +const IDENTIFIER_RE = /^[A-Za-z_$][\w$]*$/; + +function stripBom(content) { + return content.replace(/^\uFEFF/, ''); +} + +function readUtf8Text(filePath) { + return stripBom(fs.readFileSync(filePath, 'utf8').replace(/\r\n?/g, '\n')); +} + +function normalizeIncludeTarget(rawTarget) { + const trimmed = String(rawTarget || '').trim(); + if (!trimmed) return ''; + return trimmed.replace(/^['"]|['"]$/g, ''); +} + +function assertNoCircularDependency(filePath, stack) { + if (!stack.includes(filePath)) { + return; + } + const cycle = [...stack, filePath] + .map(item => path.relative(path.join(__dirname, '..'), item)) + .join(' -> '); + throw new Error(`Detected circular source include: ${cycle}`); +} + +function bundleHtmlFile(filePath, stack = []) { + assertNoCircularDependency(filePath, stack); + const source = readUtf8Text(filePath); + return source.replace(HTML_INCLUDE_RE, (_match, rawTarget) => { + const target = normalizeIncludeTarget(rawTarget); + if (!target) { + return ''; + } + const targetPath = path.resolve(path.dirname(filePath), target); + return bundleHtmlFile(targetPath, [...stack, filePath]); + }); +} + +function bundleCssFile(filePath, stack = []) { + assertNoCircularDependency(filePath, stack); + const source = readUtf8Text(filePath); + return source.replace(CSS_IMPORT_RE, (match, _quote, rawTarget) => { + const target = normalizeIncludeTarget(rawTarget); + if (!target || !target.startsWith('.')) { + return match; + } + const targetPath = path.resolve(path.dirname(filePath), target); + return bundleCssFile(targetPath, [...stack, filePath]); + }); +} + +function resolveJavaScriptDependencies(filePath) { + const source = readUtf8Text(filePath); + const dependencies = []; + for (const pattern of [JS_IMPORT_RE, JS_EXPORT_FROM_RE]) { + let match = pattern.exec(source); + while (match) { + const target = normalizeIncludeTarget(match[1]); + if (target.startsWith('.')) { + dependencies.push(path.resolve(path.dirname(filePath), target)); + } + match = pattern.exec(source); + } + pattern.lastIndex = 0; + } + return dependencies; +} + +function bundleJavaScriptFile(filePath, visited = new Set()) { + if (visited.has(filePath)) { + return ''; + } + visited.add(filePath); + + const relativePath = path.relative(path.join(__dirname, '..'), filePath).replace(/\\/g, '/'); + const source = readUtf8Text(filePath); + const chunks = [ + `// ===== FILE: ${relativePath} =====`, + source.trimEnd(), + '' + ]; + + for (const dependencyPath of resolveJavaScriptDependencies(filePath)) { + chunks.push(bundleJavaScriptFile(dependencyPath, visited).trimEnd()); + chunks.push(''); + } + + return chunks.join('\n').trimEnd() + '\n'; +} + +function collectJavaScriptFiles(filePath, ordered = [], visited = new Set(), stack = []) { + assertNoCircularDependency(filePath, stack); + if (visited.has(filePath)) { + return ordered; + } + visited.add(filePath); + for (const dependencyPath of resolveJavaScriptDependencies(filePath)) { + collectJavaScriptFiles(dependencyPath, ordered, visited, [...stack, filePath]); + } + ordered.push(filePath); + return ordered; +} + +function splitCommaSeparatedSpecifiers(source) { + const items = []; + let current = ''; + let depth = 0; + for (let i = 0; i < source.length; i += 1) { + const ch = source[i]; + if (ch === '{' || ch === '[' || ch === '(') { + depth += 1; + } else if (ch === '}' || ch === ']' || ch === ')') { + depth = Math.max(0, depth - 1); + } + if (ch === ',' && depth === 0) { + items.push(current); + current = ''; + continue; + } + current += ch; + } + if (current) { + items.push(current); + } + return items.map(item => item.trim()).filter(Boolean); +} + +function buildRelativeImportAliasStatements(importClause, filePath) { + const clause = String(importClause || '').trim(); + if (!clause) { + return ''; + } + if (!clause.startsWith('{') || !clause.endsWith('}')) { + throw new Error(`Unsupported executable bundle import in ${filePath}: ${clause}`); + } + + const innerClause = clause.slice(1, -1).trim(); + if (!innerClause) { + return ''; + } + + const statements = []; + for (const specifier of splitCommaSeparatedSpecifiers(innerClause)) { + const parts = specifier.split(/\s+as\s+/); + const imported = String(parts[0] || '').trim(); + const local = String(parts[1] || imported).trim(); + if (!IDENTIFIER_RE.test(imported) || !IDENTIFIER_RE.test(local)) { + throw new Error(`Unsupported executable bundle import specifier in ${filePath}: ${specifier}`); + } + if (local !== imported) { + statements.push(`const ${local} = ${imported};`); + } + } + return statements.join('\n'); +} + +function transformJavaScriptModuleSource(source, options = {}) { + const preserveExports = !!options.preserveExports; + const sourcePath = typeof source === 'string' ? source : String(source || ''); + let transformed = readUtf8Text(sourcePath); + transformed = transformed.replace(JS_RELATIVE_IMPORT_STATEMENT_RE, (_match, prefix, indent, importClause) => { + const aliases = buildRelativeImportAliasStatements(importClause, sourcePath); + if (!aliases) { + return prefix || ''; + } + const indentedAliases = aliases + .split('\n') + .map(line => `${indent || ''}${line}`) + .join('\n'); + return `${prefix || ''}${indentedAliases}\n`; + }); + transformed = transformed.replace(/^[ \t]*export\s+\*\s+from\s+['"]\.[^'"]+['"]\s*;?\s*$/gm, ''); + if (!preserveExports) { + transformed = transformed.replace(/(^|\n)([ \t]*)export\s+(?=(?:async\s+function|const|let|class|function)\b)/g, '$1$2'); + } + return transformed.trimEnd(); +} + +function bundleExecutableJavaScriptFile(entryPath, options = {}) { + const orderedFiles = collectJavaScriptFiles(entryPath); + const preserveExports = !!options.preserveExports; + const chunks = []; + for (const filePath of orderedFiles) { + const transformed = transformJavaScriptModuleSource(filePath, { preserveExports }); + if (!transformed) { + continue; + } + chunks.push(transformed); + } + return chunks.join('\n\n').trimEnd() + '\n'; +} + +function readBundledWebUiHtml(entryPath = path.join(__dirname, 'index.html')) { + return bundleHtmlFile(entryPath).trimEnd() + '\n'; +} + +function readBundledWebUiCss(entryPath = path.join(__dirname, 'styles.css')) { + return bundleCssFile(entryPath).trimEnd() + '\n'; +} + +function readBundledWebUiScript(entryPath = path.join(__dirname, 'app.js')) { + return bundleJavaScriptFile(entryPath); +} + +function readExecutableBundledWebUiScript(entryPath = path.join(__dirname, 'app.js')) { + return bundleExecutableJavaScriptFile(entryPath, { preserveExports: false }); +} + +function readExecutableBundledJavaScriptModule(entryPath) { + const resolvedEntryPath = path.isAbsolute(entryPath) + ? entryPath + : path.resolve(__dirname, '..', entryPath); + return bundleExecutableJavaScriptFile(resolvedEntryPath, { preserveExports: true }); +} + +module.exports = { + collectJavaScriptFiles, + readUtf8Text, + readBundledWebUiHtml, + readBundledWebUiCss, + readBundledWebUiScript, + readExecutableBundledWebUiScript, + readExecutableBundledJavaScriptModule +}; diff --git a/web-ui/styles.css b/web-ui/styles.css index 178b412..972f381 100644 --- a/web-ui/styles.css +++ b/web-ui/styles.css @@ -1,4728 +1,14 @@ -@import url('https://fonts.googleapis.com/css2?family=Fira+Mono:wght@400;500&family=JetBrains+Mono:wght@400;500&family=Source+Sans+3:wght@400;500;600&family=Space+Grotesk:wght@400;500;600;700&display=swap'); - -/* ============================================ - 设计系统 - Design Tokens - ============================================ */ -:root { - /* 色彩系统:去除杂纹,强调干净留白与温柔橙红 */ - --color-brand: #D0583A; - --color-brand-dark: #B8442B; - --color-brand-light: rgba(208, 88, 58, 0.14); - --color-brand-subtle: rgba(201, 94, 75, 0.2); - - --color-bg: #F8F2EA; - --color-surface: #FFFDFC; - --color-surface-alt: #FFF8F2; - --color-surface-elevated: #FFFFFF; - --color-surface-tint: rgba(255, 255, 255, 0.84); - --color-text-primary: #1B1714; - --color-text-secondary: #473C34; - --color-text-tertiary: #6F6054; - --color-text-muted: #6C5B50; - --color-border: #D8C9B8; - --color-border-soft: rgba(216, 201, 184, 0.38); - --color-border-strong: rgba(216, 201, 184, 0.68); - - --color-success: #4B8B6A; - --color-error: #C44536; - - --bg-warm-gradient: - linear-gradient(180deg, #F8F2EA 0%, #F8F2EA 100%); - - /* 字体系统 */ - --font-family-body: 'JetBrainsMono Nerd Font Mono', 'OPPO Sans 4.0', 'Fira Mono', 'JetBrains Mono', 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', monospace; - --font-family-display: 'JetBrainsMono Nerd Font Mono', 'OPPO Sans 4.0', 'Fira Mono', 'JetBrains Mono', 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', monospace; - --font-family-mono: 'JetBrainsMono Nerd Font Mono', 'OPPO Sans 4.0', 'Fira Mono', 'JetBrains Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', monospace; - --font-family: var(--font-family-body); - - --font-size-display: 52px; - --font-size-title: 18px; - --font-size-large: 20px; - --font-size-body: 15px; - --font-size-secondary: 13px; - --font-size-caption: 11px; - - --font-weight-display: 600; - --font-weight-primary: 600; - --font-weight-title: 600; - --font-weight-body: 400; - --font-weight-secondary: 500; - --font-weight-caption: 500; - - --line-height-tight: 1.12; - --line-height-normal: 1.5; - - /* 间距系统 */ - --spacing-xs: 8px; - --spacing-sm: 16px; - --spacing-md: 24px; - --spacing-lg: 40px; - --spacing-xl: 64px; - - /* 圆角系统 */ - --radius-sm: 8px; - --radius-md: 10px; - --radius-lg: 12px; - --radius-xl: 18px; - --radius-full: 50px; - - /* 阴影系统 - 多层叠加提升真实感 */ - --shadow-subtle: 0 1px 2px rgba(31, 26, 23, 0.03); - --shadow-card: 0 6px 18px rgba(31, 26, 23, 0.06); - --shadow-card-hover: 0 10px 24px rgba(31, 26, 23, 0.08); - --shadow-float: 0 12px 26px rgba(31, 26, 23, 0.12); - --shadow-raised: 0 10px 20px rgba(31, 26, 23, 0.1); - --shadow-modal: - 0 8px 24px rgba(31, 26, 23, 0.08), - 0 24px 64px rgba(31, 26, 23, 0.06); - --shadow-input-focus: - 0 0 0 3px var(--color-brand-light), - 0 1px 3px rgba(31, 26, 23, 0.04); - - /* 动画 - 更细腻的曲线 */ - --transition-instant: 100ms; - --transition-fast: 120ms; - --transition-normal: 200ms; - --transition-slow: 300ms; - --ease-spring: cubic-bezier(0.16, 1, 0.3, 1); - --ease-spring-soft: cubic-bezier(0.25, 1, 0.5, 1); - --ease-smooth: cubic-bezier(0.4, 0, 0.2, 1); - --ease-out-expo: cubic-bezier(0.19, 1, 0.22, 1); -} - -/* ============================================ - 手机桌面 UA 兜底:触控设备强制紧凑排版 - ============================================ */ -body.force-compact { - --font-size-title: 20px; - --font-size-body: 16px; - --font-size-secondary: 14px; - --font-size-caption: 12px; -} - -body.force-compact .container { - max-width: 760px; - padding: 10px 10px 16px; -} - -body.force-compact .provider-fast-switch { - position: sticky; - top: 8px; - z-index: 16; -} - -body.force-compact .provider-fast-switch-select { - min-height: 44px; - font-size: 16px; -} - -body.force-compact .app-shell { - grid-template-columns: 1fr; - gap: 12px; -} - -body.force-compact .main-panel { - position: relative; - top: auto; - align-self: stretch; - width: 100%; - height: auto; -} - -body.force-compact .side-rail, -body.force-compact .status-inspector { - display: none; -} - -body.force-compact .top-tabs { - display: grid !important; - grid-template-columns: repeat(1, minmax(0, 1fr)); -} - -@media (min-width: 541px) { - body.force-compact .top-tabs { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } -} - -body.force-compact .hero-logo { - display: block; -} - -body.force-compact .hero-github { - display: flex; -} - -body.force-compact .main-panel { - padding: 14px 12px; -} - -body.force-compact .hero-title { - font-size: 34px; -} - -body.force-compact .card { - display: flex; - flex-direction: column; - align-items: flex-start; - justify-content: flex-start; - padding: 12px; - gap: 8px; -} - -body.force-compact .card-leading { - align-items: flex-start; - width: 100%; -} - -body.force-compact .card-content { - width: 100%; -} - -body.force-compact .card-title, -body.force-compact .card-title > span:first-child { - white-space: normal; - overflow: visible; - text-overflow: clip; - overflow-wrap: anywhere; -} - -body.force-compact .card-subtitle { - white-space: normal; - overflow: hidden; - text-overflow: clip; - overflow-wrap: anywhere; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; -} - -body.force-compact .card-trailing { - width: 100%; - margin-top: 0; - grid-auto-flow: row; - grid-auto-columns: 1fr; - justify-content: stretch; - justify-items: end; -} - -body.force-compact .card-trailing .card-actions { - width: 100%; - justify-content: flex-end; - justify-self: stretch; - flex-wrap: wrap; -} - -body.force-compact .card-actions { - opacity: 1; - transform: none; -} - -body.force-compact .card-trailing .pill, -body.force-compact .card-trailing .latency { - justify-self: end; -} - -body.force-compact .btn-add, -body.force-compact .btn-tool, -body.force-compact .card-action-btn { - min-height: 44px; -} - -/* ============================================ - 基础重置 - ============================================ */ -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -/* 仅屏幕阅读器可见 */ -.sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} - -body { - font-family: var(--font-family-body); - background-color: var(--color-bg); - background: var(--bg-warm-gradient); - color: var(--color-text-primary); - display: flex; - justify-content: center; - align-items: center; - min-height: 100vh; - padding: var(--spacing-lg) var(--spacing-md); - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - position: relative; - overflow-x: hidden; -} - -.fab-install { - position: fixed; - left: 16px; - bottom: calc(16px + env(safe-area-inset-bottom, 0px)); - z-index: 90; - display: inline-grid; - place-items: center; - width: 50px; - height: 50px; - min-width: 44px; - min-height: 44px; - padding: 0; - border-radius: var(--radius-full); - border: 1px solid rgba(255, 255, 255, 0.28); - background: - linear-gradient(135deg, rgba(255, 255, 255, 0.18) 0%, rgba(255, 255, 255, 0.04) 100%), - linear-gradient(135deg, var(--color-brand) 0%, var(--color-brand-dark) 100%); - color: #fff; - font-size: var(--font-size-secondary); - font-weight: var(--font-weight-secondary); - letter-spacing: 0.015em; - box-shadow: var(--shadow-float); - cursor: pointer; - overflow: hidden; - transition: - transform var(--transition-fast) var(--ease-spring), - box-shadow var(--transition-fast) var(--ease-spring), - filter var(--transition-fast) var(--ease-smooth); - animation: fabPulse 3.2s ease-in-out infinite; -} - -.fab-install::after { - content: ""; - position: absolute; - inset: 1px; - border-radius: inherit; - border: 1px solid rgba(255, 255, 255, 0.12); - pointer-events: none; -} - -.fab-install-icon { - width: 20px; - height: 20px; - display: inline-grid; - place-items: center; - color: #fff; - background: transparent; - box-shadow: none; - flex-shrink: 0; -} - -.fab-install-icon svg { - width: 18px; - height: 18px; -} - -.fab-install:hover { - transform: translateY(-1px); - box-shadow: var(--shadow-raised); - filter: saturate(1.04); -} - -.fab-install:active { - transform: translateY(0); - filter: saturate(0.98); -} - -@media (max-width: 640px) { - .fab-install { - left: 12px; - bottom: calc(12px + env(safe-area-inset-bottom, 0px)); - width: 44px; - height: 44px; - padding: 0; - font-size: var(--font-size-secondary); - } - - .fab-install-icon { - width: 18px; - height: 18px; - } - - .fab-install-icon svg { - width: 16px; - height: 16px; - } -} - -@keyframes fabPulse { - 0%, - 100% { - box-shadow: var(--shadow-float); - } - 50% { - box-shadow: 0 14px 30px rgba(31, 26, 23, 0.14); - } -} - -@media (prefers-reduced-motion: reduce) { - .fab-install { - animation: none; - transition: none; - } -} - -/* ============================================ - 容器 - ============================================ */ -body::before { - content: ""; - position: fixed; - inset: 0; - background-image: - linear-gradient(180deg, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0)); - opacity: 0.16; - pointer-events: none; - z-index: 0; -} - -/* 背景网格 */ -body::after { - content: ""; - position: fixed; - inset: 0; - background-image: - linear-gradient(90deg, rgba(255, 255, 255, 0.08) 1px, transparent 1px), - linear-gradient(0deg, rgba(255, 255, 255, 0.06) 1px, transparent 1px); - background-size: 180px 180px; - opacity: 0.08; - pointer-events: none; - z-index: 0; -} - -/* ============================================ - 容器 - ============================================ */ -.container { - width: 100%; - max-width: 2200px; - margin: 0 auto; - padding: 16px 12px 28px; - position: relative; - z-index: 1; -} - -/* ============================================ - 布局:三栏(侧栏 + 主区 + 状态检查器) - ============================================ */ -.app-shell { - display: grid; - grid-template-columns: 260px minmax(0, 1fr) 340px; - gap: 16px; - align-items: flex-start; -} - -.app-shell.standalone { - grid-template-columns: 1fr; -} - -.side-rail { - position: sticky; - top: var(--spacing-md); - align-self: start; - display: flex; - flex-direction: column; - gap: var(--spacing-sm); - padding: var(--spacing-md) var(--spacing-sm); - background: linear-gradient(180deg, rgba(255, 255, 255, 0.95) 0%, rgba(255, 250, 245, 0.9) 100%); - border: 1px solid rgba(216, 201, 184, 0.65); - border-radius: var(--radius-xl); - box-shadow: var(--shadow-card); - min-height: 420px; -} - -.side-rail .brand-title { - font-size: 24px; - margin-bottom: 2px; -} - -.side-section { - display: flex; - flex-direction: column; - gap: 10px; -} - -.side-section-title { - font-size: var(--font-size-secondary); - font-weight: var(--font-weight-secondary); - color: var(--color-text-tertiary); - letter-spacing: 0.01em; - padding: 0 var(--spacing-xs); -} - -.side-item { - width: 100%; - text-align: left; - padding: 12px var(--spacing-sm); - border-radius: var(--radius-lg); - border: 1px solid var(--color-border-soft); - background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(255, 247, 240, 0.95) 100%); - color: var(--color-text-secondary); - cursor: pointer; - transition: none; - display: flex; - flex-direction: column; - gap: 6px; - box-shadow: var(--shadow-subtle); -} - -.side-item:hover { - border-color: var(--color-brand); - color: var(--color-text-primary); - transform: translateY(-1px); - box-shadow: var(--shadow-card-hover); -} - -.side-item.active { - border-color: var(--color-brand); - background: linear-gradient(135deg, rgba(201, 94, 75, 0.14), rgba(255, 255, 255, 0.96)); - color: var(--color-text-primary); - box-shadow: var(--shadow-float); -} - -.side-item.nav-intent-active { - border-color: var(--color-brand); - background: linear-gradient(135deg, rgba(201, 94, 75, 0.14), rgba(255, 255, 255, 0.96)); - color: var(--color-text-primary); - box-shadow: var(--shadow-float); -} - -.side-item.nav-intent-inactive, -.side-item.active.nav-intent-inactive { - border-color: var(--color-border-soft); - background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(255, 247, 240, 0.95) 100%); - color: var(--color-text-secondary); - box-shadow: var(--shadow-subtle); -} - -.side-item-title { - font-size: var(--font-size-body); - font-weight: var(--font-weight-secondary); - letter-spacing: -0.01em; -} - -.side-item-meta { - font-size: var(--font-size-caption); - color: var(--color-text-tertiary); - display: flex; - gap: 8px; - flex-wrap: wrap; -} - -.side-item-meta > span { - min-width: 0; - overflow-wrap: anywhere; - word-break: break-word; -} - -.top-tabs { - display: none !important; -} - -.brand-block { - display: grid; - grid-template-columns: 48px 1fr; - grid-template-rows: auto auto; - column-gap: var(--spacing-sm); - row-gap: 2px; - align-items: center; - margin-bottom: var(--spacing-md); -} - -.brand-logo-wrap { - width: 48px; - height: 48px; - border-radius: 14px; - background: rgba(208, 88, 58, 0.08); - border: 1px solid var(--color-border-soft); - display: grid; - place-items: center; - box-shadow: var(--shadow-subtle); - flex-shrink: 0; - grid-row: 1 / span 2; -} - -.brand-logo { - width: 34px; - height: 34px; - object-fit: contain; - display: block; -} - -.brand-title { - font-size: 30px; - line-height: 1.05; - font-family: var(--font-family-display); - color: var(--color-text-primary); - letter-spacing: -0.02em; -} - -.brand-title .accent { - color: var(--color-brand); -} - -.brand-subtitle { - margin-top: 8px; - font-size: var(--font-size-secondary); - color: var(--color-text-tertiary); - line-height: 1.45; -} - -.github-badge { - grid-column: 2; - display: inline-flex; - align-items: center; - justify-content: space-between; - gap: 10px; - margin-top: 6px; - padding: 6px 10px; - border-radius: 999px; - border: 1px solid var(--color-border-soft); - background: linear-gradient(to bottom, rgba(255, 255, 255, 0.92) 0%, rgba(255, 255, 255, 0.72) 100%); - color: var(--color-text-secondary); - font-size: var(--font-size-caption); - text-decoration: none; - box-shadow: var(--shadow-subtle); - transition: all var(--transition-fast) var(--ease-spring); - min-width: 0; -} - -.github-badge-rail { - width: 100%; - align-items: center; - justify-content: flex-start; - gap: 8px; - padding: 6px 8px; - border-radius: 10px; - background: rgba(255, 255, 255, 0.8); - border: 1px solid rgba(216, 201, 184, 0.5); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7); -} - -.github-badge:hover { - border-color: rgba(201, 94, 75, 0.5); - color: var(--color-text-primary); - transform: translateY(-1px); - box-shadow: 0 6px 12px rgba(27, 23, 20, 0.08); -} - -.github-badge-icon { - width: 16px; - height: 16px; - flex-shrink: 0; -} - -.github-badge-left { - display: inline-flex; - align-items: center; - gap: 6px; - flex-shrink: 0; -} - -.github-badge-label { - font-size: var(--font-size-caption); - font-weight: var(--font-weight-secondary); - color: var(--color-text-secondary); -} - -.github-badge-text { - min-width: 0; - font-family: var(--font-family-mono); - font-size: var(--font-size-caption); - color: var(--color-text-tertiary); - line-height: 1.3; - display: inline-block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.github-badge-text > span { - white-space: nowrap; -} - -.github-owner { - font-weight: 600; - color: var(--color-text-secondary); -} - -.github-sep { - margin: 0 2px; - color: var(--color-text-tertiary); -} - -.github-repo { - font-weight: 600; - color: var(--color-text-primary); -} - -.github-badge-rail .github-badge-text { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 2px; - white-space: normal; - overflow: visible; - text-overflow: clip; - color: var(--color-text-secondary); -} - -.github-badge-rail .github-sep { - display: none; -} - -.github-badge-rail .github-owner, -.github-badge-rail .github-repo { - font-weight: 600; -} - -.github-badge-rail .github-badge-left { - padding: 4px; - border-radius: 999px; - background: rgba(201, 94, 75, 0.12); -} - -.github-badge-rail .github-badge-label { - display: none; -} - -.github-badge-rail:hover { - transform: translateY(-1px); - border-color: rgba(201, 94, 75, 0.5); -} - -.main-tabs { - display: flex; - gap: 10px; -} - -.main-tab-btn { - flex: 1; - text-align: center; - border: 1px solid rgba(216, 201, 184, 0.55); - background: rgba(255, 255, 255, 0.95); - border-radius: var(--radius-lg); - padding: 12px 14px; - cursor: pointer; - color: var(--color-text-secondary); - font-size: var(--font-size-body); - font-weight: var(--font-weight-secondary); - box-shadow: var(--shadow-subtle); - transition: all var(--transition-normal) var(--ease-spring); -} - -.main-tab-btn:hover { - border-color: var(--color-brand); - color: var(--color-text-primary); - transform: translateY(-1px); -} - -.main-tab-btn.active { - border-color: var(--color-brand); - box-shadow: 0 10px 24px rgba(27, 23, 20, 0.08); - color: var(--color-text-primary); - background: linear-gradient(135deg, rgba(201, 94, 75, 0.12), rgba(255, 255, 255, 0.95)); -} - -.status-strip { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-xs); - margin-bottom: var(--spacing-sm); - margin-top: 6px; -} - -.status-chip { - min-width: 200px; - padding: 10px 12px; - border-radius: var(--radius-lg); - border: 1px solid var(--color-border-soft); - background: linear-gradient(180deg, rgba(255, 255, 255, 0.99) 0%, rgba(255, 250, 245, 0.97) 100%); - box-shadow: var(--shadow-subtle); -} - -.status-chip .label { - display: block; - font-size: var(--font-size-caption); - color: var(--color-text-tertiary); - margin-bottom: 4px; -} - -.status-chip .value { - font-size: var(--font-size-body); - font-weight: var(--font-weight-secondary); - color: var(--color-text-primary); - letter-spacing: -0.01em; - white-space: normal; - overflow-wrap: anywhere; - word-break: break-word; -} - -.provider-fast-switch { - margin: 0 0 var(--spacing-sm); - padding: 10px 12px; - border-radius: var(--radius-lg); - border: 1px solid rgba(216, 201, 184, 0.6); - background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(255, 249, 243, 0.96) 100%); - box-shadow: var(--shadow-subtle); - display: grid; - gap: 6px; -} - -.provider-fast-switch-label { - font-size: var(--font-size-caption); - color: var(--color-text-tertiary); - letter-spacing: 0.02em; -} - -.provider-fast-switch-select { - width: 100%; - min-height: 40px; - padding: 8px 12px; - padding-right: 38px; - border: 1px solid var(--color-border-soft); - border-radius: var(--radius-sm); - font-size: var(--font-size-body); - color: var(--color-text-primary); - background-color: var(--color-surface-alt); - outline: none; - cursor: pointer; - appearance: none; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='none' stroke='%23505A66' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M2 4l4 4 4-4'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 12px center; - background-size: 12px; -} - -.provider-fast-switch-select:focus { - border-color: var(--color-brand); - box-shadow: var(--shadow-input-focus); -} - -.main-panel { - min-width: 0; - background: rgba(255, 255, 255, 0.95); - border: 1px solid rgba(216, 201, 184, 0.48); - border-radius: 18px; - box-shadow: var(--shadow-card); - padding: var(--spacing-md) var(--spacing-lg); - backdrop-filter: blur(8px); - position: relative; - overflow-x: hidden; - overflow-y: visible; -} - -.status-inspector { - position: sticky; - top: 24px; - align-self: start; - height: calc(100vh - 48px); - overflow: auto; - padding: 16px; - border-radius: var(--radius-xl); - border: 1px solid var(--color-border-soft); - background: linear-gradient(180deg, rgba(255, 255, 255, 0.96) 0%, rgba(255, 248, 241, 0.92) 100%); - box-shadow: var(--shadow-card); - display: flex; - flex-direction: column; - gap: 12px; -} - -.inspector-head { - padding: 2px 2px 8px; - border-bottom: 1px solid rgba(216, 201, 184, 0.35); -} - -.inspector-title { - font-size: 16px; - line-height: 1.25; - font-weight: 600; - color: var(--color-text-primary); -} - -.inspector-subtitle { - margin-top: 4px; - font-size: 12px; - color: var(--color-text-tertiary); -} - -.inspector-group { - padding: 12px; - border-radius: var(--radius-lg); - border: 1px solid rgba(216, 201, 184, 0.34); - background: rgba(255, 255, 255, 0.88); - box-shadow: var(--shadow-subtle); -} - -.inspector-group-title { - font-size: 13px; - font-weight: 600; - color: var(--color-text-secondary); - margin-bottom: 10px; -} - -.inspector-kv { - display: grid; - grid-template-columns: 92px minmax(0, 1fr); - gap: 8px 10px; - align-items: start; -} - -.inspector-kv .key { - font-size: 11px; - line-height: 1.4; - color: var(--color-text-muted); - letter-spacing: 0.01em; - font-family: var(--font-family-mono); -} - -.inspector-kv .value { - font-size: 14px; - line-height: 1.35; - font-weight: 500; - color: var(--color-text-primary); - overflow-wrap: anywhere; - word-break: break-word; -} - -.inspector-kv .value.tone-ok { - color: var(--color-success); -} - -.inspector-kv .value.tone-warn { - color: #8d5b31; -} - -.inspector-kv .value.tone-error { - color: var(--color-error); -} - -.panel-header { - margin-bottom: 12px; - text-align: left; -} - -.hero { - display: flex; - align-items: center; - gap: var(--spacing-sm); - margin-bottom: var(--spacing-sm); -} - -.hero-logo { - display: none; - width: 64px; - height: 64px; - border-radius: 18px; - padding: 8px; - background: var(--color-surface-elevated); - border: 1px solid var(--color-border-soft); - box-shadow: var(--shadow-card); - object-fit: contain; -} - -.hero-title { - font-size: 48px; - line-height: 1.05; - font-family: var(--font-family-display); - color: var(--color-text-primary); - letter-spacing: -0.02em; -} - -.hero-title .accent { - color: var(--color-brand); -} - -.hero-subtitle { - margin-top: 8px; - font-size: var(--font-size-body); - color: var(--color-text-tertiary); - line-height: 1.5; -} - -.hero-github { - display: none; - margin-bottom: var(--spacing-sm); -} - -.top-tabs { - margin: 14px 0 18px; - background: rgba(255, 255, 255, 0.92); - border: 1px solid rgba(255, 255, 255, 0.7); - border-radius: 14px; - padding: 6px; - box-shadow: inset 0 1px 2px rgba(31, 26, 23, 0.06); - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 8px; - backdrop-filter: blur(6px); -} - -.top-tab { - border: 1px solid rgba(216, 201, 184, 0.55); - border-radius: 12px; - background: rgba(255, 255, 255, 0.96); - padding: 11px 10px; - font-size: var(--font-size-body); - color: var(--color-text-secondary); - text-align: center; - cursor: pointer; - transition: none; - box-shadow: var(--shadow-subtle); -} - -.top-tab:hover { - border-color: var(--color-brand); - color: var(--color-text-primary); - transform: translateY(-1px); -} - -.top-tab.active { - border-color: var(--color-brand); - color: var(--color-text-primary); - background: linear-gradient(135deg, rgba(201, 94, 75, 0.12), rgba(255, 255, 255, 0.95)); - box-shadow: 0 10px 24px rgba(27, 23, 20, 0.08); -} - -.top-tab.nav-intent-active { - border-color: var(--color-brand); - color: var(--color-text-primary); - background: linear-gradient(135deg, rgba(201, 94, 75, 0.12), rgba(255, 255, 255, 0.95)); - box-shadow: 0 10px 24px rgba(27, 23, 20, 0.08); -} - -.top-tab.nav-intent-inactive, -.top-tab.active.nav-intent-inactive { - border-color: rgba(216, 201, 184, 0.55); - color: var(--color-text-secondary); - background: rgba(255, 255, 255, 0.96); - box-shadow: var(--shadow-subtle); -} - -#panel-sessions.session-panel-fast-hidden { - display: none !important; -} - -.config-subtabs { - display: flex; - gap: 8px; - margin-bottom: 16px; - padding: 6px; - background: linear-gradient(180deg, rgba(255, 255, 255, 0.85), rgba(255, 255, 255, 0.7)); - border-radius: var(--radius-lg); - border: 1px solid rgba(255, 255, 255, 0.7); - box-shadow: inset 0 1px 2px rgba(31, 26, 23, 0.05); -} - -.config-subtab { - border: 1px solid var(--color-border-soft); - border-radius: var(--radius-lg); - padding: 10px 14px; - background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(255, 250, 245, 0.9)); - color: var(--color-text-secondary); - cursor: pointer; - font-size: var(--font-size-body); - font-weight: var(--font-weight-secondary); - transition: all var(--transition-normal) var(--ease-spring); - box-shadow: var(--shadow-subtle); -} - -.config-subtab:hover { - border-color: var(--color-border-strong); - color: var(--color-text-primary); -} - -.config-subtab.active { - border-color: var(--color-brand); - color: var(--color-text-primary); - background: linear-gradient(135deg, rgba(201, 94, 75, 0.18), rgba(255, 255, 255, 0.95)); - box-shadow: var(--shadow-card); -} - -.settings-subtabs { - margin-bottom: var(--spacing-sm); -} - -.settings-tab-badge { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 18px; - height: 18px; - margin-left: 6px; - padding: 0 6px; - border-radius: 999px; - background: rgba(210, 107, 90, 0.14); - color: var(--color-text-secondary); - font-size: 11px; - line-height: 1; -} - -.content-wrapper { - background: rgba(255, 255, 255, 0.94); - border: 1px solid rgba(216, 201, 184, 0.35); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-subtle); - padding: 0; -} - -.mode-content { - border-radius: var(--radius-lg); - background: rgba(255, 255, 255, 0.9); - box-shadow: var(--shadow-subtle); - padding: 12px; -} - -/* ============================================ - 主标题 - ============================================ */ -.main-title { - font-size: var(--font-size-display); - font-weight: var(--font-weight-display); - line-height: var(--line-height-tight); - letter-spacing: -0.03em; - margin-bottom: 10px; - color: var(--color-text-primary); - font-family: var(--font-family-display); - background: linear-gradient(135deg, var(--color-text-primary) 0%, rgba(27, 23, 20, 0.78) 100%); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; -} - -.main-title .accent { - color: var(--color-brand); - -webkit-text-fill-color: var(--color-brand); - position: relative; -} - -.subtitle { - font-size: var(--font-size-body); - color: var(--color-text-tertiary); - line-height: var(--line-height-normal); - margin-bottom: 20px; - max-width: 640px; - letter-spacing: 0.01em; -} - -/* ============================================ - 模式切换器 - Segmented Control - ============================================ */ -.segmented-control { - display: flex; - background: rgba(255, 255, 255, 0.92); - border-radius: var(--radius-xl); - padding: 6px; - margin-bottom: 20px; - position: relative; - box-shadow: inset 0 1px 2px rgba(31, 26, 23, 0.06); - border: 1px solid rgba(255, 255, 255, 0.7); - backdrop-filter: blur(6px); -} - -.segment { - flex: 1; - padding: 11px 16px; - border: none; - background: transparent; - font-size: var(--font-size-body); - font-weight: var(--font-weight-secondary); - color: var(--color-text-secondary); - cursor: pointer; - border-radius: 10px; - transition: all var(--transition-normal) var(--ease-spring); - position: relative; - z-index: 2; - letter-spacing: 0.01em; -} - -.segment:hover { - color: var(--color-text-primary); -} - -.segment.active { - color: var(--color-text-primary); - background: linear-gradient(180deg, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0.8) 100%); - box-shadow: var(--shadow-subtle), inset 0 1px 0 rgba(255, 255, 255, 0.85); -} - -/* ============================================ - 卡片列表 - ============================================ */ -.card-list { - display: flex; - flex-direction: column; - gap: 12px; - margin-bottom: 12px; -} - -/* ============================================ - 卡片 - ============================================ */ -.card { - background: linear-gradient(180deg, #fffdf9 0%, #fff8f2 100%); - border-radius: var(--radius-lg); - padding: 10px; - display: flex; - align-items: center; - justify-content: space-between; - cursor: pointer; - transition: - transform var(--transition-normal) var(--ease-spring), - box-shadow var(--transition-normal) var(--ease-spring), - background-color var(--transition-fast) var(--ease-smooth); - box-shadow: 0 10px 24px rgba(27, 23, 20, 0.08); - user-select: none; - will-change: transform; - border: 1px solid rgba(216, 201, 184, 0.55); - position: relative; - overflow: hidden; -} - -.card:hover { - transform: translateY(-1px); - box-shadow: var(--shadow-card-hover); -} - -.card::before, -.card::after { - content: ""; - position: absolute; - pointer-events: none; -} - -.card::before { - left: 0; - top: 10px; - bottom: 10px; - width: 3px; - border-radius: 999px; - background: transparent; - transition: background var(--transition-fast) var(--ease-smooth); -} - -.card::after { - inset: 0; - border-radius: inherit; - background: linear-gradient(120deg, rgba(255, 255, 255, 0.7) 0%, transparent 55%); - opacity: 0; - transition: opacity var(--transition-normal) var(--ease-smooth); -} - -.card:active { - transform: translateY(0); - transition: transform var(--transition-instant) var(--ease-smooth); -} - -.card.active { - background: linear-gradient(to bottom, rgba(210, 107, 90, 0.14) 0%, rgba(255, 255, 255, 0.98) 100%); - border-color: rgba(201, 94, 75, 0.55); - box-shadow: 0 10px 28px rgba(210, 107, 90, 0.14); -} - -.card.active::before { - background: linear-gradient(180deg, rgba(201, 94, 75, 0.95) 0%, rgba(201, 94, 75, 0.35) 100%); -} - -.card:hover::after { - opacity: 0.6; -} - -.card.active .card-icon { - transform: scale(1.05); -} - -.card-leading { - display: flex; - align-items: center; - gap: var(--spacing-sm); - flex: 1; - min-width: 0; -} - -.card-icon { - width: 40px; - height: 40px; - border-radius: var(--radius-sm); - background: linear-gradient(135deg, rgba(255, 255, 255, 0.9) 0%, rgba(247, 241, 232, 0.65) 100%); - display: flex; - align-items: center; - justify-content: center; - font-size: var(--font-size-title); - font-weight: var(--font-weight-title); - color: var(--color-text-secondary); - flex-shrink: 0; - transition: all var(--transition-normal) var(--ease-spring-soft); - box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.7); -} - -.card.active .card-icon { - background: linear-gradient(135deg, var(--color-brand) 0%, var(--color-brand-dark) 100%); - color: white; - box-shadow: 0 2px 8px rgba(210, 107, 90, 0.3); -} - -.card-content { - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; -} - -.card-title { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; - font-size: var(--font-size-body); - font-weight: var(--font-weight-secondary); - color: var(--color-text-primary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - letter-spacing: -0.01em; -} - -.card-title > span:first-child { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.provider-readonly-badge { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 2px 8px; - border-radius: var(--radius-full); - font-size: 11px; - line-height: 1; - color: #6f4b00; - background: linear-gradient(135deg, rgba(246, 211, 106, 0.32) 0%, rgba(246, 211, 106, 0.2) 100%); - border: 1px solid rgba(191, 151, 40, 0.35); - flex-shrink: 0; -} - -.card-subtitle { - font-size: var(--font-size-secondary); - color: var(--color-text-tertiary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - opacity: 0.8; -} - -.card-trailing { - display: grid; - grid-auto-flow: column; - grid-auto-columns: max-content; - column-gap: var(--spacing-xs); - row-gap: 6px; - align-items: center; - justify-content: end; - align-self: center; -} - -.card-trailing .card-actions { - margin-left: 0; - justify-self: end; -} - -.card-trailing .pill, -.card-trailing .latency { - justify-self: end; -} - -/* 卡片操作按钮 - hover 显示 */ -.card-actions { - display: flex; - gap: 8px; - opacity: 0; - transform: translateX(4px); - transition: all var(--transition-normal) var(--ease-spring); -} - -.card:hover .card-actions { - opacity: 1; - transform: translateX(0); -} - -.mode-cards .card-actions { - opacity: 1; - transform: translateX(0); -} - -.card-action-btn { - width: 40px; - height: 40px; - border-radius: 10px; - border: 1px solid rgba(70, 86, 110, 0.22); - background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(255, 255, 255, 0.9)); - color: var(--color-text-secondary); - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: all var(--transition-fast) var(--ease-spring); - box-shadow: inset 0 1px 2px rgba(31, 26, 23, 0.04); -} - -.card-action-btn:hover { - background: linear-gradient(135deg, rgba(210, 107, 90, 0.08) 0%, rgba(255, 255, 255, 0.95) 100%); - color: var(--color-text-primary); - transform: translateY(-1px); -} - -.card-action-btn.delete:hover { - background: linear-gradient(135deg, rgba(200, 74, 58, 0.1) 0%, rgba(200, 74, 58, 0.05) 100%); - color: var(--color-error); -} - -.card-action-btn:disabled, -.card-action-btn.disabled { - opacity: 0.45; - cursor: not-allowed; - transform: none; - filter: grayscale(0.1); -} - -.card-action-btn.delete:disabled:hover, -.card-action-btn.delete.disabled:hover { - background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(255, 255, 255, 0.9)); - color: var(--color-text-secondary); -} - -.card-action-btn svg { - width: 18px; - height: 18px; -} - -/* ============================================ - 状态徽章 - ============================================ */ -.pill { - padding: 5px 11px; - border-radius: var(--radius-full); - font-size: var(--font-size-caption); - font-weight: var(--font-weight-caption); - background-color: rgba(255, 255, 255, 0.8); - color: var(--color-text-tertiary); - text-transform: uppercase; - letter-spacing: 0.06em; - transition: all var(--transition-fast) var(--ease-smooth); - box-shadow: inset 0 0.5px 1px rgba(0, 0, 0, 0.04); -} - -.pill.configured { - background: linear-gradient(135deg, rgba(90, 139, 106, 0.15) 0%, rgba(90, 139, 106, 0.08) 100%); - color: var(--color-success); - box-shadow: inset 0 0.5px 1px rgba(90, 139, 106, 0.2); -} - -.pill.empty { - background: linear-gradient(135deg, rgba(200, 74, 58, 0.1) 0%, rgba(200, 74, 58, 0.05) 100%); - color: var(--color-error); - box-shadow: inset 0 0.5px 1px rgba(200, 74, 58, 0.15); -} - -.latency { - padding: 4px 10px; - border-radius: var(--radius-full); - font-size: var(--font-size-caption); - font-weight: var(--font-weight-caption); - background: var(--color-bg); - color: var(--color-text-tertiary); - letter-spacing: 0.02em; - min-width: 64px; - text-align: center; - display: inline-flex; - align-items: center; - justify-content: center; - flex-shrink: 0; -} - -.latency.ok { - color: var(--color-success); - background: rgba(90, 139, 106, 0.1); -} - -.latency.error { - color: var(--color-error); - background: rgba(200, 74, 58, 0.08); -} - -.card-action-btn.loading svg { - animation: spin 0.9s linear infinite; -} - -/* ============================================ - 图标 - SVG 优化 - ============================================ */ -.icon { - width: 20px; - height: 20px; - flex-shrink: 0; - stroke-linecap: round; - stroke-linejoin: round; -} - -.icon-chevron-right { - color: var(--color-text-tertiary); - opacity: 0.5; -} - -/* ============================================ - 选择器 - 用于模型选择 - ============================================ */ -.selector-section { - background: linear-gradient(to bottom, var(--color-surface) 0%, rgba(255, 255, 255, 0.92) 100%); - border-radius: var(--radius-lg); - padding: calc(var(--spacing-sm) + 2px); - margin-bottom: 16px; - box-shadow: var(--shadow-card); - border: 1px solid var(--color-border-soft); - display: flex; - flex-direction: column; - gap: var(--spacing-xs); -} - -.selector-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: var(--spacing-xs); -} - -.settings-tab-header { - justify-content: flex-end; - align-items: center; -} - -.settings-tab-actions { - display: flex; - flex-wrap: wrap; - gap: 8px; - justify-content: flex-end; -} - -.settings-tab-actions .btn-tool, -.settings-tab-actions .btn-tool-compact { - width: auto; -} - -.trash-header-actions { - display: flex; - flex-direction: row; - flex-wrap: nowrap; - align-items: stretch; - justify-content: flex-end; - width: auto; - max-width: 100%; - margin-left: auto; -} - -.selector-header .trash-header-actions > .btn-tool, -.selector-header .trash-header-actions > .btn-tool-compact { - display: flex; - align-items: center; - justify-content: center; - align-self: stretch; - margin: 0; - width: auto; - min-width: 0; - max-width: 100%; - height: 32px; - min-height: 32px; - padding: 0 10px; - line-height: 1; - vertical-align: top; - position: relative; - top: 0; - white-space: nowrap; -} - -.selector-header .trash-header-actions > .btn-tool:hover, -.selector-header .trash-header-actions > .btn-tool-compact:hover { - transform: none; -} - -.selector-title { - font-size: var(--font-size-caption); - font-weight: var(--font-weight-secondary); - color: var(--color-text-muted); - text-transform: none; - letter-spacing: 0.04em; - opacity: 0.85; -} - -.selector-actions { - display: flex; - gap: var(--spacing-xs); -} - -.health-report { - margin-top: 10px; - padding: 10px 12px; - border-radius: var(--radius-md); - border: 1px solid var(--color-border-soft); - background: var(--color-surface-alt); - display: grid; - gap: 8px; -} - -.health-remote-toggle { - display: inline-flex; - align-items: center; - gap: 8px; - font-size: var(--font-size-caption); - color: var(--color-text-secondary); -} - -.health-remote-toggle input { - accent-color: var(--color-brand); -} - -.health-ok { - color: var(--color-success); - font-weight: var(--font-weight-secondary); -} - -.health-issue { - background: #fff6f5; - border-left: 3px solid var(--color-error); - padding: 8px 10px; - border-radius: 10px; -} - -.health-issue-title { - font-size: var(--font-size-caption); - font-weight: var(--font-weight-secondary); - color: var(--color-text-primary); - margin-bottom: 4px; -} - -.health-issue-suggestion { - font-size: var(--font-size-caption); - color: var(--color-text-secondary); - line-height: 1.4; -} - -.btn-icon { - width: 28px; - height: 28px; - border-radius: var(--radius-sm); - border: none; - background: linear-gradient(135deg, var(--color-brand) 0%, var(--color-brand-dark) 100%); - color: white; - cursor: pointer; - font-size: 16px; - display: flex; - align-items: center; - justify-content: center; - transition: all var(--transition-fast) var(--ease-spring); - box-shadow: 0 2px 4px rgba(210, 107, 90, 0.2); -} - -.btn-icon:hover { - transform: translateY(-1px) scale(1.05); - box-shadow: 0 4px 8px rgba(210, 107, 90, 0.25); -} - -.btn-icon:active { - transform: translateY(0) scale(0.98); -} - -.model-select { - width: 100%; - padding: 12px var(--spacing-sm); - padding-right: 40px; - border: 1px solid var(--color-border-soft); - border-radius: var(--radius-sm); - font-size: var(--font-size-body); - font-weight: var(--font-weight-body); - background-color: var(--color-surface-alt); - color: var(--color-text-primary); - outline: none; - cursor: pointer; - appearance: none; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='none' stroke='%23505A66' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M2 4l4 4 4-4'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 14px center; - background-size: 12px; - transition: all var(--transition-fast) var(--ease-smooth); - box-shadow: inset 0 1px 2px rgba(31, 26, 23, 0.04); -} - -.model-select:hover { - border-color: var(--color-border-strong); - background-color: var(--color-surface); -} - -.model-select:focus { - background-color: var(--color-surface); - border-color: var(--color-brand); - box-shadow: var(--shadow-input-focus); -} - -.model-input { - width: 100%; - padding: 12px var(--spacing-sm); - border: 1px solid var(--color-border-soft); - border-radius: var(--radius-sm); - font-size: var(--font-size-body); - font-weight: var(--font-weight-body); - background-color: var(--color-surface-alt); - color: var(--color-text-primary); - outline: none; - transition: all var(--transition-fast) var(--ease-smooth); - box-shadow: inset 0 1px 2px rgba(31, 26, 23, 0.04); -} - -.model-input:hover { - border-color: var(--color-border-strong); - background-color: var(--color-surface); -} - -.model-input:focus { - background-color: var(--color-surface); - border-color: var(--color-brand); - box-shadow: var(--shadow-input-focus); -} - -.config-template-hint { - margin-top: 8px; - margin-bottom: 10px; - font-size: var(--font-size-caption); - color: var(--color-text-tertiary); - line-height: 1.4; -} - -.codex-config-grid { - display: grid; - gap: var(--spacing-sm); - grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr)); - align-items: start; -} - -.codex-config-field { - min-width: 0; - margin-bottom: 0; -} - -.btn-template-editor { - width: 100%; - margin-top: 2px; -} - -/* ============================================ - 按钮 - ============================================ */ -.btn-add { - width: 100%; - padding: 14px var(--spacing-sm); - border: 1.5px dashed rgba(208, 196, 182, 0.6); - border-radius: var(--radius-lg); - background: linear-gradient(to bottom, rgba(255, 255, 255, 0.55) 0%, rgba(255, 255, 255, 0.15) 100%); - font-size: var(--font-size-body); - font-weight: var(--font-weight-secondary); - color: var(--color-text-tertiary); - cursor: pointer; - transition: all var(--transition-normal) var(--ease-spring); - display: flex; - align-items: center; - justify-content: center; - gap: var(--spacing-xs); -} - -.btn-add + .selector-section, -.selector-section + .btn-add, -.btn-add + .card-list, -.card-list + .btn-add { - margin-top: 12px; -} - -.btn-add:hover { - border-color: var(--color-brand); - color: var(--color-brand); - background: linear-gradient(to bottom, rgba(210, 107, 90, 0.05) 0%, rgba(210, 107, 90, 0.02) 100%); - transform: translateY(-1px); -} - -.btn-add:active { - transform: translateY(0) scale(0.99); -} - -.btn-add .icon { - width: 18px; - height: 18px; - transition: transform var(--transition-normal) var(--ease-spring); -} - -.btn-add:hover .icon { - transform: rotate(90deg); -} - -.btn-tool { - padding: 12px var(--spacing-sm); - border-radius: var(--radius-sm); - border: 1px solid var(--color-border-soft); - background: linear-gradient(to bottom, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0.85) 100%); - font-size: var(--font-size-body); - font-weight: var(--font-weight-secondary); - color: var(--color-text-secondary); - cursor: pointer; - transition: all var(--transition-fast) var(--ease-spring); - box-shadow: var(--shadow-subtle); - letter-spacing: -0.01em; - width: 100%; - text-align: center; -} - -.selector-section .btn-tool + .btn-tool { - margin-left: 0; - margin-top: var(--spacing-xs); -} - -.selector-header .trash-header-actions > .btn-tool + .btn-tool { - margin-top: 0; -} - -.btn-tool:hover { - border-color: var(--color-brand); - color: var(--color-brand); - transform: translateY(-1px); - box-shadow: 0 4px 8px rgba(210, 107, 90, 0.12); -} - -.btn-tool-compact { - padding: 9px 12px; - font-size: var(--font-size-secondary); -} - -.selector-header .btn-tool-compact { - padding: 6px 10px; - font-size: var(--font-size-caption); - line-height: 1.1; - box-shadow: var(--shadow-subtle); -} - -.session-toolbar { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: var(--spacing-xs); - margin-bottom: var(--spacing-sm); - align-items: end; -} - -.session-toolbar-group { - display: flex; - align-items: center; - gap: var(--spacing-xs); - flex-wrap: wrap; - min-width: 0; -} - -.session-toolbar-grow { - grid-column: span 2; -} - -.session-toolbar-actions { - justify-content: flex-end; -} - -.session-toolbar-footer { - display: flex; - align-items: center; - justify-content: flex-end; - gap: var(--spacing-xs); - margin-top: -2px; - padding-top: 6px; - margin-bottom: 12px; - border-top: 1px dashed var(--color-border-soft); -} - -.session-toolbar-footer .quick-option { - margin: 0; - padding: 6px 10px; - border-radius: var(--radius-sm); - border: 1px solid var(--color-border-soft); - background: linear-gradient(to bottom, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0.8) 100%); - box-shadow: inset 0 1px 2px rgba(31, 26, 23, 0.04); - transition: all var(--transition-fast) var(--ease-spring); - line-height: 1.2; -} - -.session-toolbar-footer .quick-option:hover { - border-color: var(--color-border-strong); -} - -.session-source-select, -.session-path-select, -.session-query-input, -.session-role-select, -.session-time-select { - flex: 1; - min-width: 160px; - padding: 10px 12px; - border-radius: var(--radius-sm); - border: 1px solid var(--color-border-soft); - background: linear-gradient(to bottom, var(--color-surface) 0%, rgba(255, 255, 255, 0.92) 100%); - color: var(--color-text-secondary); - font-size: var(--font-size-body); - font-family: var(--font-family); - outline: none; - transition: all var(--transition-fast) var(--ease-spring); - box-shadow: inset 0 1px 2px rgba(31, 26, 23, 0.04); -} - -.session-query-input { - flex: 2; - min-width: 220px; -} - -.session-source-select:hover, -.session-path-select:hover, -.session-query-input:hover, -.session-role-select:hover, -.session-time-select:hover { - border-color: var(--color-border-strong); -} - -.session-source-select:focus, -.session-path-select:focus, -.session-query-input:focus, -.session-role-select:focus, -.session-time-select:focus { - border-color: var(--color-brand); - box-shadow: var(--shadow-input-focus); -} - -.session-hint { - font-size: var(--font-size-secondary); - color: var(--color-text-tertiary); - margin-bottom: 12px; - line-height: 1.45; -} - -.session-card { - align-items: flex-start; - cursor: default; -} - -.session-card:hover { - transform: none; - box-shadow: var(--shadow-card); -} - -.session-card .card-leading { - align-items: flex-start; -} - -.session-meta { - margin-top: 6px; - font-size: var(--font-size-caption); - color: var(--color-text-tertiary); - line-height: 1.4; - word-break: break-all; -} - -.session-actions { - display: flex; - gap: var(--spacing-xs); - align-items: center; - margin-left: var(--spacing-sm); - flex-shrink: 0; -} - -.session-source { - font-size: var(--font-size-caption); - color: var(--color-text-tertiary); - border: 1px solid var(--color-border-soft); - border-radius: 999px; - padding: 2px 8px; - background: var(--color-surface-alt); - white-space: nowrap; -} - -.session-count-badge { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 22px; - height: 22px; - padding: 0 7px; - border-radius: 999px; - background: linear-gradient(135deg, var(--color-brand) 0%, var(--color-brand-dark) 100%); - color: #fff; - font-size: var(--font-size-caption); - font-weight: var(--font-weight-secondary); - line-height: 1; - box-shadow: 0 4px 10px rgba(208, 88, 58, 0.16); - flex-shrink: 0; -} - -.trash-list-footer { - display: flex; - justify-content: center; - margin-top: var(--spacing-sm); -} - -.btn-session-export, -.btn-session-open, -.btn-session-clone, -.btn-session-refresh { - border: 1px solid var(--color-border-soft); - border-radius: var(--radius-sm); - background: linear-gradient(to bottom, var(--color-surface) 0%, rgba(255, 255, 255, 0.9) 100%); - color: var(--color-text-secondary); - padding: 8px 12px; - font-size: var(--font-size-secondary); - font-weight: var(--font-weight-secondary); - cursor: pointer; - transition: all var(--transition-fast) var(--ease-spring); - white-space: nowrap; - box-shadow: var(--shadow-subtle); - letter-spacing: -0.01em; -} - -.btn-session-delete { - border: 1px solid rgba(189, 70, 68, 0.45); - border-radius: var(--radius-sm); - background: linear-gradient(to bottom, rgba(255, 245, 245, 0.95) 0%, rgba(255, 255, 255, 0.9) 100%); - color: #b74545; - padding: 8px 12px; - font-size: var(--font-size-secondary); - font-weight: var(--font-weight-secondary); - cursor: pointer; - transition: all var(--transition-fast) var(--ease-spring); - white-space: nowrap; - box-shadow: var(--shadow-subtle); - letter-spacing: -0.01em; -} - -.btn-session-refresh:hover { - border-color: var(--color-brand); - color: var(--color-brand); - transform: translateY(-1px); -} - -.btn-session-refresh:disabled { - opacity: 0.5; - cursor: not-allowed; - transform: none; -} - -.btn-session-export:hover, -.btn-session-open:hover { - border-color: var(--color-brand); - color: var(--color-brand); - transform: translateY(-1px); -} - -.btn-session-export:disabled, -.btn-session-open:disabled { - opacity: 0.5; - cursor: not-allowed; - transform: none; -} - -.btn-session-clone:hover { - border-color: var(--color-brand); - color: var(--color-brand); - transform: translateY(-1px); -} - -.btn-session-clone:disabled { - opacity: 0.5; - cursor: not-allowed; - transform: none; -} - -.btn-session-delete:hover { - border-color: rgba(189, 70, 68, 0.8); - color: #9f3b3b; - transform: translateY(-1px); -} - -.btn-session-delete:disabled { - opacity: 0.5; - cursor: not-allowed; - transform: none; -} - -.session-empty { - padding: 28px var(--spacing-sm); - text-align: center; - border: 1px dashed var(--color-border-soft); - border-radius: var(--radius-lg); - color: var(--color-text-tertiary); - background: var(--bg-warm-gradient); - position: relative; - box-shadow: var(--shadow-subtle); -} - -.session-empty::before { - content: ""; - display: block; - width: 36px; - height: 36px; - border-radius: 50%; - margin: 0 auto 10px; - background: rgba(210, 107, 90, 0.12); - box-shadow: inset 0 0 0 6px rgba(255, 255, 255, 0.7); -} - -.trash-list { - display: grid; - gap: 12px; - margin-top: 12px; -} - -.trash-item.session-item { - min-height: auto; - height: auto; - cursor: default; - content-visibility: visible; - contain-intrinsic-size: auto; -} - -.trash-item.session-item:hover, -.trash-item.session-item:active { - transform: none; - border-color: var(--color-border-soft); - background: linear-gradient(to bottom, var(--color-surface) 0%, rgba(255, 255, 255, 0.92) 100%); - box-shadow: var(--shadow-subtle); -} - -.trash-item.session-item::before { - background: linear-gradient(180deg, rgba(70, 86, 110, 0.26), rgba(70, 86, 110, 0.08)); -} - -.trash-item-main { - min-width: 0; - flex: 1; -} - -.trash-item-mainline { - display: flex; - align-items: flex-start; - gap: 8px; -} - -.trash-item-title { - flex: 1; - font-size: var(--font-size-body); - font-weight: var(--font-weight-secondary); - color: var(--color-text-primary); - line-height: 1.4; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - white-space: normal; - overflow: hidden; - overflow-wrap: anywhere; -} - -.trash-item-meta { - margin-top: 6px; -} - -.trash-item-side { - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 8px; - flex-shrink: 0; - min-width: 132px; -} - -.trash-item-actions { - display: grid; - grid-template-columns: repeat(2, minmax(108px, 108px)); - align-self: flex-end; - justify-content: flex-end; - gap: 8px; -} - -.trash-item-actions .btn-mini { - width: 100%; - min-height: 36px; - display: inline-flex; - align-items: center; - justify-content: center; -} - -.trash-item-time { - width: 100%; - text-align: right; - white-space: nowrap; - color: var(--color-text-tertiary); -} - -.trash-item-path { - margin-top: 8px; - display: grid; - grid-template-columns: 48px minmax(0, 1fr); - gap: 8px; - align-items: start; - white-space: normal; - overflow-wrap: anywhere; -} - -.trash-item-label { - display: inline-block; - color: var(--color-text-secondary); - font-weight: var(--font-weight-secondary); -} - -.trash-item-path span:last-child { - min-width: 0; - word-break: break-word; -} - -.trash-item .session-count-badge { - margin-top: 2px; -} - -.session-layout { - display: grid; - grid-template-columns: minmax(260px, 360px) minmax(0, 1fr); - gap: var(--spacing-sm); - align-items: start; - height: min(72vh, 760px); - min-height: 520px; -} - -.session-layout.session-standalone { - grid-template-columns: minmax(0, 1fr); -} - -.session-standalone-page { - max-width: 960px; - margin: 0 auto; - padding: var(--spacing-sm) 0; -} - -.session-standalone-title { - font-size: var(--font-size-title); - font-weight: var(--font-weight-title); - color: var(--color-text-primary); - margin-bottom: var(--spacing-sm); - letter-spacing: -0.01em; -} - -.session-standalone-text { - white-space: pre-wrap; - font-family: var(--font-family-body); - font-size: var(--font-size-body); - line-height: 1.7; - color: var(--color-text-primary); - word-break: break-word; -} - -.session-list { - display: flex; - flex-direction: column; - gap: var(--spacing-xs); - position: sticky; - top: 12px; - height: 100%; - max-height: none; - overflow-y: auto; - overflow-x: hidden; - padding-right: 4px; - min-width: 0; - scrollbar-width: thin; - scrollbar-color: rgba(166, 149, 130, 0.85) transparent; -} - -.session-list::-webkit-scrollbar, -.session-preview-scroll::-webkit-scrollbar { - width: 10px; - height: 10px; -} - -.session-list::-webkit-scrollbar-track, -.session-preview-scroll::-webkit-scrollbar-track { - background: transparent; - border-radius: 999px; -} - -.session-list::-webkit-scrollbar-thumb, -.session-preview-scroll::-webkit-scrollbar-thumb { - background: linear-gradient(to bottom, rgba(191, 174, 154, 0.95) 0%, rgba(160, 141, 121, 0.95) 100%); - border-radius: 999px; - border: 2px solid rgba(255, 255, 255, 0.9); -} - -.session-list::-webkit-scrollbar-thumb:hover, -.session-preview-scroll::-webkit-scrollbar-thumb:hover { - background: linear-gradient(to bottom, rgba(175, 156, 136, 0.95) 0%, rgba(145, 126, 107, 0.95) 100%); -} - -.session-item { - border: 1px solid var(--color-border-soft); - border-radius: var(--radius-sm); - background: linear-gradient(to bottom, var(--color-surface) 0%, rgba(255, 255, 255, 0.92) 100%); - padding: 16px; - cursor: pointer; - transition: all var(--transition-fast) var(--ease-spring); - user-select: none; - min-width: 0; - position: relative; - overflow: hidden; - min-height: 102px; - content-visibility: auto; - contain-intrinsic-size: 102px; -} - -.session-item-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - margin-bottom: 4px; -} - -.session-item-main { - min-width: 0; - flex: 1; - display: flex; - align-items: center; - gap: 8px; -} - -.session-item:hover { - border-color: var(--color-brand); - background: linear-gradient(to bottom, rgba(210, 107, 90, 0.08) 0%, rgba(255, 255, 255, 0.98) 100%); - transform: translateY(-1px); -} - -.session-item::before { - content: ""; - position: absolute; - left: 0; - top: 10px; - bottom: 10px; - width: 3px; - border-radius: 999px; - background: rgba(210, 107, 90, 0.15); - transition: background var(--transition-fast) var(--ease-spring); -} - -.session-item:active { - transform: scale(0.99); -} - -.session-item.active { - border-color: var(--color-brand); - background: linear-gradient(to bottom, rgba(210, 107, 90, 0.1) 0%, rgba(255, 255, 255, 0.98) 100%); - box-shadow: 0 6px 16px rgba(210, 107, 90, 0.12); -} - -.session-item.pinned { - border-color: rgba(208, 88, 58, 0.42); - background: linear-gradient(to bottom, rgba(210, 107, 90, 0.12) 0%, rgba(255, 251, 247, 0.98) 100%); - box-shadow: 0 8px 18px rgba(210, 107, 90, 0.10); -} - -.session-item.pinned::before { - background: linear-gradient(180deg, rgba(201, 94, 75, 0.8), rgba(201, 94, 75, 0.32)); -} - -.session-item.active::before { - background: linear-gradient(180deg, rgba(201, 94, 75, 0.9), rgba(201, 94, 75, 0.4)); -} - -.session-item-title { - font-size: var(--font-size-body); - font-weight: var(--font-weight-secondary); - color: var(--color-text-primary); - line-height: 1.4; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - white-space: normal; - overflow: hidden; - flex: 1; - max-width: none; -} - -.session-item-actions { - display: inline-flex; - align-items: center; - gap: 6px; - flex-shrink: 0; -} - -.session-item-copy { - border: 1px solid rgba(70, 86, 110, 0.35); - background: rgba(70, 86, 110, 0.08); - color: var(--color-text-secondary); - width: 28px; - height: 28px; - border-radius: 8px; - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; - flex-shrink: 0; - transition: all var(--transition-fast) var(--ease-spring); -} - -.session-item-copy:hover { - border-color: rgba(70, 86, 110, 0.7); - background: rgba(70, 86, 110, 0.16); - color: var(--color-text-primary); - transform: translateY(-1px); -} - -.session-item-copy:disabled { - opacity: 0.5; - cursor: not-allowed; - transform: none; -} - -.session-item-copy svg { - width: 16px; - height: 16px; -} - -.session-item-pin { - border-color: rgba(208, 88, 58, 0.24); -} - -.session-item-pin .pin-icon, -.session-item-pin svg { - color: rgba(208, 88, 58, 0.78); -} - -.session-item.pinned .session-item-pin { - background: rgba(208, 88, 58, 0.16); - border-color: rgba(208, 88, 58, 0.46); - box-shadow: inset 0 0 0 1px rgba(208, 88, 58, 0.08); -} - -.session-item.pinned .session-item-pin .pin-icon, -.session-item.pinned .session-item-pin svg { - color: var(--color-brand-dark); -} - -.session-item-sub.session-item-snippet, -.session-preview-meta, -.session-preview-title { - display: none !important; -} - -.session-item-sub { - font-size: var(--font-size-caption); - color: var(--color-text-tertiary); - line-height: 1.35; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.session-item-sub.session-item-wrap { - white-space: normal; -} - -.session-item-meta { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 6px; - margin-top: 2px; - margin-bottom: 2px; -} - -.session-item-time { - font-size: var(--font-size-caption); - color: var(--color-text-tertiary); - white-space: nowrap; -} - -.session-preview { - border: 1px solid var(--color-border-soft); - border-radius: var(--radius-xl); - background: linear-gradient(to bottom, var(--color-surface-elevated) 0%, rgba(255, 255, 255, 0.96) 100%); - box-shadow: var(--shadow-card); - min-height: 0; - max-height: none; - height: 100%; - display: flex; - flex-direction: column; - overflow: hidden; - position: relative; - transform: translateX(4px); - transition: transform var(--transition-normal) var(--ease-spring-soft), box-shadow var(--transition-normal) var(--ease-spring-soft); -} - -.session-preview.active { - box-shadow: var(--shadow-float); - transform: translateX(0); -} - -.session-preview-scroll { - position: relative; - flex: 1; - min-height: 0; - overflow-y: auto; - overflow-x: hidden; - padding-right: 68px; - display: flex; - flex-direction: column; - scrollbar-width: thin; - scrollbar-color: rgba(166, 149, 130, 0.85) transparent; -} - -.session-preview-header { - padding: 12px var(--spacing-sm); - border-bottom: 1px solid var(--color-border-soft); - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: var(--spacing-sm); - position: sticky; - top: 0; - z-index: 2; - background: linear-gradient(to bottom, rgba(255, 255, 255, 0.98) 0%, rgba(255, 255, 255, 0.92) 100%); - backdrop-filter: blur(6px); -} - -.session-preview-header > div:first-child { - min-width: 0; - flex: 1; -} - -.session-preview-title { - font-size: var(--font-size-body); - font-weight: var(--font-weight-title); - color: var(--color-text-primary); - line-height: 1.4; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - word-break: break-word; -} - -.session-preview-sub { - margin-top: 4px; - font-size: var(--font-size-caption); - color: var(--color-text-tertiary); - line-height: 1.35; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.session-preview-meta { - display: flex; - flex-wrap: wrap; - align-items: center; - margin-top: 4px; -} - -.session-preview-meta-item { - font-size: var(--font-size-caption); - color: var(--color-text-tertiary); - line-height: 1.35; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.session-preview-meta-item:not(:last-child)::after { - content: "·"; - margin: 0 6px; - color: var(--color-text-tertiary); - opacity: 0.7; -} - -.session-actions { - display: flex; - align-items: center; - gap: 8px; - flex: 0 1 auto; - max-width: 100%; - margin-left: 0; - flex-wrap: wrap; - justify-content: flex-end; -} - -.session-preview-body { - flex: 1; - min-height: 0; - padding: var(--spacing-sm); - display: flex; - flex-direction: column; - gap: 10px; -} - -.session-preview-messages { - min-width: 0; - display: flex; - flex-direction: column; - gap: 10px; - contain: layout style; -} - -.session-timeline { - position: absolute; - top: var(--session-preview-header-offset, 72px); - right: 8px; - bottom: 12px; - width: 56px; - height: auto; - border-radius: 12px; - border: 1px solid rgba(208, 196, 182, 0.5); - background: linear-gradient(to bottom, rgba(255, 255, 255, 0.92) 0%, rgba(252, 246, 239, 0.94) 100%); - box-shadow: 0 4px 12px rgba(31, 26, 23, 0.06); - padding: 6px 4px 28px; - z-index: 3; -} - -.session-timeline-track { - position: absolute; - left: 50%; - top: 10px; - bottom: 32px; - width: 2px; - transform: translateX(-50%); - background: linear-gradient(to bottom, rgba(166, 149, 130, 0.3) 0%, rgba(166, 149, 130, 0.65) 100%); - border-radius: 999px; -} - -.session-timeline-node { - position: absolute; - left: 50%; - width: 10px; - height: 10px; - border-radius: 50%; - border: 1px solid rgba(139, 118, 104, 0.7); - background: rgba(255, 255, 255, 0.94); - transform: translate(-50%, -50%); - cursor: pointer; - padding: 0; - transition: none; - will-change: auto; -} - -.session-timeline-node:hover { - transform: translate(-50%, -50%); - border-color: rgba(201, 94, 75, 0.85); - background: rgba(255, 255, 255, 1); - box-shadow: none; -} - -.session-timeline-node.active { - transform: translate(-50%, -50%); - border-color: rgba(201, 94, 75, 0.95); - background: rgba(201, 94, 75, 0.95); - box-shadow: none; -} - -.session-timeline-current { - position: absolute; - left: 4px; - right: 4px; - bottom: 4px; - font-size: 10px; - line-height: 1.2; - color: var(--color-text-tertiary); - text-align: center; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.session-msg { - border-radius: 10px; - padding: 10px 12px 10px 18px; - border: 1px solid rgba(208, 196, 182, 0.45); - background: rgba(255, 255, 255, 0.75); - position: relative; - box-shadow: 0 2px 6px rgba(31, 26, 23, 0.04); - contain: layout style paint; -} - -.session-msg.user { - border-color: rgba(210, 107, 90, 0.35); - background: rgba(210, 107, 90, 0.08); -} - -.session-msg::before { - content: ""; - position: absolute; - left: 8px; - top: 10px; - bottom: 10px; - width: 3px; - border-radius: 999px; - background: rgba(139, 118, 104, 0.45); -} - -.session-msg.user::before { - background: rgba(210, 107, 90, 0.85); -} - -.session-msg.assistant::before { - background: rgba(90, 139, 106, 0.6); -} - -.session-msg-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: var(--spacing-xs); - margin-bottom: 6px; - font-size: var(--font-size-caption); - color: var(--color-text-tertiary); -} - -.session-msg-meta { - min-width: 0; - flex: 1; - display: flex; - align-items: center; - gap: 8px; -} - -.session-msg-role { - font-weight: var(--font-weight-secondary); - color: var(--color-text-secondary); -} - -.session-msg-time { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - color: var(--color-text-tertiary); -} - -.session-msg-content { - font-size: var(--font-size-secondary); - line-height: 1.55; - color: var(--color-text-primary); - white-space: pre-wrap; - word-break: break-word; -} - -.session-preview-empty { - flex: 1; - display: flex; - align-items: center; - justify-content: center; - color: var(--color-text-tertiary); - font-size: var(--font-size-secondary); - padding: var(--spacing-md); - text-align: center; - flex-direction: column; - gap: 8px; -} - -.session-preview-empty::before { - content: ""; - width: 34px; - height: 34px; - border-radius: 50%; - background: rgba(210, 107, 90, 0.12); - box-shadow: inset 0 0 0 6px rgba(255, 255, 255, 0.7); -} - -@media (max-width: 1100px) { - .session-layout { - grid-template-columns: 1fr; - height: auto; - min-height: 0; - } - - .session-toolbar { - grid-template-columns: 1fr; - } - - .session-toolbar-actions { - justify-content: flex-start; - } - - .session-toolbar-footer { - justify-content: flex-start; - } - - .session-list { - position: static; - max-height: 300px; - height: auto; - } - - .session-preview { - min-height: 360px; - max-height: none; - height: auto; - position: relative; - transform: none; - box-shadow: var(--shadow-card); - } - - .session-preview-scroll { - padding-right: 0; - } - - .session-timeline { - display: none; - } - - .session-preview-header { - flex-direction: column; - align-items: stretch; - } - - .session-actions { - justify-content: flex-start; - } - - .session-preview.active { - box-shadow: var(--shadow-float); - } -} - -@media (max-width: 520px) { - .session-item-header { - flex-direction: column; - align-items: stretch; - } - - .session-item-actions { - justify-content: flex-end; - } - - .session-actions { - width: 100%; - flex-direction: column; - align-items: stretch; - } - - .btn-session-refresh, - .btn-session-export { - width: 100%; - } - - .session-toolbar-group.session-toolbar-actions { - flex-direction: column; - align-items: stretch; - } - - .session-toolbar-group.session-toolbar-actions .btn-tool { - width: 100%; - } - - .trash-item-header { - flex-direction: column; - align-items: stretch; - gap: 10px; - } - - .trash-item-mainline { - flex-direction: column; - align-items: flex-start; - gap: 6px; - } - - .trash-item-side { - width: 100%; - min-width: 0; - align-items: stretch; - gap: 10px; - } - - .trash-item-actions { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - justify-content: flex-start; - width: 100%; - } - - .trash-item-actions .btn-mini { - width: 100%; - min-height: 40px; - display: inline-flex; - align-items: center; - justify-content: center; - } - - .trash-item-time { - text-align: right; - } -} - -.btn[disabled] { - opacity: 0.5; - cursor: not-allowed; - transform: none; - box-shadow: none; -} - -/* ============================================ - 模态框 - ============================================ */ -.modal-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: linear-gradient(to bottom, rgba(31, 26, 23, 0.3) 0%, rgba(31, 26, 23, 0.5) 100%); - display: flex; - justify-content: center; - align-items: center; - z-index: 100; - backdrop-filter: blur(8px) saturate(180%); - -webkit-backdrop-filter: blur(8px) saturate(180%); - animation: fadeIn var(--transition-normal) var(--ease-out-expo); -} - -.modal { - background: linear-gradient(to bottom, var(--color-surface) 0%, rgba(255, 255, 255, 0.98) 100%); - width: 90%; - max-width: 400px; - max-height: 90vh; - overflow-y: auto; - overscroll-behavior: contain; - border-radius: var(--radius-lg); - padding: var(--spacing-md); - box-shadow: var(--shadow-modal); - border: 1px solid rgba(255, 255, 255, 0.8); - animation: slideUp var(--transition-slow) var(--ease-spring); -} - -.modal-wide { - max-width: 980px; -} - -.modal-editor { - width: min(96vw, 980px); - max-height: calc(100vh - 24px); - display: flex; - flex-direction: column; - overflow: hidden; - padding: 0; -} - -.modal-editor-header { - margin-bottom: 0; - padding: var(--spacing-md) var(--spacing-md) 0; -} - -.modal-editor-body { - flex: 1; - min-height: 0; - overflow-y: auto; - padding: var(--spacing-sm) var(--spacing-md) 0; -} - -.modal-editor-footer { - margin-top: 0; - padding: var(--spacing-sm) var(--spacing-md) var(--spacing-md); - border-top: 1px solid var(--color-border-soft); - background: linear-gradient(to bottom, rgba(255, 255, 255, 0.82) 0%, rgba(255, 255, 255, 0.96) 100%); - backdrop-filter: blur(2px); -} - -.modal-title { - font-size: var(--font-size-title); - font-weight: var(--font-weight-title); - margin-bottom: var(--spacing-md); - color: var(--color-text-primary); - letter-spacing: -0.01em; -} - -.confirm-dialog { - max-width: 460px; -} - -.confirm-dialog-message { - font-size: var(--font-size-body); - line-height: 1.7; - color: var(--color-text-secondary); - white-space: pre-wrap; -} - -.confirm-dialog-actions { - margin-top: var(--spacing-lg); - justify-content: flex-end; -} - -.install-list { - display: flex; - flex-direction: column; - gap: var(--spacing-sm); - margin-top: var(--spacing-sm); -} - -.install-row { - display: flex; - align-items: center; - gap: var(--spacing-sm); - padding: 10px 12px; - border: 1px solid var(--color-border-soft); - border-radius: var(--radius-lg); - background: var(--color-surface-alt); -} - -.install-row-main { - flex: 1; - display: flex; - flex-direction: column; - gap: 6px; -} - -.install-row-title { - font-size: var(--font-size-secondary); - color: var(--color-text-secondary); -} - -.install-command { - flex: 1; - font-family: var(--font-family-mono); - font-size: var(--font-size-secondary); - color: var(--color-text-primary); - word-break: break-all; - background: rgba(255, 255, 255, 0.7); - padding: 8px 10px; - border-radius: var(--radius-sm); - border: 1px solid var(--color-border-soft); -} - -.install-row .btn-mini { - white-space: nowrap; -} - -.install-action-tabs { - display: flex; - gap: var(--spacing-xs); - flex-wrap: wrap; -} - -.install-action-tabs .btn-mini.active { - background: rgba(201, 94, 75, 0.16); - border-color: rgba(201, 94, 75, 0.32); - color: var(--color-text-primary); -} - -.install-registry-input { - width: 100%; -} - -.install-registry-hint { - width: 100%; - margin-top: 2px; -} - -.install-help { - margin-top: var(--spacing-sm); - border-top: 1px dashed var(--color-border-soft); - padding-top: var(--spacing-sm); -} - -.install-help-list { - margin: 6px 0 0; - padding-left: 18px; - color: var(--color-text-secondary); - font-size: var(--font-size-secondary); -} - -.install-help-list li + li { - margin-top: 4px; -} - -.modal-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--spacing-sm); - margin-bottom: var(--spacing-md); - flex-wrap: wrap; -} - -.modal-header .modal-title { - margin-bottom: 0; -} - -.modal-header-actions { - display: inline-flex; - align-items: center; - gap: 8px; - margin-left: auto; -} - -.btn-modal-copy { - padding: 6px 12px; - white-space: nowrap; - flex-shrink: 0; -} - -.form-group { - margin-bottom: var(--spacing-sm); -} - -.form-label { - display: block; - font-size: var(--font-size-secondary); - font-weight: var(--font-weight-secondary); - color: var(--color-text-secondary); - margin-bottom: 7px; - letter-spacing: 0.01em; -} - -.form-input { - width: 100%; - padding: 13px var(--spacing-sm); - border: 1.5px solid var(--color-border-soft); - border-radius: var(--radius-sm); - font-size: var(--font-size-body); - background-color: var(--color-surface-alt); - color: var(--color-text-primary); - outline: none; - transition: all var(--transition-fast) var(--ease-spring); - font-family: var(--font-family-body); - box-shadow: inset 0 1px 2px rgba(31, 26, 23, 0.04); -} - -.form-input:hover { - border-color: var(--color-border-strong); -} - -.form-input:focus { - border-color: var(--color-brand); - background-color: var(--color-surface); - box-shadow: var(--shadow-input-focus); -} - -.form-input::placeholder { - color: var(--color-text-tertiary); - opacity: 0.7; -} - -.form-select { - width: 100%; - min-height: 44px; - padding: 10px 40px 10px 12px; - border: 1.5px solid var(--color-border-soft); - border-radius: var(--radius-sm); - font-size: var(--font-size-secondary); - font-weight: var(--font-weight-secondary); - background-color: var(--color-surface-alt); - color: var(--color-text-primary); - outline: none; - transition: all var(--transition-fast) var(--ease-spring); - font-family: var(--font-family-body); - box-shadow: inset 0 1px 2px rgba(31, 26, 23, 0.04); - cursor: pointer; - appearance: none; - -webkit-appearance: none; - -moz-appearance: none; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='none' stroke='%23505A66' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round' d='M2 4l4 4 4-4'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 14px center; - background-size: 12px; -} - -.form-select:hover { - border-color: var(--color-border-strong); - background-color: var(--color-surface); -} - -.form-select:focus { - border-color: var(--color-brand); - background-color: var(--color-surface); - box-shadow: var(--shadow-input-focus); -} - -.form-select:disabled { - background: linear-gradient(to right, var(--color-bg) 0%, rgba(247, 241, 232, 0.5) 100%); - color: var(--color-text-tertiary); - cursor: not-allowed; - border-color: transparent; -} - -.template-editor { - min-height: min(60vh, 520px); - max-height: min(65vh, 620px); - resize: vertical; - overflow: auto; - white-space: pre; - font-family: var(--font-family-mono); - font-size: 13px; - line-height: 1.45; -} - -.template-editor-warning { - margin-top: 8px; - color: #8d5b31; - font-size: var(--font-size-caption); - line-height: 1.4; -} - - -.agents-diff-hint { - margin-top: 6px; - color: var(--color-text-tertiary); - font-size: var(--font-size-caption); -} - -.agents-diff-save-alert { - margin-bottom: 8px; - padding: 8px 10px; - border-radius: var(--radius-sm); - border: 1px solid rgba(238, 178, 90, 0.45); - background: rgba(255, 236, 204, 0.72); - color: #8d5b31; - font-size: var(--font-size-caption); - font-weight: var(--font-weight-secondary); -} - -.agents-diff-summary { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 8px; - font-size: var(--font-size-caption); - color: var(--color-text-tertiary); -} - -.agents-diff-stat { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 2px 8px; - border-radius: 999px; - border: 1px solid transparent; - font-weight: var(--font-weight-secondary); -} - -.agents-diff-stat.add { - color: #2b6a3b; - background: rgba(57, 181, 97, 0.12); - border-color: rgba(57, 181, 97, 0.2); -} - -.agents-diff-stat.del { - color: #8a2f36; - background: rgba(220, 95, 108, 0.12); - border-color: rgba(220, 95, 108, 0.2); -} - - -.agents-diff-empty { - padding: 10px 12px; - font-size: var(--font-size-caption); - color: var(--color-text-tertiary); - border: 1px dashed var(--color-border-soft); - border-radius: var(--radius-sm); - background: rgba(255, 255, 255, 0.6); -} - -.agents-diff-view { - border: 1px solid var(--color-border-soft); - border-radius: var(--radius-sm); - background: rgba(255, 255, 255, 0.7); - font-family: var(--font-family-mono); - font-size: 12px; - line-height: 1.55; - max-height: min(32vh, 280px); - overflow: auto; -} - - -.agents-diff-editor { - min-height: min(60vh, 520px); - max-height: min(65vh, 620px); -} - -.agents-diff-line { - display: grid; - grid-template-columns: 16px 1fr; - gap: 8px; - padding: 2px 10px; - align-items: start; -} - -.agents-diff-line + .agents-diff-line { - border-top: none; -} - -.agents-diff-line.add { - background: rgba(57, 181, 97, 0.08); -} - -.agents-diff-line.del { - background: rgba(220, 95, 108, 0.1); -} - -.agents-diff-line.context { - background: transparent; -} - - -.agents-diff-line-sign { - text-align: center; - color: var(--color-text-tertiary); - min-height: 20px; -} - -.agents-diff-line-text { - white-space: pre-wrap; - word-break: break-word; - color: var(--color-text-primary); -} - -.form-input:disabled, -.form-input[readonly] { - background: linear-gradient(to right, var(--color-bg) 0%, rgba(247, 241, 232, 0.5) 100%); - color: var(--color-text-tertiary); - cursor: not-allowed; - border-color: transparent; -} - -.form-hint { - font-size: var(--font-size-caption); - color: var(--color-text-tertiary); - margin-top: 5px; - opacity: 0.8; -} - -.quick-section { - margin-top: var(--spacing-md); - padding: var(--spacing-sm); - border-radius: var(--radius-lg); - border: 1px solid var(--color-border-soft); - background: linear-gradient(140deg, rgba(255, 252, 247, 0.95), rgba(255, 255, 255, 0.6)); -} - -.quick-header { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-xs); - align-items: flex-start; - justify-content: space-between; - margin-bottom: var(--spacing-sm); -} - -.quick-title { - font-size: var(--font-size-secondary); - font-weight: var(--font-weight-secondary); - color: var(--color-text-secondary); -} - -.quick-actions { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-xs); -} - -.quick-steps { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-xs); - margin-bottom: var(--spacing-sm); -} - -.quick-step { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 4px 10px; - border-radius: 999px; - border: 1px dashed var(--color-border-soft); - background: var(--color-surface); - font-size: var(--font-size-caption); - color: var(--color-text-secondary); -} - -.step-badge { - width: 20px; - height: 20px; - border-radius: 999px; - display: inline-flex; - align-items: center; - justify-content: center; - background: var(--color-brand); - color: #fff; - font-size: 12px; - font-weight: var(--font-weight-secondary); -} - -.quick-grid { - display: grid; - gap: var(--spacing-sm); - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); -} - -.quick-card { - background: var(--color-surface); - border: 1px solid var(--color-border-soft); - border-radius: var(--radius-sm); - padding: var(--spacing-sm); - box-shadow: var(--shadow-subtle); -} - -.quick-option { - display: flex; - align-items: center; - gap: 8px; - font-size: var(--font-size-caption); - color: var(--color-text-secondary); - margin-bottom: 6px; -} - -.quick-option input { - accent-color: var(--color-brand); -} - -.structured-section { - margin-top: var(--spacing-md); - padding: var(--spacing-sm); - border-radius: var(--radius-lg); - border: 1px solid var(--color-border-soft); - background: rgba(255, 255, 255, 0.6); -} - -.structured-header { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-xs); - align-items: baseline; - justify-content: space-between; - margin-bottom: var(--spacing-sm); -} - -.structured-title { - font-size: var(--font-size-secondary); - font-weight: var(--font-weight-secondary); - color: var(--color-text-secondary); -} - -.structured-grid { - display: grid; - gap: var(--spacing-sm); - grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); -} - -.structured-card { - background: var(--color-surface); - border: 1px solid var(--color-border-soft); - border-radius: var(--radius-sm); - padding: var(--spacing-sm); - box-shadow: var(--shadow-subtle); -} - -.structured-card-title { - font-size: var(--font-size-body); - font-weight: var(--font-weight-secondary); - color: var(--color-text-secondary); - margin-bottom: 8px; -} - -.provider-list { - display: flex; - flex-direction: column; - gap: var(--spacing-xs); -} - -.provider-item { - border: 1px dashed var(--color-border-soft); - border-radius: var(--radius-sm); - padding: var(--spacing-xs); - background: var(--color-surface-alt); -} - -.provider-header { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-xs); - align-items: center; - margin-bottom: 6px; -} - -.provider-name { - font-weight: var(--font-weight-secondary); - color: var(--color-text-secondary); -} - -.provider-source { - font-size: var(--font-size-caption); - color: var(--color-text-tertiary); -} - -.provider-fields { - display: grid; - gap: 6px; -} - -.provider-field { - display: flex; - flex-wrap: wrap; - gap: 6px; - align-items: baseline; -} - -.provider-field-key { - font-family: var(--font-family-mono); - font-size: var(--font-size-caption); - color: var(--color-text-muted); - min-width: 110px; -} - -.provider-field-value { - font-family: var(--font-family-mono); - font-size: var(--font-size-caption); - color: var(--color-text-secondary); - word-break: break-all; -} - -.auth-profile-list { - display: flex; - flex-direction: column; - gap: var(--spacing-xs); -} - -.auth-profile-item { - border: 1px dashed var(--color-border-soft); - border-radius: var(--radius-sm); - padding: var(--spacing-sm); - background: linear-gradient(to bottom, rgba(255, 255, 255, 0.82) 0%, rgba(247, 241, 232, 0.4) 100%); -} - -.auth-profile-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: var(--spacing-sm); -} - -.auth-profile-main { - min-width: 0; - flex: 1; - display: flex; - flex-direction: column; - gap: 6px; -} - -.auth-profile-title { - font-size: var(--font-size-body); - font-weight: var(--font-weight-secondary); - color: var(--color-text-secondary); - letter-spacing: -0.01em; - word-break: break-all; -} - -.auth-profile-meta { - display: flex; - flex-wrap: wrap; - gap: 8px; - align-items: center; -} - -.auth-profile-actions { - display: flex; - flex-wrap: wrap; - gap: 8px; - justify-content: flex-end; -} - -.auth-profile-grid { - margin-top: 10px; - display: grid; - grid-template-columns: minmax(96px, 130px) minmax(0, 1fr); - gap: 8px 12px; - align-items: start; -} - -.auth-profile-row { - display: contents; -} - -.auth-profile-key { - font-family: var(--font-family-mono); - font-size: var(--font-size-caption); - color: var(--color-text-muted); - line-height: 1.4; -} - -.auth-profile-value { - font-family: var(--font-family-mono); - font-size: var(--font-size-caption); - color: var(--color-text-secondary); - line-height: 1.4; - word-break: break-all; -} - -.agent-list { - display: flex; - flex-direction: column; - gap: var(--spacing-xs); -} - -.agent-item { - border: 1px dashed var(--color-border-soft); - border-radius: var(--radius-sm); - padding: var(--spacing-xs); - background: var(--color-surface-alt); -} - -.agent-header { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-xs); - align-items: center; - margin-bottom: 6px; -} - -.agent-name { - font-weight: var(--font-weight-secondary); - color: var(--color-text-secondary); -} - -.agent-id { - font-size: var(--font-size-caption); - color: var(--color-text-tertiary); -} - -.agent-meta { - display: flex; - flex-wrap: wrap; - gap: 8px; - font-size: var(--font-size-caption); - color: var(--color-text-secondary); -} - -.skill-toolbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--spacing-xs); - margin-bottom: 10px; - flex-wrap: wrap; -} - -.skill-select-all { - display: inline-flex; - align-items: center; - gap: 8px; - font-size: var(--font-size-secondary); - color: var(--color-text-secondary); - user-select: none; -} - -.skill-toolbar-count { - font-size: var(--font-size-caption); - color: var(--color-text-tertiary); -} - -.skills-modal { - width: min(96vw, 920px); -} - -.skills-modal-header { - align-items: flex-start; - margin-bottom: var(--spacing-xs); -} - -.skills-modal-subtitle { - margin-top: 6px; - font-size: var(--font-size-caption); - color: var(--color-text-tertiary); - line-height: 1.45; -} - -.skills-modal-actions { - display: flex; - flex-direction: row; - align-items: center; - gap: 8px; -} - -.skills-root-box { - border: 1px solid var(--color-border-soft); - border-radius: var(--radius-sm); - background: linear-gradient(to bottom, rgba(255, 255, 255, 0.86) 0%, rgba(255, 255, 255, 0.66) 100%); - padding: 10px 12px; - font-family: var(--font-family-mono); - font-size: var(--font-size-caption); - color: var(--color-text-secondary); - word-break: break-all; -} - -.skills-root-group { - margin-bottom: var(--spacing-xs); -} - -.skills-summary-strip { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: var(--spacing-xs); - margin-bottom: var(--spacing-sm); -} - -.skills-summary-item { - border: 1px solid rgba(160, 145, 130, 0.2); - border-radius: var(--radius-sm); - background: linear-gradient(to bottom, rgba(255, 255, 255, 0.93) 0%, rgba(255, 255, 255, 0.78) 100%); - padding: 10px 12px; - min-width: 0; - box-shadow: var(--shadow-subtle); - display: flex; - flex-direction: column; - gap: 2px; -} - -.skills-summary-label { - font-size: var(--font-size-caption); - color: var(--color-text-tertiary); -} - -.skills-summary-value { - font-size: var(--font-size-large); - color: var(--color-text-secondary); - line-height: 1.2; -} - -.skills-panel { - border: 1px solid rgba(160, 145, 130, 0.24); - border-radius: var(--radius-md); - padding: 12px; - background: linear-gradient(to bottom, rgba(255, 255, 255, 0.88) 0%, rgba(255, 255, 255, 0.72) 100%); - margin-bottom: var(--spacing-sm); -} - -.skills-panel-header { - display: flex; - justify-content: space-between; - align-items: flex-start; - gap: var(--spacing-xs); - margin-bottom: 10px; -} - -.skills-panel-title-wrap { - min-width: 0; -} - -.skills-panel-title { - font-size: var(--font-size-body); - font-weight: var(--font-weight-primary); - color: var(--color-text-secondary); -} - -.skills-panel-note { - margin-top: 4px; - font-size: var(--font-size-caption); - color: var(--color-text-tertiary); - line-height: 1.45; -} - -.market-overview-section { - margin-bottom: var(--spacing-sm); -} - -.market-overview-header { - gap: var(--spacing-sm); - align-items: flex-start; -} - -.market-header-actions { - flex-wrap: wrap; -} - -.market-target-switch { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-bottom: var(--spacing-sm); -} - -.market-target-switch-compact { - justify-content: flex-end; - margin-bottom: 0; -} - -.market-target-chip { - border: 1px solid rgba(160, 145, 130, 0.28); - border-radius: 999px; - background: rgba(255, 255, 255, 0.92); - color: var(--color-text-secondary); - padding: 8px 14px; - font-size: var(--font-size-caption); - font-weight: var(--font-weight-secondary); - cursor: pointer; - transition: border-color var(--transition-fast) var(--ease-smooth), background var(--transition-fast) var(--ease-smooth), color var(--transition-fast) var(--ease-smooth), box-shadow var(--transition-fast) var(--ease-smooth); -} - -.market-target-chip:disabled, -.market-target-chip[disabled] { - cursor: not-allowed; - opacity: 0.64; - pointer-events: none; - box-shadow: none; -} - -.market-target-chip.active { - border-color: rgba(208, 88, 58, 0.4); - background: linear-gradient(to bottom, rgba(255, 243, 236, 0.98) 0%, rgba(255, 232, 220, 0.86) 100%); - color: #8c3a1f; - box-shadow: var(--shadow-subtle); -} - -.market-root-box { - margin-bottom: var(--spacing-sm); -} - -.market-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--spacing-sm); -} - -.market-panel { - margin-bottom: 0; - min-width: 0; -} - -.market-actions-panel { - grid-column: 1 / -1; -} - -.market-panel-wide { - grid-column: 1 / -1; -} - -.market-preview-list { - display: grid; - gap: 10px; -} - -.market-preview-item { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: var(--spacing-xs); - padding: 10px 12px; - border: 1px solid rgba(160, 145, 130, 0.18); - border-radius: var(--radius-sm); - background: rgba(255, 255, 255, 0.64); -} - -.market-preview-main { - min-width: 0; - display: flex; - flex-direction: column; - gap: 4px; -} - -.market-preview-title { - font-size: var(--font-size-body); - font-weight: var(--font-weight-secondary); - color: var(--color-text-secondary); - overflow-wrap: anywhere; -} - -.market-preview-meta { - font-size: var(--font-size-caption); - color: var(--color-text-tertiary); - line-height: 1.45; - overflow-wrap: anywhere; -} - -.market-action-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: var(--spacing-xs); -} - -.market-action-card { - border: 1px solid rgba(160, 145, 130, 0.24); - border-radius: var(--radius-sm); - background: linear-gradient(to bottom, rgba(255, 255, 255, 0.96) 0%, rgba(255, 248, 242, 0.84) 100%); - color: var(--color-text-secondary); - text-align: left; - padding: 14px; - display: flex; - flex-direction: column; - gap: 6px; - cursor: pointer; - transition: transform var(--transition-fast) var(--ease-smooth), box-shadow var(--transition-fast) var(--ease-smooth), border-color var(--transition-fast) var(--ease-smooth); -} - -.market-action-card:hover:not(:disabled) { - transform: translateY(-1px); - border-color: rgba(208, 88, 58, 0.34); - box-shadow: var(--shadow-subtle); -} - -.market-action-card:disabled { - cursor: not-allowed; - opacity: 0.64; -} - -.market-action-title { - font-size: var(--font-size-body); - font-weight: var(--font-weight-secondary); -} - -.market-action-copy { - font-size: var(--font-size-caption); - color: var(--color-text-tertiary); - line-height: 1.45; -} - -.skills-filter-row { - display: flex; - gap: var(--spacing-xs); - margin-bottom: 10px; - align-items: center; - flex-wrap: wrap; -} - -.skills-filter-row .form-input { - flex: 1; - min-width: 220px; -} - -.skills-status-select { - width: 210px; - flex: 0 0 auto; -} - -.skills-hint-line, -.hint-single-line { - display: block; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 100%; -} - -.skill-list { - display: flex; - flex-direction: column; - gap: var(--spacing-xs); - margin-bottom: var(--spacing-sm); - max-height: min(52vh, 440px); - overflow-y: auto; - padding-right: 2px; - scrollbar-width: thin; - scrollbar-color: rgba(166, 149, 130, 0.82) transparent; -} - -.skill-list::-webkit-scrollbar { - width: 6px; - height: 6px; -} - -.skill-list::-webkit-scrollbar-track { - background: transparent; - border-radius: 999px; -} - -.skill-list::-webkit-scrollbar-thumb { - background: linear-gradient(to bottom, rgba(191, 174, 154, 0.95) 0%, rgba(160, 141, 121, 0.9) 100%); - border-radius: 999px; - border: 1px solid rgba(255, 255, 255, 0.9); -} - -.skill-list::-webkit-scrollbar-thumb:hover { - background: linear-gradient(to bottom, rgba(176, 157, 137, 0.95) 0%, rgba(145, 126, 107, 0.92) 100%); -} - -.skill-item { - display: flex; - align-items: flex-start; - gap: var(--spacing-xs); - border: 1px dashed var(--color-border-soft); - border-radius: var(--radius-sm); - padding: var(--spacing-xs); - background: var(--color-surface-alt); - transition: border-color var(--transition-fast) var(--ease-spring), background-color var(--transition-fast) var(--ease-spring); -} - -.skill-item-main { - min-width: 0; - display: flex; - flex-direction: column; - gap: 6px; - flex: 1; -} - -.skill-item-title { - font-size: var(--font-size-secondary); - font-weight: var(--font-weight-secondary); - color: var(--color-text-secondary); -} - -.skill-item-description { - font-size: var(--font-size-caption); - line-height: 1.45; - color: var(--color-text-tertiary); -} - -.skill-item-meta { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-xs); - align-items: center; - min-width: 0; -} - -.skill-item-path { - font-family: var(--font-family-mono); - font-size: var(--font-size-caption); - color: var(--color-text-tertiary); - word-break: break-all; -} - -.skill-item:hover { - border-color: rgba(201, 94, 75, 0.35); -} - -.skill-item.selected { - border-color: rgba(201, 94, 75, 0.55); - background: linear-gradient(to bottom, rgba(201, 94, 75, 0.10) 0%, rgba(201, 94, 75, 0.04) 100%); -} - -.skills-empty-state { - margin-bottom: var(--spacing-sm); - border: 1px dashed var(--color-border-soft); - border-radius: var(--radius-sm); - background: linear-gradient(to bottom, rgba(255, 255, 255, 0.78) 0%, rgba(255, 255, 255, 0.58) 100%); - color: var(--color-text-tertiary); - font-size: var(--font-size-secondary); - text-align: center; - padding: 18px 12px; -} - -.skills-import-block { - margin-bottom: var(--spacing-sm); -} - -.skills-import-title { - font-size: var(--font-size-body); - font-weight: var(--font-weight-secondary); - color: var(--color-text-secondary); -} - -.skills-import-list { - max-height: min(28vh, 260px); -} - -.skills-import-empty { - margin-bottom: 0; -} - -.list-row { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-xs); - align-items: center; - margin-bottom: var(--spacing-xs); -} - -.list-row:last-child { - margin-bottom: 0; -} - -.list-row .form-input { - flex: 1; - min-width: 140px; -} - -.btn-mini { - padding: 6px 10px; - border-radius: var(--radius-sm); - border: 1px solid var(--color-border-soft); - background: linear-gradient(to bottom, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0.85) 100%); - font-size: var(--font-size-caption); - font-weight: var(--font-weight-secondary); - color: var(--color-text-secondary); - cursor: pointer; - transition: all var(--transition-fast) var(--ease-spring); - box-shadow: var(--shadow-subtle); -} - -.btn-mini:hover { - border-color: var(--color-brand); - color: var(--color-brand); - transform: translateY(-1px); -} - -.btn-mini.delete { - color: var(--color-error); - border-color: rgba(193, 72, 59, 0.35); -} - -.btn-mini.delete:hover { - border-color: rgba(193, 72, 59, 0.7); - color: var(--color-error); -} - -.btn-group { - display: flex; - gap: var(--spacing-sm); - margin-top: var(--spacing-md); -} - -.btn { - flex: 1; - padding: 14px var(--spacing-sm); - border-radius: var(--radius-sm); - font-size: var(--font-size-body); - font-weight: var(--font-weight-secondary); - cursor: pointer; - transition: all var(--transition-fast) var(--ease-spring); - border: 1px solid var(--color-border-soft); - background: linear-gradient(to bottom, var(--color-surface) 0%, rgba(255, 255, 255, 0.95) 100%); - color: var(--color-text-secondary); - box-shadow: var(--shadow-subtle); - letter-spacing: -0.01em; -} - -.btn:active { - transform: scale(0.985); -} - -.btn-cancel { - background: linear-gradient(to bottom, var(--color-bg) 0%, rgba(247, 241, 232, 0.8) 100%); - color: var(--color-text-primary); - border: 1px solid var(--color-border-soft); -} - -.btn-cancel:hover { - background: linear-gradient(to bottom, var(--color-border) 0%, rgba(208, 196, 182, 0.5) 100%); -} - -.btn-confirm { - background: linear-gradient(135deg, var(--color-brand) 0%, var(--color-brand-dark) 100%); - color: white; - box-shadow: 0 2px 4px rgba(210, 107, 90, 0.2); - border: none; -} - -.btn-confirm:hover { - box-shadow: 0 4px 8px rgba(210, 107, 90, 0.25); - filter: brightness(1.05); -} - -.btn-confirm.secondary { - background: linear-gradient(135deg, var(--color-success) 0%, rgba(90, 139, 106, 0.85) 100%); - box-shadow: 0 2px 4px rgba(90, 139, 106, 0.2); - border: none; -} - -.btn-confirm.secondary:hover { - box-shadow: 0 4px 8px rgba(90, 139, 106, 0.25); - filter: brightness(1.05); -} - -.btn-confirm.btn-danger { - background: linear-gradient(135deg, #c75642 0%, #9f392c 100%); - box-shadow: 0 2px 4px rgba(163, 51, 38, 0.24); -} - -.btn-confirm.btn-danger:hover { - box-shadow: 0 4px 10px rgba(163, 51, 38, 0.28); - filter: brightness(1.04); -} - -/* ============================================ - 模型列表 - ============================================ */ -.model-list { - max-height: 200px; - overflow-y: auto; - border: 1px solid rgba(208, 196, 182, 0.4); - border-radius: var(--radius-sm); - margin-bottom: var(--spacing-sm); - scrollbar-width: none; - background: linear-gradient(to bottom, var(--color-surface) 0%, rgba(255, 255, 255, 0.8) 100%); - box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.02); -} - -.model-list::-webkit-scrollbar { - display: none; -} - -.model-item { - display: flex; - justify-content: space-between; - align-items: center; - padding: 11px var(--spacing-sm); - border-bottom: 1px solid rgba(208, 196, 182, 0.3); - font-size: var(--font-size-body); - color: var(--color-text-primary); - transition: all var(--transition-fast) var(--ease-spring); - letter-spacing: -0.005em; -} - -.model-item:last-child { - border-bottom: none; -} - -.model-item:hover { - background: linear-gradient(to right, rgba(247, 241, 232, 0.6) 0%, rgba(247, 241, 232, 0.3) 100%); -} - -.btn-remove-model { - font-size: var(--font-size-caption); - font-weight: var(--font-weight-caption); - color: var(--color-text-tertiary); - cursor: pointer; - padding: 5px 10px; - border-radius: var(--radius-full); - transition: all var(--transition-fast) var(--ease-spring); - background: transparent; - border: 1px solid rgba(139, 118, 104, 0.2); - letter-spacing: 0.03em; -} - -.btn-remove-model:hover { - background: linear-gradient(135deg, var(--color-error) 0%, rgba(200, 74, 58, 0.9) 100%); - color: white; - transform: scale(1.08); - box-shadow: 0 2px 6px rgba(200, 74, 58, 0.25); - border-color: transparent; -} - -/* ============================================ - Toast - 顶部横幅 - ============================================ */ -.toast { - position: fixed; - top: 16px; - left: 50%; - transform: translateX(-50%); - background: linear-gradient(to bottom, var(--color-surface) 0%, rgba(255, 255, 255, 0.95) 100%); - padding: 12px 24px; - border-radius: var(--radius-full); - box-shadow: var(--shadow-raised); - z-index: 200; - animation: slideDown var(--transition-slow) var(--ease-spring); - display: flex; - align-items: center; - gap: var(--spacing-xs); - font-size: var(--font-size-body); - font-weight: var(--font-weight-body); - border: 1px solid rgba(255, 255, 255, 0.8); - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); -} - -.toast.error { - border-left: 3px solid var(--color-error); -} - -.toast.success { - border-left: 3px solid var(--color-success); -} - -/* ============================================ - 状态消息 - ============================================ */ -.state-message { - text-align: center; - padding: 60px var(--spacing-md); - color: var(--color-text-tertiary); - font-size: var(--font-size-body); - opacity: 0.7; - letter-spacing: -0.005em; -} - -.state-message.error { - color: var(--color-error); -} - -/* 空状态 */ -.empty-state { - text-align: center; - padding: 48px var(--spacing-md); - color: var(--color-text-tertiary); -} - -.empty-state-icon { - width: 48px; - height: 48px; - margin: 0 auto var(--spacing-sm); - opacity: 0.3; -} - -.empty-state-title { - font-size: var(--font-size-body); - font-weight: var(--font-weight-secondary); - color: var(--color-text-secondary); - margin-bottom: 4px; -} - -.empty-state-subtitle { - font-size: var(--font-size-secondary); - color: var(--color-text-tertiary); - opacity: 0.8; -} - -/* ============================================ - 动画 - ============================================ */ -@keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } -} - -@keyframes slideUp { - from { transform: translateY(24px); opacity: 0; } - to { transform: translateY(0); opacity: 1; } -} - -@keyframes slideDown { - from { transform: translateX(-50%) translateY(-100%); opacity: 0; } - to { transform: translateX(-50%) translateY(0); opacity: 1; } -} - -@keyframes spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } -} - -[v-cloak] { - display: none !important; -} - -/* 模式内容容器 */ -.mode-content { - animation: fadeIn var(--transition-normal) var(--ease-spring); -} - -/* 内容区域包裹器 - 稳定高度 */ -.content-wrapper { - min-height: 300px; - position: relative; -} - -button:focus-visible, -select:focus-visible, -input:focus-visible, -textarea:focus-visible { - outline: 3px solid rgba(201, 94, 75, 0.25); - outline-offset: 2px; -} - -@media (max-width: 1280px) { - .app-shell { - grid-template-columns: 240px minmax(0, 1fr) 300px; - gap: 14px; - } - - .status-inspector { - top: 16px; - height: calc(100vh - 32px); - } - - .main-panel { - padding: var(--spacing-sm) var(--spacing-md); - } -} - -@media (max-width: 960px) { - .container { - padding: 12px; - } - .app-shell { - grid-template-columns: 1fr; - } - .side-rail { - display: none; - } - .status-inspector { - display: none; - } - .hero-logo { - display: block; - } - .hero-github { - display: flex; - } - .github-badge-mobile { - width: 100%; - } - .github-badge-mobile .github-badge-text, - .github-badge-mobile .github-badge-label { - font-size: var(--font-size-secondary); - } - .main-panel { - padding: var(--spacing-sm) var(--spacing-sm); - border-radius: 14px; - } - .top-tabs { - display: grid !important; - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - .status-strip { - gap: var(--spacing-sm); - margin-top: 4px; - } - .status-chip { - flex: 1 1 calc(50% - var(--spacing-sm)); - min-width: 0; - } - - .skills-summary-strip { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .skills-panel-header { - flex-direction: column; - align-items: stretch; - } - - .skills-modal-actions { - align-items: flex-start; - } - - .market-target-switch-compact { - justify-content: flex-start; - } -} - -@media (max-width: 720px) { - .main-title { - font-size: 40px; - } - - .hero-title { - font-size: 32px; - } - - .subtitle { - font-size: var(--font-size-secondary); - margin-bottom: 16px; - } - - .segmented-control { - flex-direction: column; - gap: 6px; - } - - .status-strip { - flex-direction: row; - flex-wrap: wrap; - } - - .market-grid { - grid-template-columns: 1fr; - } - - .market-action-grid { - grid-template-columns: 1fr; - } - - .status-chip { - flex: 1 1 100%; - } -} - -@media (max-width: 540px) { - .trash-header-actions { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - width: 100%; - } - - .selector-header .trash-header-actions > .btn-tool, - .selector-header .trash-header-actions > .btn-tool-compact { - width: 100%; - min-width: 0; - height: 44px; - min-height: 44px; - } - - body { - padding: var(--spacing-md) var(--spacing-sm); - } - .container { - padding: 0 var(--spacing-sm) var(--spacing-md); - } - .hero-title { - font-size: 32px; - } - .hero-subtitle { - font-size: var(--font-size-secondary); - } - .top-tabs { - grid-template-columns: repeat(1, minmax(0, 1fr)); - } - .main-panel { - padding: var(--spacing-sm); - } - .card { - padding: 12px; - } - .session-layout { - grid-template-columns: 1fr; - height: auto; - min-height: 0; - } - - .status-strip { - gap: var(--spacing-xs); - } - - .status-chip { - flex: 1 1 100%; - min-width: 100%; - } - - .btn-add, - .btn-tool, - .card-action-btn, - .btn-session-export, - .btn-session-open, - .btn-session-clone, - .btn-session-refresh, - .btn-session-delete, - .btn-icon, - .session-item-copy { - min-height: 44px; - padding-top: 12px; - padding-bottom: 12px; - } - - .btn-icon, - .session-item-copy { - min-width: 44px; - } - - .session-item { - min-height: 75px; - height: 75px; - contain-intrinsic-size: 75px; - padding: 12px 14px; - } - - .session-item-header { - flex-direction: row; - align-items: center; - gap: 8px; - } - - .session-item-main { - align-items: center; - } - - .session-item-copy { - width: 20px; - height: 20px; - min-width: 20px; - min-height: 20px; - border-radius: 6px; - padding: 2px; - display: inline-flex; - align-items: center; - justify-content: center; - transform: translate(-3px, 0); - } - - .session-item-copy svg { - width: 12px; - height: 12px; - } - - .session-item-title { - -webkit-line-clamp: 1; - max-height: none; - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - } - - .session-item-actions { - margin-top: 0; - } - - .session-item-meta { - margin-top: -2px; - margin-bottom: 0; - gap: 4px; - align-items: center; - } - - .trash-item.session-item { - min-height: auto; - height: auto; - contain-intrinsic-size: auto; - } - - .trash-item-header { - flex-direction: column; - align-items: stretch; - gap: 10px; - } - - .trash-item-mainline { - flex-direction: column; - align-items: flex-start; - gap: 6px; - } - - .trash-item-side { - width: 100%; - min-width: 0; - align-items: stretch; - gap: 10px; - padding-top: 8px; - border-top: 1px dashed rgba(216, 201, 184, 0.55); - } - - .trash-item-actions { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - justify-content: flex-start; - width: 100%; - } - - .trash-item-actions .btn-mini { - width: 100%; - min-height: 44px; - display: inline-flex; - align-items: center; - justify-content: center; - } - - .trash-item .session-count-badge { - align-self: flex-start; - margin-top: 0; - } - - .trash-item-title { - -webkit-line-clamp: 3; - max-height: none; - white-space: normal; - text-overflow: clip; - overflow: hidden; - } - - .trash-item-meta { - margin-top: 6px; - margin-bottom: 0; - gap: 6px; - align-items: center; - } - - .trash-item-time { - padding-top: 2px; - line-height: 1.35; - text-align: right; - } - - .trash-item-path { - grid-template-columns: 1fr; - gap: 4px; - } - - .card { - padding: 8px; - } - - .card-list { - gap: 4px; - margin-bottom: 4px; - } - - .card-actions { - gap: 8px; - } - - .card-action-btn { - width: 40px; - height: 40px; - border-radius: 10px; - } - - .card-action-btn svg { - width: 18px; - height: 18px; - } - - .card-trailing { - grid-auto-flow: row; - grid-auto-columns: 1fr; - justify-content: stretch; - justify-items: end; - } - - .card-trailing .card-actions { - width: 100%; - justify-content: flex-end; - justify-self: end; - } - - /* 移动端不显示配置状态 pill,节省空间 */ - .card-trailing .pill { - display: none; - } - - .auth-profile-item { - padding: 10px; - } - - .auth-profile-header { - flex-direction: column; - align-items: stretch; - gap: 10px; - } - - .auth-profile-actions { - justify-content: flex-start; - } - - .auth-profile-grid { - grid-template-columns: 1fr; - gap: 6px; - margin-top: 8px; - } - - .auth-profile-row { - display: flex; - flex-direction: column; - gap: 2px; - padding-bottom: 4px; - border-bottom: 1px dashed rgba(160, 145, 130, 0.25); - } - - .auth-profile-row:last-child { - border-bottom: none; - padding-bottom: 0; - } - - .session-preview { - border-radius: var(--radius-lg); - } - - .skills-summary-strip { - grid-template-columns: 1fr; - } - - .skills-panel { - padding: 10px; - } - - .skills-root-box { - font-size: 11px; - } -} +@import url('./styles/base-theme.css'); +@import url('./styles/layout-shell.css'); +@import url('./styles/navigation-panels.css'); +@import url('./styles/titles-cards.css'); +@import url('./styles/controls-forms.css'); +@import url('./styles/sessions-toolbar-trash.css'); +@import url('./styles/sessions-list.css'); +@import url('./styles/sessions-preview.css'); +@import url('./styles/modals-core.css'); +@import url('./styles/openclaw-structured.css'); +@import url('./styles/skills-market.css'); +@import url('./styles/skills-list.css'); +@import url('./styles/feedback.css'); +@import url('./styles/responsive.css'); diff --git a/web-ui/styles/base-theme.css b/web-ui/styles/base-theme.css new file mode 100644 index 0000000..e47e6a2 --- /dev/null +++ b/web-ui/styles/base-theme.css @@ -0,0 +1,373 @@ +/* Use local font stacks only; avoid third-party font fetches. */ + +/* ============================================ + 设计系统 - Design Tokens + ============================================ */ +:root { + /* 色彩系统:去除杂纹,强调干净留白与温柔橙红 */ + --color-brand: #D0583A; + --color-brand-dark: #B8442B; + --color-brand-light: rgba(208, 88, 58, 0.14); + --color-brand-subtle: rgba(201, 94, 75, 0.2); + + --color-bg: #F8F2EA; + --color-surface: #FFFDFC; + --color-surface-alt: #FFF8F2; + --color-surface-elevated: #FFFFFF; + --color-surface-tint: rgba(255, 255, 255, 0.84); + --color-text-primary: #1B1714; + --color-text-secondary: #473C34; + --color-text-tertiary: #6F6054; + --color-text-muted: #6C5B50; + --color-border: #D8C9B8; + --color-border-soft: rgba(216, 201, 184, 0.38); + --color-border-strong: rgba(216, 201, 184, 0.68); + + --color-success: #4B8B6A; + --color-error: #C44536; + + --bg-warm-gradient: + linear-gradient(180deg, #F8F2EA 0%, #F8F2EA 100%); + + /* 字体系统 */ + --font-family-body: 'JetBrainsMono Nerd Font Mono', 'OPPO Sans 4.0', 'Fira Mono', 'JetBrains Mono', 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', monospace; + --font-family-display: 'JetBrainsMono Nerd Font Mono', 'OPPO Sans 4.0', 'Fira Mono', 'JetBrains Mono', 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', monospace; + --font-family-mono: 'JetBrainsMono Nerd Font Mono', 'OPPO Sans 4.0', 'Fira Mono', 'JetBrains Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', monospace; + --font-family: var(--font-family-body); + + --font-size-display: 52px; + --font-size-title: 18px; + --font-size-large: 20px; + --font-size-body: 15px; + --font-size-secondary: 13px; + --font-size-caption: 11px; + + --font-weight-display: 600; + --font-weight-primary: 600; + --font-weight-title: 600; + --font-weight-body: 400; + --font-weight-secondary: 500; + --font-weight-caption: 500; + + --line-height-tight: 1.12; + --line-height-normal: 1.5; + + /* 间距系统 */ + --spacing-xs: 8px; + --spacing-sm: 16px; + --spacing-md: 24px; + --spacing-lg: 40px; + --spacing-xl: 64px; + + /* 圆角系统 */ + --radius-sm: 8px; + --radius-md: 10px; + --radius-lg: 12px; + --radius-xl: 18px; + --radius-full: 50px; + + /* 阴影系统 - 多层叠加提升真实感 */ + --shadow-subtle: 0 1px 2px rgba(31, 26, 23, 0.03); + --shadow-card: 0 6px 18px rgba(31, 26, 23, 0.06); + --shadow-card-hover: 0 10px 24px rgba(31, 26, 23, 0.08); + --shadow-float: 0 12px 26px rgba(31, 26, 23, 0.12); + --shadow-raised: 0 10px 20px rgba(31, 26, 23, 0.1); + --shadow-modal: + 0 8px 24px rgba(31, 26, 23, 0.08), + 0 24px 64px rgba(31, 26, 23, 0.06); + --shadow-input-focus: + 0 0 0 3px var(--color-brand-light), + 0 1px 3px rgba(31, 26, 23, 0.04); + + /* 动画 - 更细腻的曲线 */ + --transition-instant: 100ms; + --transition-fast: 120ms; + --transition-normal: 200ms; + --transition-slow: 300ms; + --ease-spring: cubic-bezier(0.16, 1, 0.3, 1); + --ease-spring-soft: cubic-bezier(0.25, 1, 0.5, 1); + --ease-smooth: cubic-bezier(0.4, 0, 0.2, 1); + --ease-out-expo: cubic-bezier(0.19, 1, 0.22, 1); +} + +/* ============================================ + 手机桌面 UA 兜底:触控设备强制紧凑排版 + ============================================ */ +body.force-compact { + --font-size-title: 20px; + --font-size-body: 16px; + --font-size-secondary: 14px; + --font-size-caption: 12px; +} + +body.force-compact .container { + max-width: 760px; + padding: 10px 10px 16px; +} + +body.force-compact .provider-fast-switch { + position: sticky; + top: 8px; + z-index: 16; +} + +body.force-compact .provider-fast-switch-select { + min-height: 44px; + font-size: 16px; +} + +body.force-compact .app-shell { + grid-template-columns: 1fr; + gap: 12px; +} + +body.force-compact .main-panel { + position: relative; + top: auto; + align-self: stretch; + width: 100%; + height: auto; +} + +body.force-compact .side-rail, +body.force-compact .status-inspector { + display: none; +} + +body.force-compact .top-tabs { + display: grid !important; + grid-template-columns: repeat(1, minmax(0, 1fr)); +} + +@media (min-width: 541px) { + body.force-compact .top-tabs { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +body.force-compact .hero-logo { + display: block; +} + +body.force-compact .hero-github { + display: flex; +} + +body.force-compact .main-panel { + padding: 14px 12px; +} + +body.force-compact .hero-title { + font-size: 34px; +} + +body.force-compact .card { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; + padding: 12px; + gap: 8px; +} + +body.force-compact .card-leading { + align-items: flex-start; + width: 100%; +} + +body.force-compact .card-content { + width: 100%; +} + +body.force-compact .card-title, +body.force-compact .card-title > span:first-child { + white-space: normal; + overflow: visible; + text-overflow: clip; + overflow-wrap: anywhere; +} + +body.force-compact .card-subtitle { + white-space: normal; + overflow: hidden; + text-overflow: clip; + overflow-wrap: anywhere; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +body.force-compact .card-trailing { + width: 100%; + margin-top: 0; + grid-auto-flow: row; + grid-auto-columns: 1fr; + justify-content: stretch; + justify-items: end; +} + +body.force-compact .card-trailing .card-actions { + width: 100%; + justify-content: flex-end; + justify-self: stretch; + flex-wrap: wrap; +} + +body.force-compact .card-actions { + opacity: 1; + transform: none; +} + +body.force-compact .card-trailing .pill, +body.force-compact .card-trailing .latency { + justify-self: end; +} + +body.force-compact .btn-add, +body.force-compact .btn-tool, +body.force-compact .card-action-btn { + min-height: 44px; +} + +/* ============================================ + 基础重置 + ============================================ */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +/* 仅屏幕阅读器可见 */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +body { + font-family: var(--font-family-body); + background-color: var(--color-bg); + background: var(--bg-warm-gradient); + color: var(--color-text-primary); + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + padding: var(--spacing-lg) var(--spacing-md); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + position: relative; + overflow-x: hidden; +} + +.fab-install { + position: fixed; + left: 16px; + bottom: calc(16px + env(safe-area-inset-bottom, 0px)); + z-index: 90; + display: inline-grid; + place-items: center; + width: 50px; + height: 50px; + min-width: 44px; + min-height: 44px; + padding: 0; + border-radius: var(--radius-full); + border: 1px solid rgba(255, 255, 255, 0.28); + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.18) 0%, rgba(255, 255, 255, 0.04) 100%), + linear-gradient(135deg, var(--color-brand) 0%, var(--color-brand-dark) 100%); + color: #fff; + font-size: var(--font-size-secondary); + font-weight: var(--font-weight-secondary); + letter-spacing: 0.015em; + box-shadow: var(--shadow-float); + cursor: pointer; + overflow: hidden; + transition: + transform var(--transition-fast) var(--ease-spring), + box-shadow var(--transition-fast) var(--ease-spring), + filter var(--transition-fast) var(--ease-smooth); + animation: fabPulse 3.2s ease-in-out infinite; +} + +.fab-install::after { + content: ""; + position: absolute; + inset: 1px; + border-radius: inherit; + border: 1px solid rgba(255, 255, 255, 0.12); + pointer-events: none; +} + +.fab-install-icon { + width: 20px; + height: 20px; + display: inline-grid; + place-items: center; + color: #fff; + background: transparent; + box-shadow: none; + flex-shrink: 0; +} + +.fab-install-icon svg { + width: 18px; + height: 18px; +} + +.fab-install:hover { + transform: translateY(-1px); + box-shadow: var(--shadow-raised); + filter: saturate(1.04); +} + +.fab-install:active { + transform: translateY(0); + filter: saturate(0.98); +} + +@media (max-width: 640px) { + .fab-install { + left: 12px; + bottom: calc(12px + env(safe-area-inset-bottom, 0px)); + width: 44px; + height: 44px; + padding: 0; + font-size: var(--font-size-secondary); + } + + .fab-install-icon { + width: 18px; + height: 18px; + } + + .fab-install-icon svg { + width: 16px; + height: 16px; + } +} + +@keyframes fabPulse { + 0%, + 100% { + box-shadow: var(--shadow-float); + } + 50% { + box-shadow: 0 14px 30px rgba(31, 26, 23, 0.14); + } +} + +@media (prefers-reduced-motion: reduce) { + .fab-install { + animation: none; + transition: none; + } +} diff --git a/web-ui/styles/controls-forms.css b/web-ui/styles/controls-forms.css new file mode 100644 index 0000000..92f98f8 --- /dev/null +++ b/web-ui/styles/controls-forms.css @@ -0,0 +1,354 @@ +/* ============================================ + 选择器 - 用于模型选择 + ============================================ */ +.selector-section { + background: linear-gradient(to bottom, var(--color-surface) 0%, rgba(255, 255, 255, 0.92) 100%); + border-radius: var(--radius-lg); + padding: calc(var(--spacing-sm) + 2px); + margin-bottom: 16px; + box-shadow: var(--shadow-card); + border: 1px solid var(--color-border-soft); + display: flex; + flex-direction: column; + gap: var(--spacing-xs); +} + +.selector-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--spacing-xs); +} + +.settings-tab-header { + justify-content: flex-end; + align-items: center; +} + +.settings-tab-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; +} + +.settings-tab-actions .btn-tool, +.settings-tab-actions .btn-tool-compact { + width: auto; +} + +.trash-header-actions { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: stretch; + justify-content: flex-end; + width: auto; + max-width: 100%; + margin-left: auto; +} + +.selector-header .trash-header-actions > .btn-tool, +.selector-header .trash-header-actions > .btn-tool-compact { + display: flex; + align-items: center; + justify-content: center; + align-self: stretch; + margin: 0; + width: auto; + min-width: 0; + max-width: 100%; + height: 32px; + min-height: 32px; + padding: 0 10px; + line-height: 1; + vertical-align: top; + position: relative; + top: 0; + white-space: nowrap; +} + +.selector-header .trash-header-actions > .btn-tool:not(:disabled):hover, +.selector-header .trash-header-actions > .btn-tool-compact:not(:disabled):hover { + transform: none; +} + +.selector-title { + font-size: var(--font-size-caption); + font-weight: var(--font-weight-secondary); + color: var(--color-text-muted); + text-transform: none; + letter-spacing: 0.04em; + opacity: 0.85; +} + +.selector-actions { + display: flex; + gap: var(--spacing-xs); +} + +.health-report { + margin-top: 10px; + padding: 10px 12px; + border-radius: var(--radius-md); + border: 1px solid var(--color-border-soft); + background: var(--color-surface-alt); + display: grid; + gap: 8px; +} + +.health-remote-toggle { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: var(--font-size-caption); + color: var(--color-text-secondary); +} + +.health-remote-toggle input { + accent-color: var(--color-brand); +} + +.health-ok { + color: var(--color-success); + font-weight: var(--font-weight-secondary); +} + +.health-issue { + background: #fff6f5; + border-left: 3px solid var(--color-error); + padding: 8px 10px; + border-radius: 10px; +} + +.health-issue-title { + font-size: var(--font-size-caption); + font-weight: var(--font-weight-secondary); + color: var(--color-text-primary); + margin-bottom: 4px; +} + +.health-issue-suggestion { + font-size: var(--font-size-caption); + color: var(--color-text-secondary); + line-height: 1.4; +} + +.btn-icon { + width: 28px; + height: 28px; + border-radius: var(--radius-sm); + border: none; + background: linear-gradient(135deg, var(--color-brand) 0%, var(--color-brand-dark) 100%); + color: white; + cursor: pointer; + font-size: 16px; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition-fast) var(--ease-spring); + box-shadow: 0 2px 4px rgba(210, 107, 90, 0.2); +} + +.btn-icon:hover { + transform: translateY(-1px) scale(1.05); + box-shadow: 0 4px 8px rgba(210, 107, 90, 0.25); +} + +.btn-icon:active { + transform: translateY(0) scale(0.98); +} + +.model-select { + width: 100%; + padding: 12px var(--spacing-sm); + padding-right: 40px; + border: 1px solid var(--color-border-soft); + border-radius: var(--radius-sm); + font-size: var(--font-size-body); + font-weight: var(--font-weight-body); + background-color: var(--color-surface-alt); + color: var(--color-text-primary); + outline: none; + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='none' stroke='%23505A66' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M2 4l4 4 4-4'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 14px center; + background-size: 12px; + transition: all var(--transition-fast) var(--ease-smooth); + box-shadow: inset 0 1px 2px rgba(31, 26, 23, 0.04); +} + +.model-select:hover { + border-color: var(--color-border-strong); + background-color: var(--color-surface); +} + +.model-select:focus { + background-color: var(--color-surface); + border-color: var(--color-brand); + box-shadow: var(--shadow-input-focus); +} + +.model-input { + width: 100%; + padding: 12px var(--spacing-sm); + border: 1px solid var(--color-border-soft); + border-radius: var(--radius-sm); + font-size: var(--font-size-body); + font-weight: var(--font-weight-body); + background-color: var(--color-surface-alt); + color: var(--color-text-primary); + outline: none; + transition: all var(--transition-fast) var(--ease-smooth); + box-shadow: inset 0 1px 2px rgba(31, 26, 23, 0.04); +} + +.model-input:hover { + border-color: var(--color-border-strong); + background-color: var(--color-surface); +} + +.model-input:focus { + background-color: var(--color-surface); + border-color: var(--color-brand); + box-shadow: var(--shadow-input-focus); +} + +.config-template-hint { + margin-top: 8px; + margin-bottom: 10px; + font-size: var(--font-size-caption); + color: var(--color-text-tertiary); + line-height: 1.4; +} + +.codex-config-grid { + display: grid; + gap: var(--spacing-sm); + grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr)); + align-items: start; +} + +.codex-config-field { + min-width: 0; + margin-bottom: 0; +} + +.btn-template-editor { + width: 100%; + margin-top: 2px; +} + +/* ============================================ + 按钮 + ============================================ */ +.btn-add { + width: 100%; + padding: 14px var(--spacing-sm); + border: 1.5px dashed rgba(208, 196, 182, 0.6); + border-radius: var(--radius-lg); + background: linear-gradient(to bottom, rgba(255, 255, 255, 0.55) 0%, rgba(255, 255, 255, 0.15) 100%); + font-size: var(--font-size-body); + font-weight: var(--font-weight-secondary); + color: var(--color-text-tertiary); + cursor: pointer; + transition: all var(--transition-normal) var(--ease-spring); + display: flex; + align-items: center; + justify-content: center; + gap: var(--spacing-xs); +} + +.btn-add + .selector-section, +.selector-section + .btn-add, +.btn-add + .card-list, +.card-list + .btn-add { + margin-top: 12px; +} + +.btn-add:hover { + border-color: var(--color-brand); + color: var(--color-brand); + background: linear-gradient(to bottom, rgba(210, 107, 90, 0.05) 0%, rgba(210, 107, 90, 0.02) 100%); + transform: translateY(-1px); +} + +.btn-add:active { + transform: translateY(0) scale(0.99); +} + +.btn-add .icon { + width: 18px; + height: 18px; + transition: transform var(--transition-normal) var(--ease-spring); +} + +.btn-add:hover .icon { + transform: rotate(90deg); +} + +.btn-tool { + padding: 12px var(--spacing-sm); + border-radius: var(--radius-sm); + border: 1px solid var(--color-border-soft); + background: linear-gradient(to bottom, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0.85) 100%); + font-size: var(--font-size-body); + font-weight: var(--font-weight-secondary); + color: var(--color-text-secondary); + cursor: pointer; + transition: all var(--transition-fast) var(--ease-spring); + box-shadow: var(--shadow-subtle); + letter-spacing: -0.01em; + width: 100%; + text-align: center; +} + +.selector-section .btn-tool + .btn-tool { + margin-left: 0; + margin-top: var(--spacing-xs); +} + +.selector-header .trash-header-actions > .btn-tool + .btn-tool { + margin-top: 0; +} + +.btn-tool:not(:disabled):hover { + border-color: var(--color-brand); + color: var(--color-brand); + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(210, 107, 90, 0.12); +} + +.btn-tool:disabled, +.btn-tool[disabled] { + opacity: 0.6; + cursor: not-allowed; +} + +.btn-tool:disabled:hover, +.btn-tool[disabled]:hover, +.btn-tool:disabled:active, +.btn-tool[disabled]:active, +.btn-tool-compact:disabled:hover, +.btn-tool-compact[disabled]:hover, +.btn-tool-compact:disabled:active, +.btn-tool-compact[disabled]:active { + border-color: var(--color-border-soft); + color: var(--color-text-secondary); + transform: none; + box-shadow: var(--shadow-subtle); +} + +.btn-tool-compact { + padding: 9px 12px; + font-size: var(--font-size-secondary); +} + +.selector-header .btn-tool-compact { + padding: 6px 10px; + font-size: var(--font-size-caption); + line-height: 1.1; + box-shadow: var(--shadow-subtle); +} diff --git a/web-ui/styles/feedback.css b/web-ui/styles/feedback.css new file mode 100644 index 0000000..c2b7e71 --- /dev/null +++ b/web-ui/styles/feedback.css @@ -0,0 +1,108 @@ +/* ============================================ + Toast - 顶部横幅 + ============================================ */ +.toast { + position: fixed; + top: 16px; + left: 50%; + transform: translateX(-50%); + background: linear-gradient(to bottom, var(--color-surface) 0%, rgba(255, 255, 255, 0.95) 100%); + padding: 12px 24px; + border-radius: var(--radius-full); + box-shadow: var(--shadow-raised); + z-index: 200; + animation: slideDown var(--transition-slow) var(--ease-spring); + display: flex; + align-items: center; + gap: var(--spacing-xs); + font-size: var(--font-size-body); + font-weight: var(--font-weight-body); + border: 1px solid rgba(255, 255, 255, 0.8); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} + +.toast.error { + border-left: 3px solid var(--color-error); +} + +.toast.success { + border-left: 3px solid var(--color-success); +} + +/* ============================================ + 状态消息 + ============================================ */ +.state-message { + text-align: center; + padding: 60px var(--spacing-md); + color: var(--color-text-tertiary); + font-size: var(--font-size-body); + opacity: 0.7; + letter-spacing: -0.005em; +} + +.state-message.error { + color: var(--color-error); +} + +/* 空状态 */ +.empty-state { + text-align: center; + padding: 48px var(--spacing-md); + color: var(--color-text-tertiary); +} + +.empty-state-icon { + width: 48px; + height: 48px; + margin: 0 auto var(--spacing-sm); + opacity: 0.3; +} + +.empty-state-title { + font-size: var(--font-size-body); + font-weight: var(--font-weight-secondary); + color: var(--color-text-secondary); + margin-bottom: 4px; +} + +.empty-state-subtitle { + font-size: var(--font-size-secondary); + color: var(--color-text-tertiary); + opacity: 0.8; +} + +/* ============================================ + 动画 + ============================================ */ +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slideUp { + from { transform: translateY(24px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +@keyframes slideDown { + from { transform: translateX(-50%) translateY(-100%); opacity: 0; } + to { transform: translateX(-50%) translateY(0); opacity: 1; } +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +[v-cloak] { + display: none !important; +} + +/* 模式内容容器 */ +.mode-content { + animation: fadeIn var(--transition-normal) var(--ease-spring); +} + +/* TODO: 内容区域包裹器当前不需要额外稳定高度样式,保留注释避免误判为缺失规则。 */ diff --git a/web-ui/styles/layout-shell.css b/web-ui/styles/layout-shell.css new file mode 100644 index 0000000..3db55ec --- /dev/null +++ b/web-ui/styles/layout-shell.css @@ -0,0 +1,330 @@ +/* ============================================ + 容器 + ============================================ */ +body::before { + content: ""; + position: fixed; + inset: 0; + background-image: + linear-gradient(180deg, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0)); + opacity: 0.16; + pointer-events: none; + z-index: 0; +} + +/* 背景网格 */ +body::after { + content: ""; + position: fixed; + inset: 0; + background-image: + linear-gradient(90deg, rgba(255, 255, 255, 0.08) 1px, transparent 1px), + linear-gradient(0deg, rgba(255, 255, 255, 0.06) 1px, transparent 1px); + background-size: 180px 180px; + opacity: 0.08; + pointer-events: none; + z-index: 0; +} + +/* ============================================ + 容器 + ============================================ */ +.container { + width: 100%; + max-width: 2200px; + margin: 0 auto; + padding: 16px 12px 28px; + position: relative; + z-index: 1; +} + +/* ============================================ + 布局:三栏(侧栏 + 主区 + 状态检查器) + ============================================ */ +.app-shell { + display: grid; + grid-template-columns: 260px minmax(0, 1fr) 340px; + gap: 16px; + align-items: flex-start; +} + +.app-shell.standalone { + grid-template-columns: 1fr; +} + +.side-rail { + position: sticky; + top: var(--spacing-md); + align-self: start; + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + padding: var(--spacing-md) var(--spacing-sm); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.95) 0%, rgba(255, 250, 245, 0.9) 100%); + border: 1px solid rgba(216, 201, 184, 0.65); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-card); + min-height: 420px; +} + +.side-rail .brand-title { + font-size: 24px; + margin-bottom: 2px; +} + +.side-section { + display: flex; + flex-direction: column; + gap: 10px; +} + +.side-section-title { + font-size: var(--font-size-secondary); + font-weight: var(--font-weight-secondary); + color: var(--color-text-tertiary); + letter-spacing: 0.01em; + padding: 0 var(--spacing-xs); +} + +.side-item { + width: 100%; + text-align: left; + padding: 12px var(--spacing-sm); + border-radius: var(--radius-lg); + border: 1px solid var(--color-border-soft); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(255, 247, 240, 0.95) 100%); + color: var(--color-text-secondary); + cursor: pointer; + transition: none; + display: flex; + flex-direction: column; + gap: 6px; + box-shadow: var(--shadow-subtle); +} + +.side-item:hover { + border-color: var(--color-brand); + color: var(--color-text-primary); + transform: translateY(-1px); + box-shadow: var(--shadow-card-hover); +} + +.side-item.active { + border-color: var(--color-brand); + background: linear-gradient(135deg, rgba(201, 94, 75, 0.14), rgba(255, 255, 255, 0.96)); + color: var(--color-text-primary); + box-shadow: var(--shadow-float); +} + +.side-item.nav-intent-active { + border-color: var(--color-brand); + background: linear-gradient(135deg, rgba(201, 94, 75, 0.14), rgba(255, 255, 255, 0.96)); + color: var(--color-text-primary); + box-shadow: var(--shadow-float); +} + +.side-item.nav-intent-inactive, +.side-item.active.nav-intent-inactive { + border-color: var(--color-border-soft); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(255, 247, 240, 0.95) 100%); + color: var(--color-text-secondary); + box-shadow: var(--shadow-subtle); +} + +.side-item-title { + font-size: var(--font-size-body); + font-weight: var(--font-weight-secondary); + letter-spacing: -0.01em; +} + +.side-item-meta { + font-size: var(--font-size-caption); + color: var(--color-text-tertiary); + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.side-item-meta > span { + min-width: 0; + overflow-wrap: anywhere; + word-break: break-word; +} + +@media (min-width: 961px) { + body:not(.force-compact) #app > .top-tabs { + display: none; + } +} + +.brand-block { + display: grid; + grid-template-columns: 48px 1fr; + grid-template-rows: auto auto; + column-gap: var(--spacing-sm); + row-gap: 2px; + align-items: center; + margin-bottom: var(--spacing-md); +} + +.brand-logo-wrap { + width: 48px; + height: 48px; + border-radius: 14px; + background: rgba(208, 88, 58, 0.08); + border: 1px solid var(--color-border-soft); + display: grid; + place-items: center; + box-shadow: var(--shadow-subtle); + flex-shrink: 0; + grid-row: 1 / span 2; +} + +.brand-logo { + width: 34px; + height: 34px; + object-fit: contain; + display: block; +} + +.brand-title { + font-size: 30px; + line-height: 1.05; + font-family: var(--font-family-display); + color: var(--color-text-primary); + letter-spacing: -0.02em; +} + +.brand-title .accent { + color: var(--color-brand); +} + +.brand-subtitle { + margin-top: 8px; + font-size: var(--font-size-secondary); + color: var(--color-text-tertiary); + line-height: 1.45; +} + +.github-badge { + grid-column: 2; + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-top: 6px; + padding: 6px 10px; + border-radius: 999px; + border: 1px solid var(--color-border-soft); + background: linear-gradient(to bottom, rgba(255, 255, 255, 0.92) 0%, rgba(255, 255, 255, 0.72) 100%); + color: var(--color-text-secondary); + font-size: var(--font-size-caption); + text-decoration: none; + box-shadow: var(--shadow-subtle); + transition: all var(--transition-fast) var(--ease-spring); + min-width: 0; +} + +.github-badge-rail { + width: 100%; + align-items: center; + justify-content: flex-start; + gap: 8px; + padding: 6px 8px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.8); + border: 1px solid rgba(216, 201, 184, 0.5); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7); +} + +.github-badge:hover { + border-color: rgba(201, 94, 75, 0.5); + color: var(--color-text-primary); + transform: translateY(-1px); + box-shadow: 0 6px 12px rgba(27, 23, 20, 0.08); +} + +.github-badge-icon { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.github-badge-left { + display: inline-flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.github-badge-label { + font-size: var(--font-size-caption); + font-weight: var(--font-weight-secondary); + color: var(--color-text-secondary); +} + +.github-badge-text { + min-width: 0; + font-family: var(--font-family-mono); + font-size: var(--font-size-caption); + color: var(--color-text-tertiary); + line-height: 1.3; + display: inline-block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.github-badge-text > span { + white-space: nowrap; +} + +.github-owner { + font-weight: 600; + color: var(--color-text-secondary); +} + +.github-sep { + margin: 0 2px; + color: var(--color-text-tertiary); +} + +.github-repo { + font-weight: 600; + color: var(--color-text-primary); +} + +.github-badge-rail .github-badge-text { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + white-space: normal; + overflow: visible; + text-overflow: clip; + color: var(--color-text-secondary); +} + +.github-badge-rail .github-sep { + display: none; +} + +.github-badge-rail .github-owner, +.github-badge-rail .github-repo { + font-weight: 600; +} + +.github-badge-rail .github-badge-left { + padding: 4px; + border-radius: 999px; + background: rgba(201, 94, 75, 0.12); +} + +.github-badge-rail .github-badge-label { + display: none; +} + +.github-badge-rail:hover { + transform: translateY(-1px); + border-color: rgba(201, 94, 75, 0.5); +} diff --git a/web-ui/styles/modals-core.css b/web-ui/styles/modals-core.css new file mode 100644 index 0000000..6f0ee0b --- /dev/null +++ b/web-ui/styles/modals-core.css @@ -0,0 +1,438 @@ +/* ============================================ + 模态框 + ============================================ */ +@keyframes modalFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes modalSlideUp { + from { transform: translateY(24px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(to bottom, rgba(31, 26, 23, 0.3) 0%, rgba(31, 26, 23, 0.5) 100%); + display: flex; + justify-content: center; + align-items: center; + z-index: 100; + backdrop-filter: blur(8px) saturate(180%); + -webkit-backdrop-filter: blur(8px) saturate(180%); + animation: modalFadeIn var(--transition-normal) var(--ease-out-expo); +} + +.modal { + background: linear-gradient(to bottom, var(--color-surface) 0%, rgba(255, 255, 255, 0.98) 100%); + width: 90%; + max-width: 400px; + max-height: 90vh; + overflow-y: auto; + overscroll-behavior: contain; + border-radius: var(--radius-lg); + padding: var(--spacing-md); + box-shadow: var(--shadow-modal); + border: 1px solid rgba(255, 255, 255, 0.8); + animation: modalSlideUp var(--transition-slow) var(--ease-spring); +} + +.modal-wide { + max-width: 980px; +} + +.modal-editor { + width: min(96vw, 980px); + max-height: calc(100vh - 24px); + display: flex; + flex-direction: column; + overflow: hidden; + padding: 0; +} + +.modal-editor-header { + margin-bottom: 0; + padding: var(--spacing-md) var(--spacing-md) 0; +} + +.modal-editor-body { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: var(--spacing-sm) var(--spacing-md) 0; +} + +.modal-editor-footer { + margin-top: 0; + padding: var(--spacing-sm) var(--spacing-md) var(--spacing-md); + border-top: 1px solid var(--color-border-soft); + background: linear-gradient(to bottom, rgba(255, 255, 255, 0.82) 0%, rgba(255, 255, 255, 0.96) 100%); + backdrop-filter: blur(2px); +} + +.modal-title { + font-size: var(--font-size-title); + font-weight: var(--font-weight-title); + margin-bottom: var(--spacing-md); + color: var(--color-text-primary); + letter-spacing: -0.01em; +} + +.confirm-dialog { + max-width: 460px; +} + +.confirm-dialog-message { + font-size: var(--font-size-body); + line-height: 1.7; + color: var(--color-text-secondary); + white-space: pre-wrap; +} + +.confirm-dialog-actions { + margin-top: var(--spacing-lg); + justify-content: flex-end; +} + +.install-list { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + margin-top: var(--spacing-sm); +} + +.install-row { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: 10px 12px; + border: 1px solid var(--color-border-soft); + border-radius: var(--radius-lg); + background: var(--color-surface-alt); +} + +.install-row-main { + flex: 1; + display: flex; + flex-direction: column; + gap: 6px; +} + +.install-row-title { + font-size: var(--font-size-secondary); + color: var(--color-text-secondary); +} + +.install-command { + flex: 1; + font-family: var(--font-family-mono); + font-size: var(--font-size-secondary); + color: var(--color-text-primary); + word-break: break-all; + background: rgba(255, 255, 255, 0.7); + padding: 8px 10px; + border-radius: var(--radius-sm); + border: 1px solid var(--color-border-soft); +} + +.install-row .btn-mini { + white-space: nowrap; +} + +.install-action-tabs { + display: flex; + gap: var(--spacing-xs); + flex-wrap: wrap; +} + +.install-action-tabs .btn-mini.active { + background: rgba(201, 94, 75, 0.16); + border-color: rgba(201, 94, 75, 0.32); + color: var(--color-text-primary); +} + +.install-registry-input { + width: 100%; +} + +.install-registry-hint { + width: 100%; + margin-top: 2px; +} + +.install-help { + margin-top: var(--spacing-sm); + border-top: 1px dashed var(--color-border-soft); + padding-top: var(--spacing-sm); +} + +.install-help-list { + margin: 6px 0 0; + padding-left: 18px; + color: var(--color-text-secondary); + font-size: var(--font-size-secondary); +} + +.install-help-list li + li { + margin-top: 4px; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-sm); + margin-bottom: var(--spacing-md); + flex-wrap: wrap; +} + +.modal-header .modal-title { + margin-bottom: 0; +} + +.modal-header-actions { + display: inline-flex; + align-items: center; + gap: 8px; + margin-left: auto; +} + +.btn-modal-copy { + padding: 6px 12px; + white-space: nowrap; + flex-shrink: 0; +} + +.form-group { + margin-bottom: var(--spacing-sm); +} + +.form-label { + display: block; + font-size: var(--font-size-secondary); + font-weight: var(--font-weight-secondary); + color: var(--color-text-secondary); + margin-bottom: 7px; + letter-spacing: 0.01em; +} + +.form-input { + width: 100%; + padding: 13px var(--spacing-sm); + border: 1.5px solid var(--color-border-soft); + border-radius: var(--radius-sm); + font-size: var(--font-size-body); + background-color: var(--color-surface-alt); + color: var(--color-text-primary); + outline: none; + transition: all var(--transition-fast) var(--ease-spring); + font-family: var(--font-family-body); + box-shadow: inset 0 1px 2px rgba(31, 26, 23, 0.04); +} + +.form-input:hover { + border-color: var(--color-border-strong); +} + +.form-input:focus { + border-color: var(--color-brand); + background-color: var(--color-surface); + box-shadow: var(--shadow-input-focus); +} + +.form-input::placeholder { + color: var(--color-text-tertiary); + opacity: 0.7; +} + +.form-select { + width: 100%; + min-height: 44px; + padding: 10px 40px 10px 12px; + border: 1.5px solid var(--color-border-soft); + border-radius: var(--radius-sm); + font-size: var(--font-size-secondary); + font-weight: var(--font-weight-secondary); + background-color: var(--color-surface-alt); + color: var(--color-text-primary); + outline: none; + transition: all var(--transition-fast) var(--ease-spring); + font-family: var(--font-family-body); + box-shadow: inset 0 1px 2px rgba(31, 26, 23, 0.04); + cursor: pointer; + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='none' stroke='%23505A66' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round' d='M2 4l4 4 4-4'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 14px center; + background-size: 12px; +} + +.form-select:hover { + border-color: var(--color-border-strong); + background-color: var(--color-surface); +} + +.form-select:focus { + border-color: var(--color-brand); + background-color: var(--color-surface); + box-shadow: var(--shadow-input-focus); +} + +.form-select:disabled { + background: linear-gradient(to right, var(--color-bg) 0%, rgba(247, 241, 232, 0.5) 100%); + color: var(--color-text-tertiary); + cursor: not-allowed; + border-color: transparent; +} + +.template-editor { + min-height: min(60vh, 520px); + max-height: min(65vh, 620px); + resize: vertical; + overflow: auto; + white-space: pre; + font-family: var(--font-family-mono); + font-size: 13px; + line-height: 1.45; +} + +.template-editor-warning { + margin-top: 8px; + color: #8d5b31; + font-size: var(--font-size-caption); + line-height: 1.4; +} + + +.agents-diff-hint { + margin-top: 6px; + color: var(--color-text-tertiary); + font-size: var(--font-size-caption); +} + +.agents-diff-save-alert { + margin-bottom: 8px; + padding: 8px 10px; + border-radius: var(--radius-sm); + border: 1px solid rgba(238, 178, 90, 0.45); + background: rgba(255, 236, 204, 0.72); + color: #8d5b31; + font-size: var(--font-size-caption); + font-weight: var(--font-weight-secondary); +} + +.agents-diff-summary { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + font-size: var(--font-size-caption); + color: var(--color-text-tertiary); +} + +.agents-diff-stat { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid transparent; + font-weight: var(--font-weight-secondary); +} + +.agents-diff-stat.add { + color: #2b6a3b; + background: rgba(57, 181, 97, 0.12); + border-color: rgba(57, 181, 97, 0.2); +} + +.agents-diff-stat.del { + color: #8a2f36; + background: rgba(220, 95, 108, 0.12); + border-color: rgba(220, 95, 108, 0.2); +} + + +.agents-diff-empty { + padding: 10px 12px; + font-size: var(--font-size-caption); + color: var(--color-text-tertiary); + border: 1px dashed var(--color-border-soft); + border-radius: var(--radius-sm); + background: rgba(255, 255, 255, 0.6); +} + +.agents-diff-view { + border: 1px solid var(--color-border-soft); + border-radius: var(--radius-sm); + background: rgba(255, 255, 255, 0.7); + font-family: var(--font-family-mono); + font-size: 12px; + line-height: 1.55; + max-height: min(32vh, 280px); + overflow: auto; +} + + +.agents-diff-editor { + min-height: min(60vh, 520px); + max-height: min(65vh, 620px); +} + +.agents-diff-line { + display: grid; + grid-template-columns: 16px 1fr; + gap: 8px; + padding: 2px 10px; + align-items: start; +} + +.agents-diff-line + .agents-diff-line { + border-top: none; +} + +.agents-diff-line.add { + background: rgba(57, 181, 97, 0.08); +} + +.agents-diff-line.del { + background: rgba(220, 95, 108, 0.1); +} + +.agents-diff-line.context { + background: transparent; +} + + +.agents-diff-line-sign { + text-align: center; + color: var(--color-text-tertiary); + min-height: 20px; +} + +.agents-diff-line-text { + white-space: pre-wrap; + word-break: break-word; + color: var(--color-text-primary); +} + +.form-input:disabled, +.form-input[readonly] { + background: linear-gradient(to right, var(--color-bg) 0%, rgba(247, 241, 232, 0.5) 100%); + color: var(--color-text-tertiary); + cursor: not-allowed; + border-color: transparent; +} + +.form-hint { + font-size: var(--font-size-caption); + color: var(--color-text-tertiary); + margin-top: 5px; + opacity: 0.8; +} diff --git a/web-ui/styles/navigation-panels.css b/web-ui/styles/navigation-panels.css new file mode 100644 index 0000000..2ce5d0f --- /dev/null +++ b/web-ui/styles/navigation-panels.css @@ -0,0 +1,381 @@ +.main-tabs { + display: flex; + gap: 10px; +} + +.main-tab-btn { + flex: 1; + text-align: center; + border: 1px solid rgba(216, 201, 184, 0.55); + background: rgba(255, 255, 255, 0.95); + border-radius: var(--radius-lg); + padding: 12px 14px; + cursor: pointer; + color: var(--color-text-secondary); + font-size: var(--font-size-body); + font-weight: var(--font-weight-secondary); + box-shadow: var(--shadow-subtle); + transition: all var(--transition-normal) var(--ease-spring); +} + +.main-tab-btn:hover { + border-color: var(--color-brand); + color: var(--color-text-primary); + transform: translateY(-1px); +} + +.main-tab-btn.active { + border-color: var(--color-brand); + box-shadow: 0 10px 24px rgba(27, 23, 20, 0.08); + color: var(--color-text-primary); + background: linear-gradient(135deg, rgba(201, 94, 75, 0.12), rgba(255, 255, 255, 0.95)); +} + +.status-strip { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-xs); + margin-bottom: var(--spacing-sm); + margin-top: 6px; +} + +.status-chip { + min-width: 200px; + padding: 10px 12px; + border-radius: var(--radius-lg); + border: 1px solid var(--color-border-soft); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.99) 0%, rgba(255, 250, 245, 0.97) 100%); + box-shadow: var(--shadow-subtle); +} + +.status-chip .label { + display: block; + font-size: var(--font-size-caption); + color: var(--color-text-tertiary); + margin-bottom: 4px; +} + +.status-chip .value { + font-size: var(--font-size-body); + font-weight: var(--font-weight-secondary); + color: var(--color-text-primary); + letter-spacing: -0.01em; + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; +} + +.provider-fast-switch { + margin: 0 0 var(--spacing-sm); + padding: 10px 12px; + border-radius: var(--radius-lg); + border: 1px solid rgba(216, 201, 184, 0.6); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(255, 249, 243, 0.96) 100%); + box-shadow: var(--shadow-subtle); + display: grid; + gap: 6px; +} + +.provider-fast-switch-label { + font-size: var(--font-size-caption); + color: var(--color-text-tertiary); + letter-spacing: 0.02em; +} + +.provider-fast-switch-select { + width: 100%; + min-height: 40px; + padding: 8px 12px; + padding-right: 38px; + border: 1px solid var(--color-border-soft); + border-radius: var(--radius-sm); + font-size: var(--font-size-body); + color: var(--color-text-primary); + background-color: var(--color-surface-alt); + outline: none; + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='none' stroke='%23505A66' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M2 4l4 4 4-4'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + background-size: 12px; +} + +.provider-fast-switch-select:focus { + border-color: var(--color-brand); + box-shadow: var(--shadow-input-focus); +} + +.main-panel { + min-width: 0; + background: rgba(255, 255, 255, 0.95); + border: 1px solid rgba(216, 201, 184, 0.48); + border-radius: 18px; + box-shadow: var(--shadow-card); + padding: var(--spacing-md) var(--spacing-lg); + backdrop-filter: blur(8px); + position: relative; + overflow-x: hidden; + overflow-y: visible; +} + +.status-inspector { + position: sticky; + top: 24px; + align-self: start; + height: calc(100vh - 48px); + overflow: auto; + padding: 16px; + border-radius: var(--radius-xl); + border: 1px solid var(--color-border-soft); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.96) 0%, rgba(255, 248, 241, 0.92) 100%); + box-shadow: var(--shadow-card); + display: flex; + flex-direction: column; + gap: 12px; +} + +.inspector-head { + padding: 2px 2px 8px; + border-bottom: 1px solid rgba(216, 201, 184, 0.35); +} + +.inspector-title { + font-size: 16px; + line-height: 1.25; + font-weight: 600; + color: var(--color-text-primary); +} + +.inspector-subtitle { + margin-top: 4px; + font-size: 12px; + color: var(--color-text-tertiary); +} + +.inspector-group { + padding: 12px; + border-radius: var(--radius-lg); + border: 1px solid rgba(216, 201, 184, 0.34); + background: rgba(255, 255, 255, 0.88); + box-shadow: var(--shadow-subtle); +} + +.inspector-group-title { + font-size: 13px; + font-weight: 600; + color: var(--color-text-secondary); + margin-bottom: 10px; +} + +.inspector-kv { + display: grid; + grid-template-columns: 92px minmax(0, 1fr); + gap: 8px 10px; + align-items: start; +} + +.inspector-kv .key { + font-size: 11px; + line-height: 1.4; + color: var(--color-text-muted); + letter-spacing: 0.01em; + font-family: var(--font-family-mono); +} + +.inspector-kv .value { + font-size: 14px; + line-height: 1.35; + font-weight: 500; + color: var(--color-text-primary); + overflow-wrap: anywhere; + word-break: break-word; +} + +.inspector-kv .value.tone-ok { + color: var(--color-success); +} + +.inspector-kv .value.tone-warn { + color: #8d5b31; +} + +.inspector-kv .value.tone-error { + color: var(--color-error); +} + +.panel-header { + margin-bottom: 12px; + text-align: left; +} + +.hero { + display: flex; + align-items: center; + gap: var(--spacing-sm); + margin-bottom: var(--spacing-sm); +} + +.hero-logo { + display: none; + width: 64px; + height: 64px; + border-radius: 18px; + padding: 8px; + background: var(--color-surface-elevated); + border: 1px solid var(--color-border-soft); + box-shadow: var(--shadow-card); + object-fit: contain; +} + +.hero-title { + font-size: 48px; + line-height: 1.05; + font-family: var(--font-family-display); + color: var(--color-text-primary); + letter-spacing: -0.02em; +} + +.hero-title .accent { + color: var(--color-brand); +} + +.hero-subtitle { + margin-top: 8px; + font-size: var(--font-size-body); + color: var(--color-text-tertiary); + line-height: 1.5; +} + +.hero-github { + display: none; + margin-bottom: var(--spacing-sm); +} + +.top-tabs { + margin: 14px 0 18px; + background: rgba(255, 255, 255, 0.92); + border: 1px solid rgba(255, 255, 255, 0.7); + border-radius: 14px; + padding: 6px; + box-shadow: inset 0 1px 2px rgba(31, 26, 23, 0.06); + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; + backdrop-filter: blur(6px); +} + +.top-tab { + border: 1px solid rgba(216, 201, 184, 0.55); + border-radius: 12px; + background: rgba(255, 255, 255, 0.96); + padding: 11px 10px; + font-size: var(--font-size-body); + color: var(--color-text-secondary); + text-align: center; + cursor: pointer; + transition: none; + box-shadow: var(--shadow-subtle); +} + +.top-tab:hover { + border-color: var(--color-brand); + color: var(--color-text-primary); + transform: translateY(-1px); +} + +.top-tab.active { + border-color: var(--color-brand); + color: var(--color-text-primary); + background: linear-gradient(135deg, rgba(201, 94, 75, 0.12), rgba(255, 255, 255, 0.95)); + box-shadow: 0 10px 24px rgba(27, 23, 20, 0.08); +} + +.top-tab.nav-intent-active { + border-color: var(--color-brand); + color: var(--color-text-primary); + background: linear-gradient(135deg, rgba(201, 94, 75, 0.12), rgba(255, 255, 255, 0.95)); + box-shadow: 0 10px 24px rgba(27, 23, 20, 0.08); +} + +.top-tab.nav-intent-inactive, +.top-tab.active.nav-intent-inactive { + border-color: rgba(216, 201, 184, 0.55); + color: var(--color-text-secondary); + background: rgba(255, 255, 255, 0.96); + box-shadow: var(--shadow-subtle); +} + +#panel-sessions.session-panel-fast-hidden { + display: none !important; +} + +.config-subtabs { + display: flex; + gap: 8px; + margin-bottom: 16px; + padding: 6px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.85), rgba(255, 255, 255, 0.7)); + border-radius: var(--radius-lg); + border: 1px solid rgba(255, 255, 255, 0.7); + box-shadow: inset 0 1px 2px rgba(31, 26, 23, 0.05); +} + +.config-subtab { + border: 1px solid var(--color-border-soft); + border-radius: var(--radius-lg); + padding: 10px 14px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(255, 250, 245, 0.9)); + color: var(--color-text-secondary); + cursor: pointer; + font-size: var(--font-size-body); + font-weight: var(--font-weight-secondary); + transition: all var(--transition-normal) var(--ease-spring); + box-shadow: var(--shadow-subtle); +} + +.config-subtab:hover { + border-color: var(--color-border-strong); + color: var(--color-text-primary); +} + +.config-subtab.active { + border-color: var(--color-brand); + color: var(--color-text-primary); + background: linear-gradient(135deg, rgba(201, 94, 75, 0.18), rgba(255, 255, 255, 0.95)); + box-shadow: var(--shadow-card); +} + +.settings-subtabs { + margin-bottom: var(--spacing-sm); +} + +.settings-tab-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + margin-left: 6px; + padding: 0 6px; + border-radius: 999px; + background: rgba(210, 107, 90, 0.14); + color: var(--color-text-secondary); + font-size: 11px; + line-height: 1; +} + +.content-wrapper { + background: rgba(255, 255, 255, 0.94); + border: 1px solid rgba(216, 201, 184, 0.35); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-subtle); + padding: 0; +} + +.mode-content { + border-radius: var(--radius-lg); + background: rgba(255, 255, 255, 0.9); + box-shadow: var(--shadow-subtle); + padding: 12px; +} diff --git a/web-ui/styles/openclaw-structured.css b/web-ui/styles/openclaw-structured.css new file mode 100644 index 0000000..4fd538b --- /dev/null +++ b/web-ui/styles/openclaw-structured.css @@ -0,0 +1,266 @@ +.quick-section { + margin-top: var(--spacing-md); + padding: var(--spacing-sm); + border-radius: var(--radius-lg); + border: 1px solid var(--color-border-soft); + background: linear-gradient(140deg, rgba(255, 252, 247, 0.95), rgba(255, 255, 255, 0.6)); +} + +.quick-header { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-xs); + align-items: flex-start; + justify-content: space-between; + margin-bottom: var(--spacing-sm); +} + +.quick-title { + font-size: var(--font-size-secondary); + font-weight: var(--font-weight-secondary); + color: var(--color-text-secondary); +} + +.quick-actions { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-xs); +} + +.quick-steps { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-xs); + margin-bottom: var(--spacing-sm); +} + +.quick-step { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 999px; + border: 1px dashed var(--color-border-soft); + background: var(--color-surface); + font-size: var(--font-size-caption); + color: var(--color-text-secondary); +} + +.step-badge { + width: 20px; + height: 20px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--color-brand); + color: #fff; + font-size: 12px; + font-weight: var(--font-weight-secondary); +} + +.quick-grid { + display: grid; + gap: var(--spacing-sm); + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.quick-card { + background: var(--color-surface); + border: 1px solid var(--color-border-soft); + border-radius: var(--radius-sm); + padding: var(--spacing-sm); + box-shadow: var(--shadow-subtle); +} + +.quick-option { + display: flex; + align-items: center; + gap: 8px; + font-size: var(--font-size-caption); + color: var(--color-text-secondary); + margin-bottom: 6px; +} + +.quick-option input { + accent-color: var(--color-brand); +} + +.structured-section { + margin-top: var(--spacing-md); + padding: var(--spacing-sm); + border-radius: var(--radius-lg); + border: 1px solid var(--color-border-soft); + background: rgba(255, 255, 255, 0.6); +} + +.structured-header { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-xs); + align-items: baseline; + justify-content: space-between; + margin-bottom: var(--spacing-sm); +} + +.structured-title { + font-size: var(--font-size-secondary); + font-weight: var(--font-weight-secondary); + color: var(--color-text-secondary); +} + +.structured-grid { + display: grid; + gap: var(--spacing-sm); + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); +} + +.structured-card { + background: var(--color-surface); + border: 1px solid var(--color-border-soft); + border-radius: var(--radius-sm); + padding: var(--spacing-sm); + box-shadow: var(--shadow-subtle); +} + +.structured-card-title { + font-size: var(--font-size-body); + font-weight: var(--font-weight-secondary); + color: var(--color-text-secondary); + margin-bottom: 8px; +} + +.provider-list { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); +} + +.provider-item { + border: 1px dashed var(--color-border-soft); + border-radius: var(--radius-sm); + padding: var(--spacing-xs); + background: var(--color-surface-alt); +} + +.provider-header { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-xs); + align-items: center; + margin-bottom: 6px; +} + +.provider-name { + font-weight: var(--font-weight-secondary); + color: var(--color-text-secondary); +} + +.provider-source { + font-size: var(--font-size-caption); + color: var(--color-text-tertiary); +} + +.provider-fields { + display: grid; + gap: 6px; +} + +.provider-field { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: baseline; +} + +.provider-field-key { + font-family: var(--font-family-mono); + font-size: var(--font-size-caption); + color: var(--color-text-muted); + min-width: 110px; +} + +.provider-field-value { + font-family: var(--font-family-mono); + font-size: var(--font-size-caption); + color: var(--color-text-secondary); + word-break: break-all; +} + +.auth-profile-list { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); +} + +.auth-profile-item { + border: 1px dashed var(--color-border-soft); + border-radius: var(--radius-sm); + padding: var(--spacing-sm); + background: linear-gradient(to bottom, rgba(255, 255, 255, 0.82) 0%, rgba(247, 241, 232, 0.4) 100%); +} + +.auth-profile-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--spacing-sm); +} + +.auth-profile-main { + min-width: 0; + flex: 1; + display: flex; + flex-direction: column; + gap: 6px; +} + +.auth-profile-title { + font-size: var(--font-size-body); + font-weight: var(--font-weight-secondary); + color: var(--color-text-secondary); + letter-spacing: -0.01em; + word-break: break-all; +} + +.auth-profile-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.auth-profile-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; +} + +.auth-profile-grid { + margin-top: 10px; + display: grid; + grid-template-columns: minmax(96px, 130px) minmax(0, 1fr); + gap: 8px 12px; + align-items: start; +} + +.auth-profile-row { + display: contents; +} + +.auth-profile-key { + font-family: var(--font-family-mono); + font-size: var(--font-size-caption); + color: var(--color-text-muted); + line-height: 1.4; +} + +.auth-profile-value { + font-family: var(--font-family-mono); + font-size: var(--font-size-caption); + color: var(--color-text-secondary); + line-height: 1.4; + word-break: break-all; +} + diff --git a/web-ui/styles/responsive.css b/web-ui/styles/responsive.css new file mode 100644 index 0000000..83a595d --- /dev/null +++ b/web-ui/styles/responsive.css @@ -0,0 +1,416 @@ +.content-wrapper { + min-height: 300px; + position: relative; +} + +button:focus-visible, +select:focus-visible, +input:focus-visible, +textarea:focus-visible { + outline: 3px solid rgba(201, 94, 75, 0.25); + outline-offset: 2px; +} + +@media (max-width: 1280px) { + .app-shell { + grid-template-columns: 240px minmax(0, 1fr) 300px; + gap: 14px; + } + + .status-inspector { + top: 16px; + height: calc(100vh - 32px); + } + + .main-panel { + padding: var(--spacing-sm) var(--spacing-md); + } +} + +@media (max-width: 960px) { + .container { + padding: 12px; + } + .app-shell { + grid-template-columns: 1fr; + } + .side-rail { + display: none; + } + .status-inspector { + display: none; + } + .hero-logo { + display: block; + } + .hero-github { + display: flex; + } + .github-badge-mobile { + width: 100%; + } + .github-badge-mobile .github-badge-text, + .github-badge-mobile .github-badge-label { + font-size: var(--font-size-secondary); + } + .main-panel { + padding: var(--spacing-sm) var(--spacing-sm); + border-radius: 14px; + } + .top-tabs { + display: grid !important; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .status-strip { + gap: var(--spacing-sm); + margin-top: 4px; + } + .status-chip { + flex: 1 1 calc(50% - var(--spacing-sm)); + min-width: 0; + } + + .skills-summary-strip { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .skills-panel-header { + flex-direction: column; + align-items: stretch; + } + + .skills-modal-actions { + align-items: flex-start; + } + + .market-target-switch-compact { + justify-content: flex-start; + } +} + +@media (max-width: 720px) { + .main-title { + font-size: 40px; + } + + .hero-title { + font-size: 32px; + } + + .subtitle { + font-size: var(--font-size-secondary); + margin-bottom: 16px; + } + + .segmented-control { + flex-direction: column; + gap: 6px; + } + + .status-strip { + flex-direction: row; + flex-wrap: wrap; + } + + .market-grid { + grid-template-columns: 1fr; + } + + .market-action-grid { + grid-template-columns: 1fr; + } + + .status-chip { + flex: 1 1 100%; + } +} + +@media (max-width: 540px) { + .trash-header-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + width: 100%; + } + + .selector-header .trash-header-actions > .btn-tool, + .selector-header .trash-header-actions > .btn-tool-compact { + width: 100%; + min-width: 0; + height: 44px; + min-height: 44px; + } + + body { + padding: var(--spacing-md) var(--spacing-sm); + } + .container { + padding: 0 var(--spacing-sm) var(--spacing-md); + } + .hero-title { + font-size: 32px; + } + .hero-subtitle { + font-size: var(--font-size-secondary); + } + .top-tabs { + grid-template-columns: repeat(1, minmax(0, 1fr)); + } + .main-panel { + padding: var(--spacing-sm); + } + .card { + padding: 12px; + } + .session-layout { + grid-template-columns: 1fr; + height: auto; + min-height: 0; + } + + .status-strip { + gap: var(--spacing-xs); + } + + .status-chip { + flex: 1 1 100%; + min-width: 100%; + } + + .btn-add, + .btn-tool, + .card-action-btn, + .btn-session-export, + .btn-session-open, + .btn-session-clone, + .btn-session-refresh, + .btn-session-delete, + .btn-icon, + .session-item-copy { + min-height: 44px; + padding-top: 12px; + padding-bottom: 12px; + } + + .btn-icon, + .session-item-copy { + min-width: 44px; + } + + .session-item { + min-height: 75px; + height: 75px; + contain-intrinsic-size: 75px; + padding: 12px 14px; + } + + .session-item-header { + flex-direction: row; + align-items: center; + gap: 8px; + } + + .session-item-main { + align-items: center; + } + + .session-item-copy { + width: 44px; + height: 44px; + min-width: 44px; + min-height: 44px; + border-radius: 6px; + padding: 2px; + display: inline-flex; + align-items: center; + justify-content: center; + transform: translate(-3px, 0); + } + + .session-item-copy svg { + width: 12px; + height: 12px; + } + + .session-item-title { + -webkit-line-clamp: 1; + max-height: none; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } + + .session-item-actions { + margin-top: 0; + } + + .session-item-meta { + margin-top: -2px; + margin-bottom: 0; + gap: 4px; + align-items: center; + } + + .trash-item.session-item { + min-height: auto; + height: auto; + contain-intrinsic-size: auto; + } + + .trash-item-header { + flex-direction: column; + align-items: stretch; + gap: 10px; + } + + .trash-item-mainline { + flex-direction: column; + align-items: flex-start; + gap: 6px; + } + + .trash-item-side { + width: 100%; + min-width: 0; + align-items: stretch; + gap: 10px; + padding-top: 8px; + border-top: 1px dashed rgba(216, 201, 184, 0.55); + } + + .trash-item-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + justify-content: flex-start; + width: 100%; + } + + .trash-item-actions .btn-mini { + width: 100%; + min-height: 44px; + display: inline-flex; + align-items: center; + justify-content: center; + } + + .trash-item .session-count-badge { + align-self: flex-start; + margin-top: 0; + } + + .trash-item-title { + -webkit-line-clamp: 3; + max-height: none; + white-space: normal; + text-overflow: clip; + overflow: hidden; + } + + .trash-item-meta { + margin-top: 6px; + margin-bottom: 0; + gap: 6px; + align-items: center; + } + + .trash-item-time { + padding-top: 2px; + line-height: 1.35; + text-align: right; + } + + .trash-item-path { + grid-template-columns: 1fr; + gap: 4px; + } + + .card { + padding: 8px; + } + + .card-list { + gap: 4px; + margin-bottom: 4px; + } + + .card-actions { + gap: 8px; + } + + .card-action-btn { + width: 40px; + height: 40px; + border-radius: 10px; + } + + .card-action-btn svg { + width: 18px; + height: 18px; + } + + .card-trailing { + grid-auto-flow: row; + grid-auto-columns: 1fr; + justify-content: stretch; + justify-items: end; + } + + .card-trailing .card-actions { + width: 100%; + justify-content: flex-end; + justify-self: end; + } + + /* 移动端不显示配置状态 pill,节省空间 */ + .card-trailing .pill { + display: none; + } + + .auth-profile-item { + padding: 10px; + } + + .auth-profile-header { + flex-direction: column; + align-items: stretch; + gap: 10px; + } + + .auth-profile-actions { + justify-content: flex-start; + } + + .auth-profile-grid { + grid-template-columns: 1fr; + gap: 6px; + margin-top: 8px; + } + + .auth-profile-row { + display: flex; + flex-direction: column; + gap: 2px; + padding-bottom: 4px; + border-bottom: 1px dashed rgba(160, 145, 130, 0.25); + } + + .auth-profile-row:last-child { + border-bottom: none; + padding-bottom: 0; + } + + .session-preview { + border-radius: var(--radius-lg); + } + + .skills-summary-strip { + grid-template-columns: 1fr; + } + + .skills-panel { + padding: 10px; + } + + .skills-root-box { + font-size: 11px; + } +} diff --git a/web-ui/styles/sessions-list.css b/web-ui/styles/sessions-list.css new file mode 100644 index 0000000..489243d --- /dev/null +++ b/web-ui/styles/sessions-list.css @@ -0,0 +1,412 @@ +.session-empty { + padding: 28px var(--spacing-sm); + text-align: center; + border: 1px dashed var(--color-border-soft); + border-radius: var(--radius-lg); + color: var(--color-text-tertiary); + background: var(--bg-warm-gradient); + position: relative; + box-shadow: var(--shadow-subtle); +} + +.session-empty::before { + content: ""; + display: block; + width: 36px; + height: 36px; + border-radius: 50%; + margin: 0 auto 10px; + background: rgba(210, 107, 90, 0.12); + box-shadow: inset 0 0 0 6px rgba(255, 255, 255, 0.7); +} + +.trash-list { + display: grid; + gap: 12px; + margin-top: 12px; +} + +.trash-item.session-item { + min-height: auto; + height: auto; + cursor: default; + content-visibility: visible; + contain-intrinsic-size: auto; +} + +.trash-item.session-item:hover, +.trash-item.session-item:active { + transform: none; + border-color: var(--color-border-soft); + background: linear-gradient(to bottom, var(--color-surface) 0%, rgba(255, 255, 255, 0.92) 100%); + box-shadow: var(--shadow-subtle); +} + +.trash-item.session-item::before { + background: linear-gradient(180deg, rgba(70, 86, 110, 0.26), rgba(70, 86, 110, 0.08)); +} + +.trash-item-main { + min-width: 0; + flex: 1; +} + +.trash-item-mainline { + display: flex; + align-items: flex-start; + gap: 8px; +} + +.trash-item-title { + flex: 1; + font-size: var(--font-size-body); + font-weight: var(--font-weight-secondary); + color: var(--color-text-primary); + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + white-space: normal; + overflow: hidden; + overflow-wrap: anywhere; +} + +.trash-item-meta { + margin-top: 6px; +} + +.trash-item-side { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 8px; + flex-shrink: 0; + min-width: 132px; +} + +.trash-item-actions { + display: grid; + grid-template-columns: repeat(2, minmax(108px, 108px)); + align-self: flex-end; + justify-content: flex-end; + gap: 8px; +} + +.trash-item-actions .btn-mini { + width: 100%; + min-height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.trash-item-time { + width: 100%; + text-align: right; + white-space: nowrap; + color: var(--color-text-tertiary); +} + +.trash-item-path { + margin-top: 8px; + display: grid; + grid-template-columns: 48px minmax(0, 1fr); + gap: 8px; + align-items: start; + white-space: normal; + overflow-wrap: anywhere; +} + +.trash-item-label { + display: inline-block; + color: var(--color-text-secondary); + font-weight: var(--font-weight-secondary); +} + +.trash-item-path span:last-child { + min-width: 0; + word-break: break-word; +} + +.trash-item .session-count-badge { + margin-top: 2px; +} + +.session-layout { + display: grid; + grid-template-columns: minmax(260px, 360px) minmax(0, 1fr); + gap: var(--spacing-sm); + align-items: start; + height: min(72vh, 760px); + min-height: 520px; +} + +.session-layout.session-standalone { + grid-template-columns: minmax(0, 1fr); +} + +.session-standalone-page { + max-width: 960px; + margin: 0 auto; + padding: var(--spacing-sm) 0; +} + +.session-standalone-title { + font-size: var(--font-size-title); + font-weight: var(--font-weight-title); + color: var(--color-text-primary); + margin-bottom: var(--spacing-sm); + letter-spacing: -0.01em; +} + +.session-standalone-text { + white-space: pre-wrap; + font-family: var(--font-family-body); + font-size: var(--font-size-body); + line-height: 1.7; + color: var(--color-text-primary); + word-break: break-word; +} + +.session-list { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); + position: sticky; + top: 12px; + height: 100%; + max-height: none; + overflow-y: auto; + overflow-x: hidden; + padding-right: 4px; + min-width: 0; + scrollbar-width: thin; + scrollbar-color: rgba(166, 149, 130, 0.85) transparent; +} + +.session-list::-webkit-scrollbar, +.session-preview-scroll::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +.session-list::-webkit-scrollbar-track, +.session-preview-scroll::-webkit-scrollbar-track { + background: transparent; + border-radius: 999px; +} + +.session-list::-webkit-scrollbar-thumb, +.session-preview-scroll::-webkit-scrollbar-thumb { + background: linear-gradient(to bottom, rgba(191, 174, 154, 0.95) 0%, rgba(160, 141, 121, 0.95) 100%); + border-radius: 999px; + border: 2px solid rgba(255, 255, 255, 0.9); +} + +.session-list::-webkit-scrollbar-thumb:hover, +.session-preview-scroll::-webkit-scrollbar-thumb:hover { + background: linear-gradient(to bottom, rgba(175, 156, 136, 0.95) 0%, rgba(145, 126, 107, 0.95) 100%); +} + +.session-item { + border: 1px solid var(--color-border-soft); + border-radius: var(--radius-sm); + background: linear-gradient(to bottom, var(--color-surface) 0%, rgba(255, 255, 255, 0.92) 100%); + padding: 16px; + cursor: pointer; + transition: all var(--transition-fast) var(--ease-spring); + user-select: none; + min-width: 0; + position: relative; + overflow: hidden; + min-height: 102px; + content-visibility: auto; + contain-intrinsic-size: 102px; +} + +.session-item-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 4px; +} + +.session-item-main { + min-width: 0; + flex: 1; + display: flex; + align-items: center; + gap: 8px; +} + +.session-item:hover { + border-color: var(--color-brand); + background: linear-gradient(to bottom, rgba(210, 107, 90, 0.08) 0%, rgba(255, 255, 255, 0.98) 100%); + transform: translateY(-1px); +} + +.session-item:focus-visible { + outline: 3px solid rgba(201, 94, 75, 0.25); + outline-offset: 2px; + border-color: var(--color-brand); + background: linear-gradient(to bottom, rgba(210, 107, 90, 0.08) 0%, rgba(255, 255, 255, 0.98) 100%); +} + +.session-item::before { + content: ""; + position: absolute; + left: 0; + top: 10px; + bottom: 10px; + width: 3px; + border-radius: 999px; + background: rgba(210, 107, 90, 0.15); + transition: background var(--transition-fast) var(--ease-spring); +} + +.session-item:active { + transform: scale(0.99); +} + +.session-item.active { + border-color: var(--color-brand); + background: linear-gradient(to bottom, rgba(210, 107, 90, 0.1) 0%, rgba(255, 255, 255, 0.98) 100%); + box-shadow: 0 6px 16px rgba(210, 107, 90, 0.12); +} + +.session-item.pinned { + border-color: rgba(208, 88, 58, 0.42); + background: linear-gradient(to bottom, rgba(210, 107, 90, 0.12) 0%, rgba(255, 251, 247, 0.98) 100%); + box-shadow: 0 8px 18px rgba(210, 107, 90, 0.10); +} + +.session-item.pinned::before { + background: linear-gradient(180deg, rgba(201, 94, 75, 0.8), rgba(201, 94, 75, 0.32)); +} + +.session-item.active::before { + background: linear-gradient(180deg, rgba(201, 94, 75, 0.9), rgba(201, 94, 75, 0.4)); +} + +.session-item-title { + font-size: var(--font-size-body); + font-weight: var(--font-weight-secondary); + color: var(--color-text-primary); + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + white-space: normal; + overflow: hidden; + flex: 1; + max-width: none; +} + +.session-item-actions { + display: inline-flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.session-item-copy { + border: 1px solid rgba(70, 86, 110, 0.35); + background: rgba(70, 86, 110, 0.08); + color: var(--color-text-secondary); + width: 28px; + height: 28px; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; + transition: all var(--transition-fast) var(--ease-spring); +} + +.session-item-copy:hover { + border-color: rgba(70, 86, 110, 0.7); + background: rgba(70, 86, 110, 0.16); + color: var(--color-text-primary); + transform: translateY(-1px); +} + +.session-item-copy:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.session-item-copy svg { + width: 16px; + height: 16px; +} + +.session-item-pin { + border-color: rgba(208, 88, 58, 0.24); +} + +.session-item-pin .pin-icon, +.session-item-pin svg { + color: rgba(208, 88, 58, 0.78); +} + +.session-item.pinned .session-item-pin { + background: rgba(208, 88, 58, 0.16); + border-color: rgba(208, 88, 58, 0.46); + box-shadow: inset 0 0 0 1px rgba(208, 88, 58, 0.08); +} + +.session-item.pinned .session-item-pin .pin-icon, +.session-item.pinned .session-item-pin svg { + color: var(--color-brand-dark); +} + +.session-item-sub.session-item-snippet { + display: none !important; +} + +.session-item-sub { + font-size: var(--font-size-caption); + color: var(--color-text-tertiary); + line-height: 1.35; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.session-item-sub.session-item-wrap { + white-space: normal; +} + +.session-item-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin-top: 2px; + margin-bottom: 2px; +} + +.session-item-time { + font-size: var(--font-size-caption); + color: var(--color-text-tertiary); + white-space: nowrap; +} + +.session-preview { + border: 1px solid var(--color-border-soft); + border-radius: var(--radius-xl); + background: linear-gradient(to bottom, var(--color-surface-elevated) 0%, rgba(255, 255, 255, 0.96) 100%); + box-shadow: var(--shadow-card); + min-height: 0; + max-height: none; + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; + position: relative; + transform: translateX(4px); + transition: transform var(--transition-normal) var(--ease-spring-soft), box-shadow var(--transition-normal) var(--ease-spring-soft); +} diff --git a/web-ui/styles/sessions-preview.css b/web-ui/styles/sessions-preview.css new file mode 100644 index 0000000..7a3ce40 --- /dev/null +++ b/web-ui/styles/sessions-preview.css @@ -0,0 +1,405 @@ +.session-preview.active { + box-shadow: var(--shadow-float); + transform: translateX(0); +} + +.session-preview-scroll { + position: relative; + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + padding-right: 68px; + display: flex; + flex-direction: column; + scrollbar-width: thin; + scrollbar-color: rgba(166, 149, 130, 0.85) transparent; +} + +.session-preview-header { + padding: 12px var(--spacing-sm); + border-bottom: 1px solid var(--color-border-soft); + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--spacing-sm); + position: sticky; + top: 0; + z-index: 2; + background: linear-gradient(to bottom, rgba(255, 255, 255, 0.98) 0%, rgba(255, 255, 255, 0.92) 100%); + backdrop-filter: blur(6px); +} + +.session-preview-header > div:first-child { + min-width: 0; + flex: 1; +} + +.session-preview-title { + font-size: var(--font-size-body); + font-weight: var(--font-weight-title); + color: var(--color-text-primary); + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + word-break: break-word; +} + +.session-preview-sub { + margin-top: 4px; + font-size: var(--font-size-caption); + color: var(--color-text-tertiary); + line-height: 1.35; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.session-preview-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + margin-top: 4px; +} + +.session-preview-meta-item { + font-size: var(--font-size-caption); + color: var(--color-text-tertiary); + line-height: 1.35; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.session-preview-meta-item:not(:last-child)::after { + content: "·"; + margin: 0 6px; + color: var(--color-text-tertiary); + opacity: 0.7; +} + +.session-actions { + display: flex; + align-items: center; + gap: 8px; + flex: 0 1 auto; + max-width: 100%; + margin-left: 0; + flex-wrap: wrap; + justify-content: flex-end; +} + +.session-preview-body { + flex: 1; + min-height: 0; + padding: var(--spacing-sm); + display: flex; + flex-direction: column; + gap: 10px; +} + +.session-preview-messages { + min-width: 0; + display: flex; + flex-direction: column; + gap: 10px; + contain: layout style; +} + +.session-timeline { + position: absolute; + top: var(--session-preview-header-offset, 72px); + right: 8px; + bottom: 12px; + width: 56px; + height: auto; + border-radius: 12px; + border: 1px solid rgba(208, 196, 182, 0.5); + background: linear-gradient(to bottom, rgba(255, 255, 255, 0.92) 0%, rgba(252, 246, 239, 0.94) 100%); + box-shadow: 0 4px 12px rgba(31, 26, 23, 0.06); + padding: 6px 4px 28px; + z-index: 3; +} + +.session-timeline-track { + position: absolute; + left: 50%; + top: 10px; + bottom: 32px; + width: 2px; + transform: translateX(-50%); + background: linear-gradient(to bottom, rgba(166, 149, 130, 0.3) 0%, rgba(166, 149, 130, 0.65) 100%); + border-radius: 999px; +} + +.session-timeline-node { + position: absolute; + left: 50%; + width: 10px; + height: 10px; + border-radius: 50%; + border: 1px solid rgba(139, 118, 104, 0.7); + background: rgba(255, 255, 255, 0.94); + transform: translate(-50%, -50%); + cursor: pointer; + padding: 0; + transition: none; + will-change: auto; +} + +.session-timeline-node:hover { + transform: translate(-50%, -50%); + border-color: rgba(201, 94, 75, 0.85); + background: rgba(255, 255, 255, 1); + box-shadow: none; +} + +.session-timeline-node.active { + transform: translate(-50%, -50%); + border-color: rgba(201, 94, 75, 0.95); + background: rgba(201, 94, 75, 0.95); + box-shadow: none; +} + +.session-timeline-current { + position: absolute; + left: 4px; + right: 4px; + bottom: 4px; + font-size: 10px; + line-height: 1.2; + color: var(--color-text-tertiary); + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.session-msg { + border-radius: 10px; + padding: 10px 12px 10px 18px; + border: 1px solid rgba(208, 196, 182, 0.45); + background: rgba(255, 255, 255, 0.75); + position: relative; + box-shadow: 0 2px 6px rgba(31, 26, 23, 0.04); + contain: layout style paint; +} + +.session-msg.user { + border-color: rgba(210, 107, 90, 0.35); + background: rgba(210, 107, 90, 0.08); +} + +.session-msg::before { + content: ""; + position: absolute; + left: 8px; + top: 10px; + bottom: 10px; + width: 3px; + border-radius: 999px; + background: rgba(139, 118, 104, 0.45); +} + +.session-msg.user::before { + background: rgba(210, 107, 90, 0.85); +} + +.session-msg.assistant::before { + background: rgba(90, 139, 106, 0.6); +} + +.session-msg-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--spacing-xs); + margin-bottom: 6px; + font-size: var(--font-size-caption); + color: var(--color-text-tertiary); +} + +.session-msg-meta { + min-width: 0; + flex: 1; + display: flex; + align-items: center; + gap: 8px; +} + +.session-msg-role { + font-weight: var(--font-weight-secondary); + color: var(--color-text-secondary); +} + +.session-msg-time { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--color-text-tertiary); +} + +.session-msg-content { + font-size: var(--font-size-secondary); + line-height: 1.55; + color: var(--color-text-primary); + white-space: pre-wrap; + word-break: break-word; +} + +.session-preview-empty { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: var(--color-text-tertiary); + font-size: var(--font-size-secondary); + padding: var(--spacing-md); + text-align: center; + flex-direction: column; + gap: 8px; +} + +.session-preview-empty::before { + content: ""; + width: 34px; + height: 34px; + border-radius: 50%; + background: rgba(210, 107, 90, 0.12); + box-shadow: inset 0 0 0 6px rgba(255, 255, 255, 0.7); +} + +@media (max-width: 1100px) { + .session-layout { + grid-template-columns: 1fr; + height: auto; + min-height: 0; + } + + .session-toolbar { + grid-template-columns: 1fr; + } + + .session-toolbar-actions { + justify-content: flex-start; + } + + .session-toolbar-footer { + justify-content: flex-start; + } + + .session-list { + position: static; + max-height: 300px; + height: auto; + } + + .session-preview { + min-height: 360px; + max-height: none; + height: auto; + position: relative; + transform: none; + box-shadow: var(--shadow-card); + } + + .session-preview-scroll { + padding-right: 0; + } + + .session-timeline { + display: none; + } + + .session-preview-header { + flex-direction: column; + align-items: stretch; + } + + .session-actions { + justify-content: flex-start; + } + + .session-preview.active { + box-shadow: var(--shadow-float); + } +} + +@media (max-width: 520px) { + .session-item-header { + flex-direction: column; + align-items: stretch; + } + + .session-item-actions { + justify-content: flex-end; + } + + .session-actions { + width: 100%; + flex-direction: column; + align-items: stretch; + } + + .btn-session-refresh, + .btn-session-export { + width: 100%; + } + + .session-toolbar-group.session-toolbar-actions { + flex-direction: column; + align-items: stretch; + } + + .session-toolbar-group.session-toolbar-actions .btn-tool { + width: 100%; + } + + .trash-item-header { + flex-direction: column; + align-items: stretch; + gap: 10px; + } + + .trash-item-mainline { + flex-direction: column; + align-items: flex-start; + gap: 6px; + } + + .trash-item-side { + width: 100%; + min-width: 0; + align-items: stretch; + gap: 10px; + } + + .trash-item-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + justify-content: flex-start; + width: 100%; + } + + .trash-item-actions .btn-mini { + width: 100%; + min-height: 40px; + display: inline-flex; + align-items: center; + justify-content: center; + } + + .trash-item-time { + text-align: right; + } +} + +.btn[disabled] { + opacity: 0.5; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + diff --git a/web-ui/styles/sessions-toolbar-trash.css b/web-ui/styles/sessions-toolbar-trash.css new file mode 100644 index 0000000..2061c1c --- /dev/null +++ b/web-ui/styles/sessions-toolbar-trash.css @@ -0,0 +1,243 @@ +.session-toolbar { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: var(--spacing-xs); + margin-bottom: var(--spacing-sm); + align-items: end; +} + +.session-toolbar-group { + display: flex; + align-items: center; + gap: var(--spacing-xs); + flex-wrap: wrap; + min-width: 0; +} + +.session-toolbar-grow { + grid-column: 1 / -1; +} + +.session-toolbar-actions { + justify-content: flex-end; +} + +.session-toolbar-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--spacing-xs); + margin-top: -2px; + padding-top: 6px; + margin-bottom: 12px; + border-top: 1px dashed var(--color-border-soft); +} + +.session-toolbar-footer .quick-option { + margin: 0; + padding: 6px 10px; + border-radius: var(--radius-sm); + border: 1px solid var(--color-border-soft); + background: linear-gradient(to bottom, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0.8) 100%); + box-shadow: inset 0 1px 2px rgba(31, 26, 23, 0.04); + transition: all var(--transition-fast) var(--ease-spring); + line-height: 1.2; +} + +.session-toolbar-footer .quick-option:hover { + border-color: var(--color-border-strong); +} + +.session-source-select, +.session-path-select, +.session-query-input, +.session-role-select, +.session-time-select { + flex: 1; + min-width: 160px; + padding: 10px 12px; + border-radius: var(--radius-sm); + border: 1px solid var(--color-border-soft); + background: linear-gradient(to bottom, var(--color-surface) 0%, rgba(255, 255, 255, 0.92) 100%); + color: var(--color-text-secondary); + font-size: var(--font-size-body); + font-family: var(--font-family); + outline: none; + transition: all var(--transition-fast) var(--ease-spring); + box-shadow: inset 0 1px 2px rgba(31, 26, 23, 0.04); +} + +.session-query-input { + flex: 2; + min-width: 220px; +} + +.session-source-select:hover, +.session-path-select:hover, +.session-query-input:hover, +.session-role-select:hover, +.session-time-select:hover { + border-color: var(--color-border-strong); +} + +.session-source-select:focus, +.session-path-select:focus, +.session-query-input:focus, +.session-role-select:focus, +.session-time-select:focus { + border-color: var(--color-brand); + box-shadow: var(--shadow-input-focus); +} + +.session-hint { + font-size: var(--font-size-secondary); + color: var(--color-text-tertiary); + margin-bottom: 12px; + line-height: 1.45; +} + +.session-card { + align-items: flex-start; + cursor: default; +} + +.session-card:hover { + transform: none; + box-shadow: var(--shadow-card); +} + +.session-card .card-leading { + align-items: flex-start; +} + +.session-meta { + margin-top: 6px; + font-size: var(--font-size-caption); + color: var(--color-text-tertiary); + line-height: 1.4; + word-break: break-all; +} + +.session-actions { + display: flex; + gap: var(--spacing-xs); + align-items: center; + margin-left: var(--spacing-sm); + flex-shrink: 0; +} + +.session-source { + font-size: var(--font-size-caption); + color: var(--color-text-tertiary); + border: 1px solid var(--color-border-soft); + border-radius: 999px; + padding: 2px 8px; + background: var(--color-surface-alt); + white-space: nowrap; +} + +.session-count-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 22px; + height: 22px; + padding: 0 7px; + border-radius: 999px; + background: linear-gradient(135deg, var(--color-brand) 0%, var(--color-brand-dark) 100%); + color: #fff; + font-size: var(--font-size-caption); + font-weight: var(--font-weight-secondary); + line-height: 1; + box-shadow: 0 4px 10px rgba(208, 88, 58, 0.16); + flex-shrink: 0; +} + +.trash-list-footer { + display: flex; + justify-content: center; + margin-top: var(--spacing-sm); +} + +.btn-session-export, +.btn-session-open, +.btn-session-clone, +.btn-session-refresh { + border: 1px solid var(--color-border-soft); + border-radius: var(--radius-sm); + background: linear-gradient(to bottom, var(--color-surface) 0%, rgba(255, 255, 255, 0.9) 100%); + color: var(--color-text-secondary); + padding: 8px 12px; + font-size: var(--font-size-secondary); + font-weight: var(--font-weight-secondary); + cursor: pointer; + transition: all var(--transition-fast) var(--ease-spring); + white-space: nowrap; + box-shadow: var(--shadow-subtle); + letter-spacing: -0.01em; +} + +.btn-session-delete { + border: 1px solid rgba(189, 70, 68, 0.45); + border-radius: var(--radius-sm); + background: linear-gradient(to bottom, rgba(255, 245, 245, 0.95) 0%, rgba(255, 255, 255, 0.9) 100%); + color: #b74545; + padding: 8px 12px; + font-size: var(--font-size-secondary); + font-weight: var(--font-weight-secondary); + cursor: pointer; + transition: all var(--transition-fast) var(--ease-spring); + white-space: nowrap; + box-shadow: var(--shadow-subtle); + letter-spacing: -0.01em; +} + +.btn-session-refresh:hover { + border-color: var(--color-brand); + color: var(--color-brand); + transform: translateY(-1px); +} + +.btn-session-refresh:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.btn-session-export:hover, +.btn-session-open:hover { + border-color: var(--color-brand); + color: var(--color-brand); + transform: translateY(-1px); +} + +.btn-session-export:disabled, +.btn-session-open:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.btn-session-clone:hover { + border-color: var(--color-brand); + color: var(--color-brand); + transform: translateY(-1px); +} + +.btn-session-clone:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.btn-session-delete:hover { + border-color: rgba(189, 70, 68, 0.8); + color: #9f3b3b; + transform: translateY(-1px); +} + +.btn-session-delete:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} diff --git a/web-ui/styles/skills-list.css b/web-ui/styles/skills-list.css new file mode 100644 index 0000000..2c99193 --- /dev/null +++ b/web-ui/styles/skills-list.css @@ -0,0 +1,298 @@ +.skills-hint-line, +.hint-single-line { + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +.skill-list { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); + margin-bottom: var(--spacing-sm); + max-height: min(52vh, 440px); + overflow-y: auto; + padding-right: 2px; + scrollbar-width: thin; + scrollbar-color: rgba(166, 149, 130, 0.82) transparent; +} + +.skill-list::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +.skill-list::-webkit-scrollbar-track { + background: transparent; + border-radius: 999px; +} + +.skill-list::-webkit-scrollbar-thumb { + background: linear-gradient(to bottom, rgba(191, 174, 154, 0.95) 0%, rgba(160, 141, 121, 0.9) 100%); + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.9); +} + +.skill-list::-webkit-scrollbar-thumb:hover { + background: linear-gradient(to bottom, rgba(176, 157, 137, 0.95) 0%, rgba(145, 126, 107, 0.92) 100%); +} + +.skill-item { + display: flex; + align-items: flex-start; + gap: var(--spacing-xs); + border: 1px dashed var(--color-border-soft); + border-radius: var(--radius-sm); + padding: var(--spacing-xs); + background: var(--color-surface-alt); + transition: border-color var(--transition-fast) var(--ease-spring), background-color var(--transition-fast) var(--ease-spring); +} + +.skill-item-main { + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; +} + +.skill-item-title { + font-size: var(--font-size-secondary); + font-weight: var(--font-weight-secondary); + color: var(--color-text-secondary); +} + +.skill-item-description { + font-size: var(--font-size-caption); + line-height: 1.45; + color: var(--color-text-tertiary); +} + +.skill-item-meta { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-xs); + align-items: center; + min-width: 0; +} + +.skill-item-path { + font-family: var(--font-family-mono); + font-size: var(--font-size-caption); + color: var(--color-text-tertiary); + word-break: break-all; +} + +.skill-item:hover { + border-color: rgba(201, 94, 75, 0.35); +} + +.skill-item.selected { + border-color: rgba(201, 94, 75, 0.55); + background: linear-gradient(to bottom, rgba(201, 94, 75, 0.10) 0%, rgba(201, 94, 75, 0.04) 100%); +} + +.skills-empty-state { + margin-bottom: var(--spacing-sm); + border: 1px dashed var(--color-border-soft); + border-radius: var(--radius-sm); + background: linear-gradient(to bottom, rgba(255, 255, 255, 0.78) 0%, rgba(255, 255, 255, 0.58) 100%); + color: var(--color-text-tertiary); + font-size: var(--font-size-secondary); + text-align: center; + padding: 18px 12px; +} + +.skills-import-block { + margin-bottom: var(--spacing-sm); +} + +.skills-import-title { + font-size: var(--font-size-body); + font-weight: var(--font-weight-secondary); + color: var(--color-text-secondary); +} + +.skills-import-list { + max-height: min(28vh, 260px); +} + +.skills-import-empty { + margin-bottom: 0; +} + +.list-row { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-xs); + align-items: center; + margin-bottom: var(--spacing-xs); +} + +.list-row:last-child { + margin-bottom: 0; +} + +.list-row .form-input { + flex: 1; + min-width: 140px; +} + +.btn-mini { + padding: 6px 10px; + border-radius: var(--radius-sm); + border: 1px solid var(--color-border-soft); + background: linear-gradient(to bottom, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0.85) 100%); + font-size: var(--font-size-caption); + font-weight: var(--font-weight-secondary); + color: var(--color-text-secondary); + cursor: pointer; + transition: all var(--transition-fast) var(--ease-spring); + box-shadow: var(--shadow-subtle); +} + +.btn-mini:hover { + border-color: var(--color-brand); + color: var(--color-brand); + transform: translateY(-1px); +} + +.btn-mini.delete { + color: var(--color-error); + border-color: rgba(193, 72, 59, 0.35); +} + +.btn-mini.delete:hover { + border-color: rgba(193, 72, 59, 0.7); + color: var(--color-error); +} + +.btn-group { + display: flex; + gap: var(--spacing-sm); + margin-top: var(--spacing-md); +} + +.btn { + flex: 1; + padding: 14px var(--spacing-sm); + border-radius: var(--radius-sm); + font-size: var(--font-size-body); + font-weight: var(--font-weight-secondary); + cursor: pointer; + transition: all var(--transition-fast) var(--ease-spring); + border: 1px solid var(--color-border-soft); + background: linear-gradient(to bottom, var(--color-surface) 0%, rgba(255, 255, 255, 0.95) 100%); + color: var(--color-text-secondary); + box-shadow: var(--shadow-subtle); + letter-spacing: -0.01em; +} + +.btn:active { + transform: scale(0.985); +} + +.btn-cancel { + background: linear-gradient(to bottom, var(--color-bg) 0%, rgba(247, 241, 232, 0.8) 100%); + color: var(--color-text-primary); + border: 1px solid var(--color-border-soft); +} + +.btn-cancel:hover { + background: linear-gradient(to bottom, var(--color-border) 0%, rgba(208, 196, 182, 0.5) 100%); +} + +.btn-confirm { + background: linear-gradient(135deg, var(--color-brand) 0%, var(--color-brand-dark) 100%); + color: white; + box-shadow: 0 2px 4px rgba(210, 107, 90, 0.2); + border: none; +} + +.btn-confirm:hover { + box-shadow: 0 4px 8px rgba(210, 107, 90, 0.25); + filter: brightness(1.05); +} + +.btn-confirm.secondary { + background: linear-gradient(135deg, var(--color-success) 0%, rgba(90, 139, 106, 0.85) 100%); + box-shadow: 0 2px 4px rgba(90, 139, 106, 0.2); + border: none; +} + +.btn-confirm.secondary:hover { + box-shadow: 0 4px 8px rgba(90, 139, 106, 0.25); + filter: brightness(1.05); +} + +.btn-confirm.btn-danger { + background: linear-gradient(135deg, #c75642 0%, #9f392c 100%); + box-shadow: 0 2px 4px rgba(163, 51, 38, 0.24); +} + +.btn-confirm.btn-danger:hover { + box-shadow: 0 4px 10px rgba(163, 51, 38, 0.28); + filter: brightness(1.04); +} + +/* ============================================ + 模型列表 + ============================================ */ +.model-list { + max-height: 200px; + overflow-y: auto; + border: 1px solid rgba(208, 196, 182, 0.4); + border-radius: var(--radius-sm); + margin-bottom: var(--spacing-sm); + scrollbar-width: none; + background: linear-gradient(to bottom, var(--color-surface) 0%, rgba(255, 255, 255, 0.8) 100%); + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.02); +} + +.model-list::-webkit-scrollbar { + display: none; +} + +.model-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 11px var(--spacing-sm); + border-bottom: 1px solid rgba(208, 196, 182, 0.3); + font-size: var(--font-size-body); + color: var(--color-text-primary); + transition: all var(--transition-fast) var(--ease-spring); + letter-spacing: -0.005em; +} + +.model-item:last-child { + border-bottom: none; +} + +.model-item:hover { + background: linear-gradient(to right, rgba(247, 241, 232, 0.6) 0%, rgba(247, 241, 232, 0.3) 100%); +} + +.btn-remove-model { + font-size: var(--font-size-caption); + font-weight: var(--font-weight-caption); + color: var(--color-text-tertiary); + cursor: pointer; + padding: 5px 10px; + border-radius: var(--radius-full); + transition: all var(--transition-fast) var(--ease-spring); + background: transparent; + border: 1px solid rgba(139, 118, 104, 0.2); + letter-spacing: 0.03em; +} + +.btn-remove-model:hover { + background: linear-gradient(135deg, var(--color-error) 0%, rgba(200, 74, 58, 0.9) 100%); + color: white; + transform: scale(1.08); + box-shadow: 0 2px 6px rgba(200, 74, 58, 0.25); + border-color: transparent; +} + diff --git a/web-ui/styles/skills-market.css b/web-ui/styles/skills-market.css new file mode 100644 index 0000000..09c50c3 --- /dev/null +++ b/web-ui/styles/skills-market.css @@ -0,0 +1,335 @@ +.agent-list { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); +} + +.agent-item { + border: 1px dashed var(--color-border-soft); + border-radius: var(--radius-sm); + padding: var(--spacing-xs); + background: var(--color-surface-alt); +} + +.agent-header { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-xs); + align-items: center; + margin-bottom: 6px; +} + +.agent-name { + font-weight: var(--font-weight-secondary); + color: var(--color-text-secondary); +} + +.agent-id { + font-size: var(--font-size-caption); + color: var(--color-text-tertiary); +} + +.agent-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + font-size: var(--font-size-caption); + color: var(--color-text-secondary); +} + +.skill-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-xs); + margin-bottom: 10px; + flex-wrap: wrap; +} + +.skill-select-all { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: var(--font-size-secondary); + color: var(--color-text-secondary); + user-select: none; +} + +.skill-toolbar-count { + font-size: var(--font-size-caption); + color: var(--color-text-tertiary); +} + +.skills-modal { + width: min(96vw, 920px); +} + +.skills-modal-header { + align-items: flex-start; + margin-bottom: var(--spacing-xs); +} + +.skills-modal-subtitle { + margin-top: 6px; + font-size: var(--font-size-caption); + color: var(--color-text-tertiary); + line-height: 1.45; +} + +.skills-modal-actions { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; +} + +.skills-root-box { + border: 1px solid var(--color-border-soft); + border-radius: var(--radius-sm); + background: linear-gradient(to bottom, rgba(255, 255, 255, 0.86) 0%, rgba(255, 255, 255, 0.66) 100%); + padding: 10px 12px; + font-family: var(--font-family-mono); + font-size: var(--font-size-caption); + color: var(--color-text-secondary); + word-break: break-all; +} + +.skills-root-group { + margin-bottom: var(--spacing-xs); +} + +.skills-summary-strip { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--spacing-xs); + margin-bottom: var(--spacing-sm); +} + +.skills-summary-item { + border: 1px solid rgba(160, 145, 130, 0.2); + border-radius: var(--radius-sm); + background: linear-gradient(to bottom, rgba(255, 255, 255, 0.93) 0%, rgba(255, 255, 255, 0.78) 100%); + padding: 10px 12px; + min-width: 0; + box-shadow: var(--shadow-subtle); + display: flex; + flex-direction: column; + gap: 2px; +} + +.skills-summary-label { + font-size: var(--font-size-caption); + color: var(--color-text-tertiary); +} + +.skills-summary-value { + font-size: var(--font-size-large); + color: var(--color-text-secondary); + line-height: 1.2; +} + +.skills-panel { + border: 1px solid rgba(160, 145, 130, 0.24); + border-radius: var(--radius-md); + padding: 12px; + background: linear-gradient(to bottom, rgba(255, 255, 255, 0.88) 0%, rgba(255, 255, 255, 0.72) 100%); + margin-bottom: var(--spacing-sm); +} + +.skills-panel-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--spacing-xs); + margin-bottom: 10px; +} + +.skills-panel-title-wrap { + min-width: 0; +} + +.skills-panel-title { + font-size: var(--font-size-body); + font-weight: var(--font-weight-primary); + color: var(--color-text-secondary); +} + +.skills-panel-note { + margin-top: 4px; + font-size: var(--font-size-caption); + color: var(--color-text-tertiary); + line-height: 1.45; +} + +.market-overview-section { + margin-bottom: var(--spacing-sm); +} + +.market-overview-header { + gap: var(--spacing-sm); + align-items: flex-start; +} + +.market-header-actions { + flex-wrap: wrap; +} + +.market-target-switch { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: var(--spacing-sm); +} + +.market-target-switch-compact { + justify-content: flex-end; + margin-bottom: 0; +} + +.market-target-chip { + border: 1px solid rgba(160, 145, 130, 0.28); + border-radius: 999px; + background: rgba(255, 255, 255, 0.92); + color: var(--color-text-secondary); + padding: 8px 14px; + font-size: var(--font-size-caption); + font-weight: var(--font-weight-secondary); + cursor: pointer; + transition: border-color var(--transition-fast) var(--ease-smooth), background var(--transition-fast) var(--ease-smooth), color var(--transition-fast) var(--ease-smooth), box-shadow var(--transition-fast) var(--ease-smooth); +} + +.market-target-chip:disabled, +.market-target-chip[disabled] { + cursor: not-allowed; + opacity: 0.64; + pointer-events: none; + box-shadow: none; +} + +.market-target-chip.active { + border-color: rgba(208, 88, 58, 0.4); + background: linear-gradient(to bottom, rgba(255, 243, 236, 0.98) 0%, rgba(255, 232, 220, 0.86) 100%); + color: #8c3a1f; + box-shadow: var(--shadow-subtle); +} + +.market-root-box { + margin-bottom: var(--spacing-sm); +} + +.market-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--spacing-sm); +} + +.market-panel { + margin-bottom: 0; + min-width: 0; +} + +.market-actions-panel { + grid-column: 1 / -1; +} + +.market-panel-wide { + grid-column: 1 / -1; +} + +.market-preview-list { + display: grid; + gap: 10px; +} + +.market-preview-item { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--spacing-xs); + padding: 10px 12px; + border: 1px solid rgba(160, 145, 130, 0.18); + border-radius: var(--radius-sm); + background: rgba(255, 255, 255, 0.64); +} + +.market-preview-main { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.market-preview-title { + font-size: var(--font-size-body); + font-weight: var(--font-weight-secondary); + color: var(--color-text-secondary); + overflow-wrap: anywhere; +} + +.market-preview-meta { + font-size: var(--font-size-caption); + color: var(--color-text-tertiary); + line-height: 1.45; + overflow-wrap: anywhere; +} + +.market-action-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--spacing-xs); +} + +.market-action-card { + border: 1px solid rgba(160, 145, 130, 0.24); + border-radius: var(--radius-sm); + background: linear-gradient(to bottom, rgba(255, 255, 255, 0.96) 0%, rgba(255, 248, 242, 0.84) 100%); + color: var(--color-text-secondary); + text-align: left; + padding: 14px; + display: flex; + flex-direction: column; + gap: 6px; + cursor: pointer; + transition: transform var(--transition-fast) var(--ease-smooth), box-shadow var(--transition-fast) var(--ease-smooth), border-color var(--transition-fast) var(--ease-smooth); +} + +.market-action-card:hover:not(:disabled) { + transform: translateY(-1px); + border-color: rgba(208, 88, 58, 0.34); + box-shadow: var(--shadow-subtle); +} + +.market-action-card:disabled { + cursor: not-allowed; + opacity: 0.64; +} + +.market-action-title { + font-size: var(--font-size-body); + font-weight: var(--font-weight-secondary); +} + +.market-action-copy { + font-size: var(--font-size-caption); + color: var(--color-text-tertiary); + line-height: 1.45; +} + +.skills-filter-row { + display: flex; + gap: var(--spacing-xs); + margin-bottom: 10px; + align-items: center; + flex-wrap: wrap; +} + +.skills-filter-row .form-input { + flex: 1; + min-width: 220px; +} + +.skills-status-select { + width: 210px; + flex: 0 0 auto; +} + diff --git a/web-ui/styles/titles-cards.css b/web-ui/styles/titles-cards.css new file mode 100644 index 0000000..f8be702 --- /dev/null +++ b/web-ui/styles/titles-cards.css @@ -0,0 +1,407 @@ +/* ============================================ + 主标题 + ============================================ */ +.main-title { + font-size: var(--font-size-display); + font-weight: var(--font-weight-display); + line-height: var(--line-height-tight); + letter-spacing: -0.03em; + margin-bottom: 10px; + color: var(--color-text-primary); + font-family: var(--font-family-display); + background: linear-gradient(135deg, var(--color-text-primary) 0%, rgba(27, 23, 20, 0.78) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.main-title .accent { + color: var(--color-brand); + -webkit-text-fill-color: var(--color-brand); + position: relative; +} + +.subtitle { + font-size: var(--font-size-body); + color: var(--color-text-tertiary); + line-height: var(--line-height-normal); + margin-bottom: 20px; + max-width: 640px; + letter-spacing: 0.01em; +} + +/* ============================================ + 模式切换器 - Segmented Control + ============================================ */ +.segmented-control { + display: flex; + background: rgba(255, 255, 255, 0.92); + border-radius: var(--radius-xl); + padding: 6px; + margin-bottom: 20px; + position: relative; + box-shadow: inset 0 1px 2px rgba(31, 26, 23, 0.06); + border: 1px solid rgba(255, 255, 255, 0.7); + backdrop-filter: blur(6px); +} + +.segment { + flex: 1; + padding: 11px 16px; + border: none; + background: transparent; + font-size: var(--font-size-body); + font-weight: var(--font-weight-secondary); + color: var(--color-text-secondary); + cursor: pointer; + border-radius: 10px; + transition: all var(--transition-normal) var(--ease-spring); + position: relative; + z-index: 2; + letter-spacing: 0.01em; +} + +.segment:hover { + color: var(--color-text-primary); +} + +.segment.active { + color: var(--color-text-primary); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0.8) 100%); + box-shadow: var(--shadow-subtle), inset 0 1px 0 rgba(255, 255, 255, 0.85); +} + +/* ============================================ + 卡片列表 + ============================================ */ +.card-list { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 12px; +} + +/* ============================================ + 卡片 + ============================================ */ +.card { + background: linear-gradient(180deg, #fffdf9 0%, #fff8f2 100%); + border-radius: var(--radius-lg); + padding: 10px; + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + transition: + transform var(--transition-normal) var(--ease-spring), + box-shadow var(--transition-normal) var(--ease-spring), + background-color var(--transition-fast) var(--ease-smooth); + box-shadow: 0 10px 24px rgba(27, 23, 20, 0.08); + user-select: none; + will-change: transform; + border: 1px solid rgba(216, 201, 184, 0.55); + position: relative; + overflow: hidden; +} + +.card:hover { + transform: translateY(-1px); + box-shadow: var(--shadow-card-hover); +} + +.card::before, +.card::after { + content: ""; + position: absolute; + pointer-events: none; +} + +.card::before { + left: 0; + top: 10px; + bottom: 10px; + width: 3px; + border-radius: 999px; + background: transparent; + transition: background var(--transition-fast) var(--ease-smooth); +} + +.card::after { + inset: 0; + border-radius: inherit; + background: linear-gradient(120deg, rgba(255, 255, 255, 0.7) 0%, transparent 55%); + opacity: 0; + transition: opacity var(--transition-normal) var(--ease-smooth); +} + +.card:active { + transform: translateY(0); + transition: transform var(--transition-instant) var(--ease-smooth); +} + +.card.active { + background: linear-gradient(to bottom, rgba(210, 107, 90, 0.14) 0%, rgba(255, 255, 255, 0.98) 100%); + border-color: rgba(201, 94, 75, 0.55); + box-shadow: 0 10px 28px rgba(210, 107, 90, 0.14); +} + +.card.active::before { + background: linear-gradient(180deg, rgba(201, 94, 75, 0.95) 0%, rgba(201, 94, 75, 0.35) 100%); +} + +.card:hover::after { + opacity: 0.6; +} + +.card.active .card-icon { + transform: scale(1.05); +} + +.card-leading { + display: flex; + align-items: center; + gap: var(--spacing-sm); + flex: 1; + min-width: 0; +} + +.card-icon { + width: 40px; + height: 40px; + border-radius: var(--radius-sm); + background: linear-gradient(135deg, rgba(255, 255, 255, 0.9) 0%, rgba(247, 241, 232, 0.65) 100%); + display: flex; + align-items: center; + justify-content: center; + font-size: var(--font-size-title); + font-weight: var(--font-weight-title); + color: var(--color-text-secondary); + flex-shrink: 0; + transition: all var(--transition-normal) var(--ease-spring-soft); + box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.7); +} + +.card.active .card-icon { + background: linear-gradient(135deg, var(--color-brand) 0%, var(--color-brand-dark) 100%); + color: white; + box-shadow: 0 2px 8px rgba(210, 107, 90, 0.3); +} + +.card-content { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.card-title { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + font-size: var(--font-size-body); + font-weight: var(--font-weight-secondary); + color: var(--color-text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + letter-spacing: -0.01em; +} + +.card-title > span:first-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.provider-readonly-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px 8px; + border-radius: var(--radius-full); + font-size: 11px; + line-height: 1; + color: #6f4b00; + background: linear-gradient(135deg, rgba(246, 211, 106, 0.32) 0%, rgba(246, 211, 106, 0.2) 100%); + border: 1px solid rgba(191, 151, 40, 0.35); + flex-shrink: 0; +} + +.card-subtitle { + font-size: var(--font-size-secondary); + color: var(--color-text-tertiary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + opacity: 0.8; +} + +.card-trailing { + display: grid; + grid-auto-flow: column; + grid-auto-columns: max-content; + column-gap: var(--spacing-xs); + row-gap: 6px; + align-items: center; + justify-content: end; + align-self: center; +} + +.card-trailing .card-actions { + margin-left: 0; + justify-self: end; +} + +.card-trailing .pill, +.card-trailing .latency { + justify-self: end; +} + +/* 卡片操作按钮 - hover 显示 */ +.card-actions { + display: flex; + gap: 8px; + opacity: 0; + pointer-events: none; + transform: translateX(4px); + transition: all var(--transition-normal) var(--ease-spring); +} + +.card:hover .card-actions { + opacity: 1; + pointer-events: auto; + transform: translateX(0); +} + +.card:focus-within .card-actions { + opacity: 1; + pointer-events: auto; + transform: translateX(0); +} + +.mode-cards .card-actions { + opacity: 1; + pointer-events: auto; + transform: translateX(0); +} + +.card-action-btn { + width: 40px; + height: 40px; + border-radius: 10px; + border: 1px solid rgba(70, 86, 110, 0.22); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(255, 255, 255, 0.9)); + color: var(--color-text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition-fast) var(--ease-spring); + box-shadow: inset 0 1px 2px rgba(31, 26, 23, 0.04); +} + +.card-action-btn:hover { + background: linear-gradient(135deg, rgba(210, 107, 90, 0.08) 0%, rgba(255, 255, 255, 0.95) 100%); + color: var(--color-text-primary); + transform: translateY(-1px); +} + +.card-action-btn.delete:hover { + background: linear-gradient(135deg, rgba(200, 74, 58, 0.1) 0%, rgba(200, 74, 58, 0.05) 100%); + color: var(--color-error); +} + +.card-action-btn:disabled, +.card-action-btn.disabled { + opacity: 0.45; + cursor: not-allowed; + transform: none; + filter: grayscale(0.1); +} + +.card-action-btn.delete:disabled:hover, +.card-action-btn.delete.disabled:hover { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(255, 255, 255, 0.9)); + color: var(--color-text-secondary); +} + +.card-action-btn svg { + width: 18px; + height: 18px; +} + +/* ============================================ + 状态徽章 + ============================================ */ +.pill { + padding: 5px 11px; + border-radius: var(--radius-full); + font-size: var(--font-size-caption); + font-weight: var(--font-weight-caption); + background-color: rgba(255, 255, 255, 0.8); + color: var(--color-text-tertiary); + text-transform: uppercase; + letter-spacing: 0.06em; + transition: all var(--transition-fast) var(--ease-smooth); + box-shadow: inset 0 0.5px 1px rgba(0, 0, 0, 0.04); +} + +.pill.configured { + background: linear-gradient(135deg, rgba(90, 139, 106, 0.15) 0%, rgba(90, 139, 106, 0.08) 100%); + color: var(--color-success); + box-shadow: inset 0 0.5px 1px rgba(90, 139, 106, 0.2); +} + +.pill.empty { + background: linear-gradient(135deg, rgba(200, 74, 58, 0.1) 0%, rgba(200, 74, 58, 0.05) 100%); + color: var(--color-error); + box-shadow: inset 0 0.5px 1px rgba(200, 74, 58, 0.15); +} + +.latency { + padding: 4px 10px; + border-radius: var(--radius-full); + font-size: var(--font-size-caption); + font-weight: var(--font-weight-caption); + background: var(--color-bg); + color: var(--color-text-tertiary); + letter-spacing: 0.02em; + min-width: 64px; + text-align: center; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.latency.ok { + color: var(--color-success); + background: rgba(90, 139, 106, 0.1); +} + +.latency.error { + color: var(--color-error); + background: rgba(200, 74, 58, 0.08); +} + +.card-action-btn.loading svg { + animation: spin 0.9s linear infinite; +} + +/* ============================================ + 图标 - SVG 优化 + ============================================ */ +.icon { + width: 20px; + height: 20px; + flex-shrink: 0; + stroke-linecap: round; + stroke-linejoin: round; +} + +.icon-chevron-right { + color: var(--color-text-tertiary); + opacity: 0.5; +}