Merge remote-tracking branch 'desktop/main' into RoundSquisheen
This commit is contained in:
commit
03bd2d5c20
3 files changed
+399
-31
No files matched your search
@@ -0,0 +1,227 @@
|
||||
# GADesktop 前端美化开发笔记本
|
||||
|
||||
**项目**:GADesktop-main-4
|
||||
**仓库**:origin=dd3xp/GADesktop(上游),myfork=wjl2023/GADesktop(个人 fork)
|
||||
**起始 commit**:4004590(与 origin/main 一致)
|
||||
**主题**:前端美化(UI/UX/视觉优化)
|
||||
**Bridge**:http://127.0.0.1:14169(PID 见 /tmp/bridge_main4.log)
|
||||
|
||||
---
|
||||
|
||||
## 0. 开发约束(每次开始前过一遍)
|
||||
|
||||
| # | 约束 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | 仅改前端 | 主战场:`frontends/desktop/static/` (app.js / index.html / styles.css / annot.* / vendor) |
|
||||
| 2 | 零硬编码 | 颜色用 CSS 变量,文本走 i18n,禁止字面量 |
|
||||
| 3 | 单文件优先 | 高内聚低耦合,能在一个文件解决就不跨文件 |
|
||||
| 4 | 风格一致 | 与现有命名/缩进/注释风格保持一致 |
|
||||
| 5 | 改前必读 | 用 file_read 看完目标段落再 patch |
|
||||
| 6 | 小步 commit | 每个功能验证 OK 再 commit,标题用 `style(desktop): ...` 或 `feat(desktop): ...` |
|
||||
| 7 | 大改起 subagent | 跨文件 / >100 行的改动起监察 |
|
||||
| 8 | Cmd+Shift+R | 用户测试前提醒强制刷新清缓存 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目入口速查
|
||||
|
||||
| 文件 | 作用 |
|
||||
|------|------|
|
||||
| `frontends/desktop_bridge.py` | aiohttp HTTP+WS 桥,端口 14168(默认),Web/桌面共用 |
|
||||
| `frontends/desktop/static/index.html` | 主 HTML 框架 |
|
||||
| `frontends/desktop/static/app.js` | 主前端逻辑(含 typewriter / 折叠 / scroll / 等) |
|
||||
| `frontends/desktop/static/styles.css` | 主样式 |
|
||||
| `frontends/desktop/static/annot.css/.js` | 注释相关 |
|
||||
| `frontends/desktop/static/vendor/` | 第三方库 |
|
||||
|
||||
启动桥:
|
||||
```bash
|
||||
cd /Users/lwj/Documents/ga/GADesktop-main-4
|
||||
BRIDGE_PORT=14169 nohup python3 frontends/desktop_bridge.py > /tmp/bridge_main4.log 2>&1 &
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 美化方向(待用户填补)
|
||||
|
||||
> 本节随用户指示更新
|
||||
|
||||
- [ ] T1 — 待定
|
||||
- [ ] T2 — 待定
|
||||
|
||||
---
|
||||
|
||||
## 3. 进度日志
|
||||
|
||||
| 日期 | 任务 | 文件 | 状态 | commit |
|
||||
|------|------|------|------|--------|
|
||||
| 2026-05-25 | 初始化笔记本,桥重启在 14169 | — | ✅ | — |
|
||||
| 2026-05-25 | fix: summary 重复打印(最后一轮 head+body 双渲染) | app.js L826-838 | ✅ | pending |
|
||||
|
||||
---
|
||||
|
||||
## 4. 已知决策记录
|
||||
|
||||
(每次设计抉择记录于此,避免反复折腾)
|
||||
|
||||
- 主战场限定为 `frontends/desktop/static/`,不动 Python 后端
|
||||
- CSS 变量优先于 inline style
|
||||
- 文本走 i18n(看 index.html / app.js 中已有 i18n 机制再扩展)
|
||||
|
||||
---
|
||||
|
||||
## 5. PR 计划
|
||||
|
||||
- 分支名:`feat/frontend-beauty-v1`(待定)
|
||||
- base:`origin/main` (4004590)
|
||||
- 提交策略:每个美化任务一个 commit,最终汇总成一个 PR
|
||||
- PR 描述模板见 `../memory/github_contribution_sop.md`
|
||||
|
||||
---
|
||||
|
||||
## 6. 反模式提醒(来自上次 PR#11 教训)
|
||||
|
||||
- ❌ 不读笔记直接动手 → 重复踩坑
|
||||
- ❌ 一次改太多 → 改崩无法回退
|
||||
- ❌ 硬编码颜色/文本 → 维护噩梦
|
||||
- ❌ 跳过用户验证 → 问题累积
|
||||
- ❌ 在崩溃代码上继续修 → 越修越烂,应 stash + checkout
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 7. Feature: 消息计时 (Message Timing)
|
||||
|
||||
**状态**:📋 计划中
|
||||
**参考**:v1 (GenericAgent-main-6/frontends/genericagent-electron/renderer/app.js L292-415)
|
||||
**目标**:在 assistant 消息上显示任务耗时 badge(实时跳动 → 结束后定格)
|
||||
|
||||
### 7.1 v1 核心逻辑摘要
|
||||
|
||||
| 函数 | 作用 |
|
||||
|------|------|
|
||||
| `formatDuration(ms)` | ms→"1ms"/"3.2s"/"2m 15s" |
|
||||
| `formatTaskElapsed(ms)` | 带前缀格式化,用于 badge 文本 |
|
||||
| `startTaskTimer(sess)` | runtime.taskStartedAt=now, setInterval(1s) 更新 live badge |
|
||||
| `stopTaskTimer(sess)` | clearInterval, 计算 endedAt, badge 定格 |
|
||||
| `ensureAssistantTaskElapsed(wrap, start, end)` | 在 .msg-wrap 上创建/更新 .task-elapsed badge |
|
||||
|
||||
CSS: `.task-elapsed { color: var(--accent); font-size: 12px; font-weight: 600; }`
|
||||
|
||||
### 7.2 v2 Hook 点分析
|
||||
|
||||
| 事件 | v2 位置 | 说明 |
|
||||
|------|---------|------|
|
||||
| 任务开始 | `setBusy(sess, true)` (L1451) | sendPrompt 内 |
|
||||
| 任务结束 | `setBusy(sess, false)` (L1334/1343/1477) | poll 正常结束 / error |
|
||||
| 流式 DOM | `r.draftEl` 创建 (L1094) | streaming 时的 assistant 消息 |
|
||||
| 最终消息 | `appendMessage(sess, msg)` | poll 结束后正式渲染 |
|
||||
| runtime 对象 | L896 | `{ polling, busy, lastId, seen, draftEl, draftText }` |
|
||||
|
||||
### 7.3 变更计划
|
||||
|
||||
**原则**:计时逻辑自包含为一个模块(~50行),通过 setBusy 的 2 个调用点接入,变化半径=2行。
|
||||
|
||||
| # | 变更 | 文件 | 位置 | 行数 |
|
||||
|---|------|------|------|------|
|
||||
| 1 | 添加 `formatTaskElapsed(ms)` 工具函数 | app.js | 工具函数区(formatDuration 不存在则新建) | ~15行 |
|
||||
| 2 | runtime 增加 `taskStartedAt: null, taskTimerId: null` | app.js | L896 runtime 初始化 | +2字段 |
|
||||
| 3 | 添加 `startTaskTimer(sess)` / `stopTaskTimer(sess)` | app.js | setBusy 上方 | ~25行 |
|
||||
| 4 | setBusy 内调用:busy=true→start, busy=false→stop | app.js | L1144 setBusy 函数体 | +2行 |
|
||||
| 5 | `ensureTaskElapsedBadge(wrap, startedAt, endedAt)` | app.js | 渲染辅助区 | ~15行 |
|
||||
| 6 | draftEl 创建时挂 badge;appendMessage 对 role=assistant 挂 badge | app.js | L1094, L1043 | +2行 |
|
||||
| 7 | `.task-elapsed` 样式 | styles.css | 消息样式区 | ~3行 |
|
||||
| 8 | i18n key: `timing.elapsed` | app.js | i18n 对象 | +1行 |
|
||||
|
||||
**总改动**:~65 行新增,2 行修改。仅 app.js + styles.css。
|
||||
|
||||
### 7.4 关联影响
|
||||
|
||||
- `setBusy()` — 新增 2 行调用,不改原有逻辑
|
||||
- `appendMessage()` — 在 assistant 分支末尾加 1 行 badge 挂载
|
||||
- `draftEl` 创建处 — 加 1 行 badge 挂载
|
||||
- 无 index.html 改动(badge 由 JS 动态创建)
|
||||
|
||||
### 7.5 风险
|
||||
|
||||
- 低:setInterval 泄漏 → stopTaskTimer 必须在所有 setBusy(false) 路径调用(已覆盖 3 处)
|
||||
- 低:页面切换时 badge 更新 → 只更新当前 session 的 badge(用 data-session-id 过滤)
|
||||
|
||||
### 7.6 Bug修复:badge 闪烁 + 布局跳动
|
||||
|
||||
**现象**:badge 一跳一跳,只在变化时短暂显示,每次出现导致气泡上下跳动
|
||||
|
||||
**根因**:
|
||||
1. `rewriteDraftBubble` 每 30ms 用 `innerHTML=` 重写 draftEl,badge 被销毁
|
||||
2. timer 每 1000ms 才重建 badge → badge 只存活 30ms
|
||||
3. CSS 无固定高度,badge 出现/消失改变容器高度
|
||||
|
||||
**修复**(3 处,仅 app.js + styles.css):
|
||||
1. `rewriteDraftBubble`:innerHTML 前保存 badge 文本,替换后恢复(L1133-1149)
|
||||
2. `renderDraft`:创建 draftEl 时若正在计时立即挂载 badge(L1109)
|
||||
3. CSS `.task-elapsed`:`display:block; min-height:18px; line-height:18px` 固定占位
|
||||
|
||||
**关联**:
|
||||
- `rewriteDraftBubble` — 新增 badge 保存/恢复逻辑
|
||||
- `renderDraft` — 新增 1 行 ensureTaskElapsedBadge 调用
|
||||
- styles.css `.task-elapsed` — 改为 block + 固定行高
|
||||
|
||||
### 7.7 Code Review & PR
|
||||
|
||||
**PR #14**: https://github.com/dd3xp/GADesktop/pull/14
|
||||
- 分支: `feat/message-timing-badge`
|
||||
- 改动: app.js +93, styles.css +8
|
||||
- Review 结论: 可合并,无阻塞问题
|
||||
- 可优化点(后续迭代):rewriteDraftBubble 可移动节点而非重建;多条 assistant 消息只有最后一条带 badge
|
||||
|
||||
---
|
||||
|
||||
## 8. 主题精简 + 气泡美化(进行中)
|
||||
|
||||
### TODO 清单
|
||||
|
||||
| # | 任务 | 目标 | 验证 | 状态 |
|
||||
|---|------|------|------|------|
|
||||
| 1 | 主题缩减为 2 个圆点 | 保留 swatch 圆点 UI,只留白色(浅色)和灰色(深色)两个 | 设置弹窗只显示 2 个圆点;点击切换 light/dark 正常;无控制台报错 | ✅ 已完成 |
|
||||
| 2 | 气泡样式重构 | 待定 | 待定 | 待定 |
|
||||
| 3 | 字体与行高优化 | 待定 | 待定 | 待定 |
|
||||
|
||||
---
|
||||
|
||||
### TODO 1: 主题缩减为 2 个圆点(白色=浅色,灰色=深色)
|
||||
|
||||
**改动计划**:
|
||||
- **index.html**: `#theme-swatches` 从 8 个 swatch 缩减为 2 个
|
||||
- **styles.css**: `--swatch-1`~`--swatch-8` 缩减为 2 个;删除多余 `html[data-theme]` 规则
|
||||
- **app.js**: `applyTheme()` 简化为切换 light/dark appearance;swatch 点击逻辑适配
|
||||
|
||||
|
||||
**实施完成** (2026-05-25):
|
||||
- index.html: 8 个 swatch 缩减为 2 个(data-theme="1" 白色, data-theme="2" 灰色)
|
||||
- styles.css: 删除 swatch 3-8 颜色变量和规则;删除 theme="7"/"8" 残留规则
|
||||
- app.js: applyTheme() 简化,swatch 与 appearance-seg 双向同步
|
||||
|
||||
**Bug 修复** (2026-05-25):
|
||||
- Bug1: 侧边栏选中项文字不可见 → 根因:`applyTheme` 中 `root.style.setProperty('--blue', swatch颜色)` 把全局强调色覆盖为白/灰 → 删除该行,--blue 保持 CSS 定义的 #1f6f53
|
||||
- Bug2: swatch 高亮环永远在深色 → 同一根因,--blue 被设为白色导致 box-shadow 白底白环不可见 → 同上修复
|
||||
- 额外:白色 swatch 的勾选标记改用深色描边(避免白底白勾不可见)
|
||||
|
||||
**验证结果**:
|
||||
- --blue = #1f6f53(固定绿色强调色),不再被 swatch 颜色覆盖
|
||||
- 侧边栏选中项文字 rgb(31,111,83) 在浅色/深色背景上均可见
|
||||
- swatch 高亮环 rgb(31,111,83) 正确跟随选中状态切换
|
||||
|
||||
---
|
||||
|
||||
### 计时器刷新驻留修复 v2 (2026-05-25)
|
||||
|
||||
**问题:计时"已运行x"刷新后丢失**
|
||||
- 根因:`r.taskStartedAt` 仅存于内存 runtime Map,刷新后 runtime 重建;pollSession 重新检测 running → setBusy → startTaskTimer 设新 Date.now(),真正开始时间丢失
|
||||
- ❌ sessionStorage 方案失败(刷新后仍丢失)
|
||||
- ✅ 最终方案:基于消息 ts 时间戳恢复(2026-05-25 成功验证)
|
||||
- 根因:消息对象本身有 `ts` 字段(API 返回),但 `normalize()` 函数丢弃了它
|
||||
- 修复 3 处(仅 app.js):
|
||||
1. `normalize()` 增加 `if (m.ts) o.ts = m.ts;` — 保留时间戳
|
||||
2. `restoreElapsedBadges(sess, box)` 从 `renderAllMessages` 移到 `pollSession` finally 块 — 确保消息已加载
|
||||
3. `startTaskTimer(sess)` 从最后一条 user 消息的 ts 恢复 taskStartedAt — 运行中任务继续计时
|
||||
- 验证结果:刷新后 badge 正确显示"已运行 17s"/"已运行 14s",已完成任务静态显示,运行中任务实时计时
|
||||
+140
-10
@@ -287,6 +287,7 @@ const I18N = {
|
||||
'upload.dropHint': '松开以上传文件',
|
||||
'lightbox.closeTitle': '关闭',
|
||||
'fold.thinking': '思考', 'fold.tool': '工具调用', 'fold.toolResult': '工具结果', 'fold.llm': 'LLM Running', 'fold.turn': '第 {n} 轮',
|
||||
'timing.elapsed': '已运行 {t}',
|
||||
'model.auto': '自动选择',
|
||||
'model.menuLabel': '选择模型',
|
||||
'chip.plan': 'Plan',
|
||||
@@ -392,6 +393,7 @@ const I18N = {
|
||||
'upload.dropHint': 'Drop to upload files',
|
||||
'lightbox.closeTitle': 'Close',
|
||||
'fold.thinking': 'Thinking', 'fold.tool': 'Tool call', 'fold.toolResult': 'Tool result', 'fold.llm': 'LLM Running', 'fold.turn': 'Turn {n}',
|
||||
'timing.elapsed': 'Elapsed {t}',
|
||||
'model.auto': 'Auto',
|
||||
'model.menuLabel': 'Select model',
|
||||
'chip.plan': 'Plan',
|
||||
@@ -818,18 +820,29 @@ function renderAssistant(text) {
|
||||
// 3) 拼装:历史轮包 details 默认折叠,最后一轮平铺
|
||||
const turnLabel = (n) => t('fold.turn').replace('{n}', n);
|
||||
// 从原始 seg.body 中抽出该轮首个 <summary>...</summary> 文本,作为折叠头副标题
|
||||
// fallback: 若无 <summary> 标签,提取该轮调用的工具名列表
|
||||
const extractTurnSummary = (raw) => {
|
||||
const m = /<summary>([\s\S]*?)<\/summary>/i.exec(raw || '');
|
||||
if (!m) return '';
|
||||
return m[1].replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
|
||||
if (m) return m[1].replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
|
||||
// fallback: 提取工具名
|
||||
const tools = [];
|
||||
const toolRe = /🛠️\s*Tool:\s*`([^`]+)`/g;
|
||||
let tm;
|
||||
while ((tm = toolRe.exec(raw || '')) !== null) {
|
||||
if (!tools.includes(tm[1])) tools.push(tm[1]);
|
||||
}
|
||||
if (tools.length) return tools.join(', ');
|
||||
return '';
|
||||
};
|
||||
const parts = segs.map((seg, i) => {
|
||||
const isLast = (i === segs.length - 1);
|
||||
if (seg.n == null) return foldBlocks(seg.body);
|
||||
const sum = extractTurnSummary(seg.body);
|
||||
// Strip the <summary> tag from body before rendering to avoid duplication with head
|
||||
const cleanBody = seg.body.replace(/<summary>[\s\S]*?<\/summary>\s*/i, '');
|
||||
const inner = foldBlocks(cleanBody);
|
||||
// Strip the <summary> tag from body to avoid duplication with head
|
||||
const bodyForRender = sum
|
||||
? seg.body.replace(/<summary>[\s\S]*?<\/summary>\s*/i, '')
|
||||
: seg.body;
|
||||
const inner = foldBlocks(bodyForRender);
|
||||
const head = sum
|
||||
? `${escapeHtml(turnLabel(seg.n))}:<span class="turn-head-sum">${escapeHtml(sum)}</span>`
|
||||
: escapeHtml(turnLabel(seg.n));
|
||||
@@ -895,7 +908,7 @@ const state = {
|
||||
};
|
||||
function rt(sess) {
|
||||
let r = state.runtime.get(sess.id);
|
||||
if (!r) { r = { polling:false, busy:false, lastId:0, seen:new Set(), draftEl:null, draftText:'' }; state.runtime.set(sess.id, r); }
|
||||
if (!r) { r = { polling:false, busy:false, lastId:0, seen:new Set(), draftEl:null, draftText:'', taskStartedAt:null, taskEndedAt:null, taskTimerId:null }; state.runtime.set(sess.id, r); }
|
||||
return r;
|
||||
}
|
||||
const activeSess = () => state.sessions.get(state.activeId) || null;
|
||||
@@ -1069,11 +1082,46 @@ function msgNode(msg) {
|
||||
function renderAllMessages(sess) {
|
||||
const box = ensureMsgs(); box.innerHTML = '';
|
||||
for (const m of sess.messages) box.appendChild(msgNode(m));
|
||||
// badge 恢复在 pollSession finally 中执行(此时 messages 已通过异步加载填充)
|
||||
refreshEmptyState(sess); scrollBottom(true);
|
||||
}
|
||||
// 遍历消息对,用 ts 差值恢复 badge;对运行中任务恢复 taskStartedAt
|
||||
function restoreElapsedBadges(sess, box) {
|
||||
const msgs = sess.messages;
|
||||
if (!msgs || !msgs.length) return;
|
||||
const nodes = box.querySelectorAll('.msg');
|
||||
let lastUserTs = null;
|
||||
for (let i = 0; i < msgs.length; i++) {
|
||||
if (msgs[i].role === 'user') {
|
||||
lastUserTs = msgs[i].ts ? msgs[i].ts * 1000 : null; // 无 ts 则重置
|
||||
} else if (msgs[i].role === 'assistant') {
|
||||
if (lastUserTs && msgs[i].ts) {
|
||||
const elapsed = msgs[i].ts * 1000 - lastUserTs;
|
||||
if (elapsed > 0 && nodes[i]) {
|
||||
ensureTaskElapsedBadge(nodes[i], lastUserTs, msgs[i].ts * 1000);
|
||||
}
|
||||
}
|
||||
lastUserTs = null;
|
||||
}
|
||||
}
|
||||
// 运行中任务:最后一条是 user 且 session busy,恢复实时计时
|
||||
if (lastUserTs && rt(sess).busy) {
|
||||
const r = rt(sess);
|
||||
r.taskStartedAt = lastUserTs;
|
||||
r.taskEndedAt = null;
|
||||
}
|
||||
}
|
||||
function appendMessage(sess, msg) {
|
||||
if (!isActive(sess)) return;
|
||||
ensureMsgs().appendChild(msgNode(msg));
|
||||
const el = msgNode(msg);
|
||||
ensureMsgs().appendChild(el);
|
||||
if (msg.role === 'assistant') {
|
||||
const r = rt(sess);
|
||||
if (r.taskStartedAt) {
|
||||
ensureTaskElapsedBadge(el, r.taskStartedAt, r.taskEndedAt || Date.now());
|
||||
r.taskStartedAt = null; r.taskEndedAt = null;
|
||||
}
|
||||
}
|
||||
refreshEmptyState(sess); scrollBottom(true);
|
||||
}
|
||||
function isNearBottom(threshold = 80) {
|
||||
@@ -1094,6 +1142,8 @@ function renderDraft(sess) {
|
||||
const box = ensureMsgs();
|
||||
if (!r.draftEl || r.draftEl.parentNode !== box) {
|
||||
r.draftEl = document.createElement('div'); r.draftEl.className = 'msg assistant'; box.appendChild(r.draftEl);
|
||||
// 正在计时则立即挂载 badge,避免等 1s tick 后才出现导致跳动
|
||||
if (r.taskStartedAt) ensureTaskElapsedBadge(r.draftEl, r.taskStartedAt, null);
|
||||
}
|
||||
if (!r.twState) r.twState = { shown: 0, timer: null };
|
||||
const tw = r.twState;
|
||||
@@ -1114,10 +1164,13 @@ function renderDraft(sess) {
|
||||
refreshEmptyState(sess);
|
||||
}
|
||||
|
||||
// 重写打字机气泡:先记 near + 保存 <details> open 态;innerHTML 替换后恢复 open;仅当原先贴底才滚
|
||||
// 重写打字机气泡:先记 near + 保存 <details> open 态 + badge;innerHTML 替换后恢复;仅当原先贴底才滚
|
||||
function rewriteDraftBubble(r, visible) {
|
||||
const wasNear = isNearBottom();
|
||||
const openIdx = [];
|
||||
// 保存 badge(会被 innerHTML 覆盖)
|
||||
const oldBadge = r.draftEl ? r.draftEl.querySelector(':scope > .task-elapsed') : null;
|
||||
const badgeText = oldBadge ? oldBadge.textContent : null;
|
||||
if (r.draftEl) {
|
||||
r.draftEl.querySelectorAll('details').forEach((d, i) => { if (d.open) openIdx.push(i); });
|
||||
}
|
||||
@@ -1125,6 +1178,14 @@ function rewriteDraftBubble(r, visible) {
|
||||
postRenderEnhance(r.draftEl.querySelector('.bubble'));
|
||||
const dets = r.draftEl.querySelectorAll('details');
|
||||
openIdx.forEach(i => { if (dets[i]) dets[i].open = true; });
|
||||
// 恢复 badge
|
||||
if (badgeText) {
|
||||
const badge = document.createElement('div');
|
||||
badge.className = 'task-elapsed';
|
||||
badge.textContent = badgeText;
|
||||
badge.dataset.live = '1';
|
||||
r.draftEl.prepend(badge);
|
||||
}
|
||||
if (wasNear) scrollBottom(true);
|
||||
}
|
||||
|
||||
@@ -1143,11 +1204,77 @@ function statusLabel() {
|
||||
return state.bridgeReady ? t('status.ready') : t('status.disconnected');
|
||||
}
|
||||
function refreshStatusLabel() { runLabel.textContent = statusLabel(); }
|
||||
|
||||
/* ═══════════════ 消息计时 ═══════════════ */
|
||||
function formatTaskElapsed(ms) {
|
||||
const v = Number(ms);
|
||||
if (!Number.isFinite(v) || v < 0) return '';
|
||||
const sec = Math.round(v / 1000);
|
||||
if (sec < 60) return t('timing.elapsed').replace('{t}', `${Math.max(1, sec)}s`);
|
||||
const min = Math.floor(sec / 60), s = sec % 60;
|
||||
if (min < 60) return t('timing.elapsed').replace('{t}', `${min}m ${s}s`);
|
||||
const hr = Math.floor(min / 60), m = min % 60;
|
||||
return t('timing.elapsed').replace('{t}', `${hr}h ${m}m`);
|
||||
}
|
||||
|
||||
function ensureTaskElapsedBadge(wrap, startedAt, endedAt) {
|
||||
if (!wrap || !startedAt) return null;
|
||||
let badge = wrap.querySelector(':scope > .task-elapsed');
|
||||
if (!badge) {
|
||||
badge = document.createElement('div');
|
||||
badge.className = 'task-elapsed';
|
||||
wrap.prepend(badge);
|
||||
}
|
||||
const elapsed = (endedAt || Date.now()) - startedAt;
|
||||
badge.textContent = formatTaskElapsed(elapsed);
|
||||
badge.dataset.live = endedAt ? '' : '1';
|
||||
return badge;
|
||||
}
|
||||
|
||||
function startTaskTimer(sess) {
|
||||
const r = rt(sess);
|
||||
if (r.taskStartedAt) return; // 已在计时,不重置
|
||||
// 优先从消息时间戳恢复(刷新后持久化)
|
||||
const msgs = sess.messages;
|
||||
let restored = 0;
|
||||
if (msgs && msgs.length) {
|
||||
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||
if (msgs[i].role === 'user' && msgs[i].ts) { restored = msgs[i].ts * 1000; break; }
|
||||
}
|
||||
}
|
||||
r.taskStartedAt = restored || Date.now();
|
||||
r.taskEndedAt = null;
|
||||
if (r.taskTimerId) clearInterval(r.taskTimerId);
|
||||
r.taskTimerId = setInterval(() => {
|
||||
if (!r.taskStartedAt) return;
|
||||
const el = r.draftEl || document.querySelector('.msg-list .msg.assistant:last-child');
|
||||
if (el) ensureTaskElapsedBadge(el, r.taskStartedAt, null);
|
||||
// 更新左上角状态栏显示实时耗时
|
||||
if (isActive(sess)) {
|
||||
const elapsed = Date.now() - r.taskStartedAt;
|
||||
runLabel.textContent = formatTaskElapsed(elapsed);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function stopTaskTimer(sess) {
|
||||
const r = rt(sess);
|
||||
if (r.taskTimerId) { clearInterval(r.taskTimerId); r.taskTimerId = null; }
|
||||
if (!r.taskStartedAt) return;
|
||||
r.taskEndedAt = Date.now();
|
||||
}
|
||||
|
||||
function setBusy(sess, busy) {
|
||||
const r = rt(sess); r.busy = busy;
|
||||
if (busy) startTaskTimer(sess); else stopTaskTimer(sess);
|
||||
if (!isActive(sess)) return;
|
||||
runToggle.classList.toggle('busy', busy);
|
||||
runLabel.textContent = busy ? t('status.running') : (state.bridgeReady ? t('status.ready') : t('status.disconnected'));
|
||||
if (busy) {
|
||||
const elapsed = Date.now() - (r.taskStartedAt || Date.now());
|
||||
runLabel.textContent = formatTaskElapsed(elapsed);
|
||||
} else {
|
||||
runLabel.textContent = state.bridgeReady ? t('status.ready') : t('status.disconnected');
|
||||
}
|
||||
if (sendBtn) {
|
||||
sendBtn.classList.toggle('is-stop', busy);
|
||||
sendBtn.setAttribute('aria-label', busy ? t('act.stop') : t('act.send'));
|
||||
@@ -1310,6 +1437,7 @@ function normalize(m) {
|
||||
if (m.stopped) o.stopped = true;
|
||||
if (m.images) o.images = m.images;
|
||||
if (m.files) o.files = m.files;
|
||||
if (m.ts) o.ts = m.ts;
|
||||
return o;
|
||||
}
|
||||
function upsert(sess, raw, partial) {
|
||||
@@ -1345,6 +1473,8 @@ async function pollSession(sess) {
|
||||
setBusy(sess, false);
|
||||
} finally {
|
||||
r.polling = false; renderSessionList();
|
||||
// 历史消息已全部加载,恢复已完成任务的耗时 badge
|
||||
if (isActive(sess)) restoreElapsedBadges(sess, ensureMsgs());
|
||||
tokPollBridge();
|
||||
}
|
||||
}
|
||||
@@ -1436,7 +1566,7 @@ async function sendPrompt(text) {
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
const usedFiles = collectUsedFiles(text);
|
||||
const userMsg = { role: 'user', content: text };
|
||||
const userMsg = { role: 'user', content: text, ts: Date.now() / 1000 };
|
||||
const previewImgs = usedFiles.filter(f => f.isImage).map(f => ({ id: 'f-' + f.sid, name: f.name, path: f.path, dataUrl: f.dataUrl || '' }));
|
||||
if (previewImgs.length) userMsg.images = previewImgs;
|
||||
const previewFiles = usedFiles.filter(f => !f.isImage).map(f => ({ id: 'f-' + f.sid, name: f.name, path: f.path }));
|
||||
|
||||
@@ -83,15 +83,16 @@
|
||||
font-family:-apple-system,"Segoe UI","Microsoft YaHei",system-ui,sans-serif;
|
||||
/* —— 折叠块 —— */
|
||||
--fold-thinking-border:#d4c090;
|
||||
--fold-thinking-bg:#fefbf3;
|
||||
--fold-thinking-bg:transparent;
|
||||
--fold-tool-border:#90b8d4;
|
||||
--fold-tool-bg:#f3f8fe;
|
||||
--fold-tool-bg:transparent;
|
||||
--fold-result-border:#a0d4a0;
|
||||
--fold-result-bg:#f4fdf4;
|
||||
--fold-result-bg:transparent;
|
||||
--fold-turn-border:var(--line);
|
||||
--fold-turn-bg:var(--field-bg);
|
||||
--fold-turn-bg:transparent;
|
||||
--fold-retry-border:var(--line-soft);
|
||||
--fold-retry-bg:transparent;
|
||||
--chat-max-w:clamp(600px, 75%, 1000px);
|
||||
}
|
||||
|
||||
/* —— 主题色板(素色:仅 accent 浅底)—— */
|
||||
@@ -155,11 +156,11 @@ html[data-appearance="dark"]{
|
||||
--shadow-modal:0 18px 50px #010409cc;
|
||||
/* —— 折叠块(dark 覆盖) —— */
|
||||
--fold-thinking-border:#6b5b30;
|
||||
--fold-thinking-bg:#2a2418;
|
||||
--fold-thinking-bg:transparent;
|
||||
--fold-tool-border:#3a5066;
|
||||
--fold-tool-bg:#16202b;
|
||||
--fold-tool-bg:transparent;
|
||||
--fold-result-border:#3f6647;
|
||||
--fold-result-bg:#15241a;
|
||||
--fold-result-bg:transparent;
|
||||
}
|
||||
html[data-appearance="dark"][data-theme]{
|
||||
--blue-bg:color-mix(in srgb, var(--blue) 22%, var(--panel));
|
||||
@@ -565,18 +566,18 @@ html[data-appearance="dark"] .swatch[data-theme="8"].sel::after{
|
||||
.conv-empty{ color:var(--muted2); font-size:12px; }
|
||||
|
||||
/* 消息流 */
|
||||
.msgs{ width:100%; max-width:760px; margin:0 auto; display:flex; flex-direction:column; gap:14px; }
|
||||
.msgs{ width:100%; max-width:var(--chat-max-w); margin:0 auto; display:flex; flex-direction:column; gap:14px; }
|
||||
.msg{ display:flex; flex-wrap:wrap; }
|
||||
.msg.user{ justify-content:flex-end; }
|
||||
.msg.assistant,.msg.error{ justify-content:flex-start; }
|
||||
.msg.system{ justify-content:center; }
|
||||
.bubble{
|
||||
max-width:80%; padding:10px 14px; border-radius:12px; font-size:var(--chat-font);
|
||||
max-width:85%; padding:8px 0; border-radius:0; font-size:var(--chat-font);
|
||||
line-height:1.6; white-space:pre-wrap; word-break:break-word;
|
||||
background:var(--blue-bg); color:var(--txt-strong);
|
||||
background:none; color:var(--txt-strong); font-weight:600;
|
||||
}
|
||||
.bubble.md{ white-space:normal; background:var(--line-soft); color:var(--txt); max-width:100%; width:100%; }
|
||||
.bubble.err{ background:var(--danger-bg); color:var(--danger-text); white-space:normal; }
|
||||
.bubble.md{ white-space:normal; background:none; color:var(--txt); max-width:100%; width:100%; font-weight:400; }
|
||||
.bubble.err{ background:var(--danger-bg); color:var(--danger-text); white-space:normal; padding:8px 14px; border-radius:8px; }
|
||||
.bubble.sys{ background:transparent; color:var(--muted); font-size:12px; padding:4px 8px; }
|
||||
.bubble.md p{ margin:0 0 8px; } .bubble.md p:last-child{ margin:0; }
|
||||
.bubble.md pre{ background:var(--field-bg); border:1px solid var(--line); border-radius:8px; padding:10px 12px; overflow:auto; font-size:.86em; }
|
||||
@@ -596,15 +597,16 @@ html[data-appearance="dark"] .swatch[data-theme="8"].sel::after{
|
||||
.bubble.md table{ width:100%; border-collapse:collapse; margin:0 0 8px; font-size:.92em; }
|
||||
.bubble.md th,.bubble.md td{ border:1px solid var(--line); padding:6px 10px; text-align:left; }
|
||||
.bubble.md th{ background:var(--line-soft); color:var(--txt-strong); }
|
||||
.fold{ margin:6px 0; border:1px solid var(--line); border-radius:8px; background:var(--field-bg); }
|
||||
.bubble .fold summary{ cursor:pointer; padding:6px 10px; font-size:.86em; color:var(--muted); user-select:none; }
|
||||
.bubble .fold pre{ margin:0; padding:10px 12px; border:none; border-top:1px solid var(--line); background:transparent; font-size:.86em; overflow:auto; }
|
||||
.fold{ margin:4px 0; border:none; border-left:2px solid var(--line); border-radius:0; background:transparent; padding-left:12px; }
|
||||
.bubble .fold summary{ cursor:pointer; padding:4px 0; font-size:.82em; color:var(--muted); user-select:none; }
|
||||
.bubble .fold summary:hover{ color:var(--txt-2); }
|
||||
.bubble .fold pre{ margin:0; padding:6px 0; border:none; background:transparent; font-size:.8em; overflow:auto; }
|
||||
.cursor{ display:inline-block; width:7px; height:14px; background:var(--blue); vertical-align:-2px; margin-left:2px; animation:blink 1s steps(2) infinite; }
|
||||
@keyframes blink{ 50%{ opacity:0; } }
|
||||
.run-state.busy .dot{ animation:blink 1s steps(2) infinite; }
|
||||
|
||||
/* 预设功能卡片(现在嵌在聊天空状态)*/
|
||||
.chat-start{ width:100%; max-width:560px; }
|
||||
.chat-start{ width:100%; max-width:var(--chat-max-w); }
|
||||
html[data-boot-has-sessions] .chat-start{ display:none !important; }
|
||||
.feature-head{ text-align:center; margin-bottom:22px; }
|
||||
.feature-grid{ display:grid; grid-template-columns:repeat(auto-fill,minmax(150px,1fr)); gap:12px; }
|
||||
@@ -1263,16 +1265,17 @@ html[data-boot-has-sessions] .chat-start{ display:none !important; }
|
||||
|
||||
/* ═══════════════ Markdown 渲染增强 (from PR) ═══════════════ */
|
||||
.fold-pre{ white-space:pre-wrap; word-break:break-word; max-height:300px; overflow:auto; }
|
||||
.fold.fold-thinking{ border-color:var(--fold-thinking-border); background:var(--fold-thinking-bg); }
|
||||
.fold.fold-tool{ border-color:var(--fold-tool-border); background:var(--fold-tool-bg); }
|
||||
.fold.fold-result{ border-color:var(--fold-result-border); background:var(--fold-result-bg); }
|
||||
.fold.fold-turn{ border-color:var(--fold-turn-border); background:var(--fold-turn-bg); }
|
||||
.fold.fold-thinking{ border-left-color:var(--fold-thinking-border); }
|
||||
.fold.fold-tool{ border-left-color:var(--fold-tool-border); }
|
||||
.fold.fold-result{ border-left-color:var(--fold-result-border); }
|
||||
.fold.fold-turn{ border-left-color:var(--fold-turn-border); }
|
||||
.fold.fold-turn > summary{ font-weight:600; color:var(--txt-2); }
|
||||
.fold.fold-retry{ border-color:var(--fold-retry-border); background:var(--fold-retry-bg); border-style:dashed; }
|
||||
.fold.fold-retry{ border-left-color:var(--fold-retry-border); border-left-style:dashed; }
|
||||
.fold.fold-retry > summary{ font-size:11px; color:var(--muted2); font-weight:400; }
|
||||
.fold.fold-retry pre{ font-size:11px; color:var(--muted); }
|
||||
/* ---- 代码块容器 + 复制按钮 ---- */
|
||||
.bubble.md pre{ position:relative; background:var(--field-bg); border-radius:8px; padding:12px; overflow-x:auto; }
|
||||
.bubble.md .fold pre{ position:static; background:transparent; border-radius:0; padding:6px 0; }
|
||||
.bubble.md pre code{ font-size:.92em; }
|
||||
.code-copy-btn{
|
||||
position:absolute; top:6px; right:6px; z-index:1;
|
||||
@@ -1338,3 +1341,11 @@ html[data-appearance="dark"] .bubble.md pre code{ color:var(--txt-2); }
|
||||
.fold-turn > summary .turn-head-sum{
|
||||
font-style:italic; color:var(--muted); font-weight:normal; margin-left:4px;
|
||||
}
|
||||
|
||||
/* —— 消息计时 badge —— */
|
||||
.task-elapsed{
|
||||
display:block; font-size:11px; color:var(--muted);
|
||||
line-height:18px; min-height:18px;
|
||||
padding:0 6px; margin-bottom:2px;
|
||||
border-radius:var(--radius-sm);
|
||||
}
|
||||
Reference in new issue
Block a user