dsh-island 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # DeepSeek Island · 桌面灵动岛
2
2
 
3
- DeepSeek Harness 的桌面灵动岛插件:像 iPhone 灵动岛一样停在屏幕边缘,实时显示 Agent 在做什么——思考、回答、执行工具、等你确认;展开后是任务控制台,可以看计划进度和用量、停止任务,并直接回答 Agent 的提问。
3
+ DeepSeek Harness 的桌面灵动岛插件:像 iPhone 灵动岛一样停在屏幕边缘,实时显示 Agent 在做什么——思考、回答、执行工具、等你确认;展开后是任务控制台,可以看计划进度、上下文占用和用量、停止任务,直接批准权限请求、回答 Agent 的提问,任务完成时预览回复;另有按时间、模型、会话、轮次统计的用量窗口,并按 DeepSeek 官方价格估算费用。
4
4
 
5
5
  > 社区插件,非 DeepSeek 官方产品。DeepSeek 名称与鲸鱼标志归其所有者。
6
6
 
@@ -10,6 +10,12 @@ DeepSeek Harness 的桌面灵动岛插件:像 iPhone 灵动岛一样停在屏
10
10
  | --- | --- |
11
11
  | ![任务控制台](docs/console.png) | ![回答提问](docs/ask.png) |
12
12
 
13
+ | 批准权限请求 | 有新回复 |
14
+ | --- | --- |
15
+ | ![批准权限请求](docs/approval.png) | ![有新回复](docs/reply.png) |
16
+
17
+ ![用量统计](docs/stats.png)
18
+
13
19
  ![Harness 设置 › 灵动岛](docs/settings.png)
14
20
 
15
21
  [动画录屏(mp4)](docs/motion.mp4)
@@ -30,16 +36,19 @@ DeepSeek Harness 的桌面灵动岛插件:像 iPhone 灵动岛一样停在屏
30
36
  | --- | --- | --- |
31
37
  | 待机 | 118 × 32 | 没有任务:只有鲸鱼和一颗呼吸的蓝点 |
32
38
  | 紧凑 | 212 × 36 | 任务进行中:鲸鱼 · 状态文字 · 动态指示器,多任务时显示数量 |
33
- | 弹出 | 312 × 66 | 新任务开始、等待确认、完成、异常时自动弹出几秒;鼠标悬停也会弹出,显示任务标题 |
34
- | 展开 | 344 × 自适应 | 点击:任务详情、计时、停靠位置、大小、动效 |
39
+ | 弹出 | 312 × 66 | 新任务开始、等待确认、完成、异常时自动弹出几秒,显示任务标题;完成时显示回复开头 |
40
+ | 展开 | 344 × 自适应 | 鼠标停留约 0.7 秒或单击:任务控制台 |
35
41
  | 竖向 | 32 × 78 / 36 × 104 | 停靠在左侧或右侧居中时变为竖条,悬停或提醒时横向舒展 |
36
42
 
37
43
  尺寸、圆角、鲸鱼和指示器的位置都随形态一起用带轻微回弹的弹簧曲线过渡;状态切换时文字和图标做模糊交叉淡入。
38
44
 
45
+ 任务进行中,鲸鱼外圈是**上下文占用环**:按当前上下文占模型窗口的比例描边,70% 以下绿色,70% 起黄色,90% 起红色并轻轻呼吸,提示快要整理上下文了。
46
+
39
47
  ## 使用
40
48
 
41
- - 单击胶囊展开任务控制台,再次单击或按 Esc 收起;鼠标离开 6 秒后自动收起。
42
- - “打开 Harness”回到主窗口;确认和问题仍在 Harness 中处理。
49
+ - 鼠标在灵动岛上停留片刻即展开任务控制台,移开后自动收起;悬停展开后再单击可固定,不随鼠标离开收起。可在设置中关闭悬停展开。
50
+ - 单击胶囊展开或收起,按 Esc 收起;单击展开的面板在鼠标离开 6 秒后自动收起。
51
+ - “打开 Harness”回到主窗口;面板底部的柱状图按钮或托盘菜单「用量统计」打开用量窗口。
43
52
  - “隐藏”只隐藏灵动岛,点系统托盘的蓝色鲸鱼可重新显示;右键托盘可选显示器或退出。
44
53
  - 透明区域不拦截鼠标;窗口置顶但出现时不抢输入焦点。
45
54
 
@@ -47,11 +56,11 @@ DeepSeek Harness 的桌面灵动岛插件:像 iPhone 灵动岛一样停在屏
47
56
 
48
57
  | 什么时候 | 显示什么 |
49
58
  | --- | --- |
50
- | 有任务进行中 | 任务标题与计时;Agent 计划的进度条(如 4/7)和当前步骤;最近几步工具(读取文件、编辑、终端…);模型、上下文大小、缓存命中率、工具次数、输出 tokens;其他同时进行的任务;**停止任务**按钮 |
51
- | 刚结束 | 结果、最终计划进度、本次用量,以及最近几个任务 |
52
- | 空闲 | 今天完成的任务数、工作时长、DeepSeek 账户余额(未登录账号时显示今日 tokens);过去 24 小时每小时的 token 柱状图与缓存命中率;最近几个任务(结果、时长、多久以前) |
59
+ | 有任务进行中 | 任务标题与计时;Agent 计划的进度条(如 4/7)和当前步骤;最近几步工具(读取文件、编辑、终端…);模型、上下文占用(如 412K / 1.0M (39%))、缓存命中率、工具次数、输出 tokens;其他同时进行的任务;**停止任务**按钮 |
60
+ | 刚结束 | **有新回复**:Agent 最后一条回复的开头(点击回到 Harness 看全文);结果、最终计划进度、本次用量 |
61
+ | 空闲 | 今天完成的任务数、工作时长、DeepSeek 账户余额(未登录账号时显示今日 tokens);过去 24 小时每小时的 token 柱状图、缓存命中率和今日估算费用;最近几个任务(结果、时长、多久以前) |
53
62
 
54
- **停止任务**要点两次(第一次变红确认),效果与 Harness 里的停止按钮相同,只对顶层任务生效;子智能体不会单独列为任务,除非它在等你确认或出错。完成记录保存在 `%USERPROFILE%\.dsh\dsh-island\history.json`,只含标题、结果、时长、工具次数和 token 数,保留 7 天。
63
+ **停止任务**要点两次(第一次变红确认),效果与 Harness 里的停止按钮相同,只对顶层任务生效;子智能体不会单独列为任务,除非它在等你确认或出错。用量数据来自 Harness 自己的会话记录(`ctx.sessionQuery`):启用后在后台补算最近 30 天的会话,之后跟随实时事件更新,汇总保存在 `%USERPROFILE%\.dsh\dsh-island\usage.json`,只含会话标题、每轮的模型、时间、结果、调用与工具次数、token 数和估算费用,保留 35 天,不含对话内容。
55
64
 
56
65
  账户余额通过 Harness 自己的账户服务读取(与「设置 › 账户」相同),只在用 DeepSeek 账号登录时可用;灵动岛看不到任何令牌或 API Key。打开面板时最多每分钟刷新一次,另外每 10 分钟刷新一次。
57
66
 
@@ -61,6 +70,30 @@ Agent 用 `ask_user_question` 提问时,灵动岛自动展开提问卡:单
61
70
 
62
71
  计划审阅(plan mode)和带长说明的问题需要看全文,仍在 Harness 中回答。可在设置中关闭「在灵动岛回答提问」。
63
72
 
73
+ ## 在灵动岛批准权限请求
74
+
75
+ Agent 要执行需要授权的操作(运行命令、修改文件、访问网址等)时,灵动岛变为“需要你批准”并自动展开审批卡,显示是哪个工具、为什么、具体要做什么(命令全文、文件路径或网址)。可以 **允许一次** 或 **拒绝**;按钮在卡片出现后约 0.6 秒才可点,避免误触。Harness 窗口里的审批卡照常可用,**哪边先批用哪边**,灵动岛先批时 Harness 里的卡随之撤下;没有打开 Harness 窗口时灵动岛就是审批入口。多个请求依次处理。
76
+
77
+ “始终允许”等持久授权不在灵动岛提供,仍在 Harness 中设置;子智能体的请求也在 Harness 中处理。可在设置中关闭「在灵动岛批准权限请求」。
78
+
79
+ ## 用量统计
80
+
81
+ 从面板底部的柱状图按钮或托盘菜单打开,范围可选 **今天**(按小时)、**7 天**、**30 天**(按天):
82
+
83
+ - 概览:总 tokens、估算费用、缓存命中率、轮次(完成/其他)、工作时长。
84
+ - 堆叠柱状图:缓存命中 / 未命中输入 / 输出,悬停查看每段的数量、费用和轮次。
85
+ - 按模型:tokens、估算费用、轮次。
86
+ - 按会话:每个会话的轮次、tokens、费用,点开看每一轮的结束时间、模型、调用与工具次数、各类 tokens 和费用。
87
+
88
+ 费用按 [DeepSeek 官方价格](https://api-docs.deepseek.com/zh-cn/quick_start/pricing)(2026-10-27 查询,人民币/百万 tokens)逐次调用估算:
89
+
90
+ | 模型 | 时段 | 缓存命中输入 | 未命中输入 | 输出 |
91
+ | --- | --- | --- | --- | --- |
92
+ | deepseek-flash | 空闲 / 高峰 | 0.02 / 0.04 | 1 / 2 | 4 / 8 |
93
+ | deepseek-v4-pro | 空闲 / 高峰 | 0.15 / 0.30 | 4.5 / 9 | 13.5 / 27 |
94
+
95
+ 高峰时段为北京时间工作日 9:00–12:00、14:00–18:00(法定节假日无法识别,按工作日计)。写入缓存的输入按未命中价,输出含思考内容;已下线的 `deepseek-v4-flash` 按 Flash 价计;没有官方价格的模型不计入费用。估算仅供参考,实际扣费以 DeepSeek 账单为准。
96
+
64
97
  ## 在 Harness 中设置
65
98
 
66
99
  位置、大小等选项在 Harness 的 **设置 › 灵动岛** 中,修改后立即生效,无需重启:
@@ -71,8 +104,10 @@ Agent 用 `ask_user_question` 提问时,灵动岛自动展开提问卡:单
71
104
  | 显示灵动岛 | 关闭即隐藏;打开时会重新显示 |
72
105
  | 大小 | 小巧、适中、舒展 |
73
106
  | 弹出提醒 | 任务开始、完成、需要确认或出错时短暂放大 |
74
- | 显示任务内容 | 关闭后不显示任务标题、计划内容和工具名,只显示状态 |
107
+ | 显示任务内容 | 关闭后不显示任务标题、计划内容、工具名、审批详情和回复预览,只显示状态 |
75
108
  | 在灵动岛回答提问 | Agent 提问时自动展开提问卡,可直接回答 |
109
+ | 在灵动岛批准权限请求 | 需要授权时显示审批卡,可允许一次或拒绝 |
110
+ | 悬停展开面板 | 鼠标停留即展开,移开收起;关闭后需要单击 |
76
111
  | 显示账户余额 | 空闲时显示 DeepSeek 账户余额 |
77
112
  | 减少动态效果 | 关闭循环动画和弹性过渡(也跟随系统设置) |
78
113
 
@@ -11,6 +11,7 @@
11
11
  <div class="surface">
12
12
  <button id="capsule" class="capsule" aria-label="展开 DeepSeek 灵动岛" aria-expanded="false" aria-controls="panel">
13
13
  <span class="lead" aria-hidden="true">
14
+ <svg class="ctx-ring" viewBox="0 0 40 40" aria-hidden="true"><circle class="ctx-track" cx="20" cy="20" r="18"/><circle id="ctx-arc" class="ctx-arc" cx="20" cy="20" r="18" pathLength="100"/></svg>
14
15
  <span class="whale"></span>
15
16
  <span class="spout"><i></i><i></i><i></i></span>
16
17
  </span>
@@ -35,6 +36,16 @@
35
36
  </button>
36
37
 
37
38
  <section id="panel" class="panel" inert aria-label="任务详情">
39
+ <form id="approve" class="card ask approve" hidden autocomplete="off">
40
+ <div class="ask-top"><span class="ask-dot" aria-hidden="true"></span><span id="approve-title">需要你批准</span><span id="approve-count" class="num"></span></div>
41
+ <p id="approve-reason" class="ask-q"></p>
42
+ <div id="approve-detail" class="approve-detail"><span id="approve-label"></span><code id="approve-text"></code></div>
43
+ <div class="ask-actions"><span id="approve-status" class="ask-status" role="status"></span><button id="approve-reject" type="button" class="ask-skip approve-reject">拒绝</button><button id="approve-allow" type="submit" class="ask-next">允许一次</button></div>
44
+ </form>
45
+ <div id="reply" class="card reply" hidden>
46
+ <div class="reply-head"><span class="reply-dot" aria-hidden="true"></span>有新回复<span class="reply-hint">回到 Harness 查看全文</span></div>
47
+ <p id="reply-text"></p>
48
+ </div>
38
49
  <form id="ask" class="card ask" hidden autocomplete="off">
39
50
  <div class="ask-top"><span class="ask-dot" aria-hidden="true"></span><span id="ask-title">Agent 在问你</span><span id="ask-step" class="num"></span></div>
40
51
  <p id="ask-question" class="ask-q"></p>
@@ -82,6 +93,7 @@
82
93
  <div class="footer">
83
94
  <button id="stop" class="stop" hidden><span class="stop-glyph" aria-hidden="true"></span><span id="stop-label">停止任务</span></button>
84
95
  <button id="open-harness" class="open-harness">打开 Harness <span aria-hidden="true">↗</span></button>
96
+ <button id="stats" class="icon-button" aria-label="用量统计" title="用量统计"><svg viewBox="0 0 16 16" aria-hidden="true" width="14" height="14"><path d="M2.5 13.5h11M4.5 11V7.5M8 11V3.5M11.5 11V6" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button>
85
97
  <button id="more" class="icon-button" aria-label="更多" title="显示器、隐藏、退出">···</button>
86
98
  <button id="hide" class="hide" title="可从系统托盘重新显示">隐藏</button>
87
99
  </div>
@@ -281,6 +281,28 @@ p{margin:3px 0 0;font-size:11.5px;line-height:1.5;color:var(--muted)}
281
281
  .ask-next{height:28px;padding:0 14px;border-radius:9px;font-size:11.5px;font-weight:600;color:#1b1203;background:#f5b544}
282
282
  .ask-next:hover{background:#ffc35c}
283
283
  .ask-next:disabled{opacity:.45;cursor:default}
284
+ .ctx-ring{position:absolute;inset:-5px;width:calc(100% + 10px);height:calc(100% + 10px);transform:rotate(-90deg);opacity:0;transition:opacity .4s;pointer-events:none;overflow:visible}
285
+ .ctx-track{fill:none;stroke:rgba(255,255,255,.08);stroke-width:2.6}
286
+ .ctx-arc{fill:none;stroke:var(--ctx-color,#3ddc91);stroke-width:2.6;stroke-linecap:round;stroke-dasharray:var(--ctx-pct,0) 100;transition:stroke-dasharray .8s var(--spring),stroke .4s}
287
+ .island:not([data-state="idle"]):not([data-state="offline"])[data-ctx] .ctx-ring{opacity:1}
288
+ .island[data-ctx="ok"]{--ctx-color:#3ddc91}.island[data-ctx="warn"]{--ctx-color:#f5b544}.island[data-ctx="full"]{--ctx-color:#ff6b6b}
289
+ .island[data-ctx="full"] .ctx-arc{animation:pulse 1.6s ease-in-out infinite}
290
+ .approve{border-color:color-mix(in srgb,#ff9b5e 34%,transparent);background:linear-gradient(180deg,rgba(255,155,94,.08),rgba(255,255,255,.03))}
291
+ .approve .ask-dot{background:#ff9b5e;--c:#ff9b5e}
292
+ .approve .ask-top{color:#ffc39b}
293
+ .approve-detail{display:flex;flex-direction:column;gap:3px;padding:8px 10px;border-radius:10px;background:rgba(0,0,0,.4);border:1px solid rgba(255,255,255,.06)}
294
+ .approve-detail:empty,.approve-detail[hidden]{display:none}
295
+ .approve-detail #approve-label{font-size:10px;color:var(--muted)}
296
+ .approve-detail code{font:11.5px/1.5 "Cascadia Code",Consolas,"Microsoft YaHei UI",monospace;color:#f3f5ff;white-space:pre-wrap;word-break:break-all;max-height:84px;overflow:auto}
297
+ .approve-reject{color:#ffb4b4}
298
+ .approve-reject:hover{background:rgba(255,107,107,.14)!important;color:#ffd0d0!important}
299
+ .reply{border-color:color-mix(in srgb,#3ddc91 26%,transparent)}
300
+ .reply-head{display:flex;align-items:center;gap:7px;font-size:11px;color:#9ff0c6}
301
+ .reply-hint{margin-left:auto;font-size:10px;color:var(--faint)}
302
+ .reply-dot{width:7px;height:7px;border-radius:50%;background:#3ddc91}
303
+ #reply-text{margin:6px 0 0;font-size:12px;line-height:1.55;color:#e8ecf8;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
304
+ .ghost{height:30px;padding:0 10px;border-radius:10px;font-size:11.5px;color:var(--muted)}
305
+ .ghost:hover{background:rgba(255,255,255,.06);color:#e5e9f7}
284
306
  .hint{margin:0 2px;font-size:10.5px;line-height:1.5;color:var(--faint)}
285
307
 
286
308
  .footer{display:flex;align-items:center;gap:6px;margin-top:2px}
@@ -339,3 +361,10 @@ body[data-motion="reduced"] .ind-completed .tick{stroke-dashoffset:0}
339
361
  *,*::before,*::after{animation:none!important;transition-duration:.08s!important;transition-delay:0s!important}
340
362
  .ind-completed .tick{stroke-dashoffset:0}
341
363
  }
364
+ .approve-detail code.plain{font-family:inherit}
365
+ .island[data-alert="approval"]{--tint:#ff9b5e}
366
+ .reply{cursor:pointer;transition:background-color .2s}
367
+ .reply:hover{background:rgba(61,220,145,.07)}
368
+ .footer button{white-space:nowrap}
369
+ #stats svg{display:block;margin:auto}
370
+ #stats+#more{margin-left:0}
package/desktop/main.cjs CHANGED
@@ -27,9 +27,10 @@ async function start() {
27
27
  };
28
28
  // 本地只保存“是否隐藏”和显示器;位置、大小、动效来自 Harness 设置(预览模式下才在本地保存)。
29
29
  let settings=normalizeSettings(read('settings.json',{}));
30
- let prefs={enabled:true,popups:true,details:true,balance:true,questions:true};
30
+ let prefs={enabled:true,popups:true,details:true,balance:true,questions:true,approvals:true,hoverExpand:true};
31
31
  let balance=null;
32
- let answerResults={};
32
+ let answerResults={},approvalResults={};
33
+ let statsWin=null,statsRange='today';
33
34
  let history=Array.isArray(read('history.json',[]))?read('history.json',[]):[];
34
35
  let win, tray, ignore=true, hitbox=null, quitting=false, activity={state:'idle',activeCount:0}, demoTimer;
35
36
  const save=()=>write('settings.json',settings);
@@ -62,7 +63,8 @@ async function start() {
62
63
  day:{hours,tokens:hours.reduce((a,b)=>a+b,0),cacheRate:input+cache>0?Math.round(cache/(input+cache)*100):null}
63
64
  };
64
65
  };
65
- const payload=()=>({activity,settings,prefs,answerResults,balance:prefs.balance?balance:null,preview:demo,history:summary(),displays:screen.getAllDisplays().map((d,i)=>({id:d.id,label:d.label||`显示器 ${i+1}`}))});
66
+ // Harness 侧的用量账本(含历史会话补算)优先;没有时退回本地的完成记录。
67
+ const payload=()=>({activity,settings,prefs,answerResults,approvalResults,balance:prefs.balance?balance:null,preview:demo,history:activity.usage??summary(),displays:screen.getAllDisplays().map((d,i)=>({id:d.id,label:d.label||`显示器 ${i+1}`}))});
66
68
  const send=()=>{ if(win && !win.isDestroyed())win.webContents.send('island:snapshot',payload()); };
67
69
  const position=()=>{
68
70
  const display=screen.getAllDisplays().find(d=>d.id===settings.displayId)||screen.getPrimaryDisplay();
@@ -76,11 +78,29 @@ async function start() {
76
78
  const update=value=>{settings=normalizeSettings({...settings,...value});save();sync();};
77
79
  const applyPrefs=value=>{
78
80
  if(!value||typeof value!=='object')return;
79
- prefs={enabled:value.enabled!==false,popups:value.popups!==false,details:value.details!==false,balance:value.balance!==false,questions:value.questions!==false};
81
+ prefs={enabled:value.enabled!==false,popups:value.popups!==false,details:value.details!==false,balance:value.balance!==false,questions:value.questions!==false,approvals:value.approvals!==false,hoverExpand:value.hoverExpand!==false};
80
82
  settings=normalizeSettings({...settings,position:value.position,scale:SIZES[value.size]??1,motion:value.reduceMotion?'reduced':'full'});
81
83
  save();sync();
82
84
  };
83
85
  const openHarness=()=>shell.openExternal('dsh://open').catch(()=>{});
86
+ // 用量统计窗口:数据由 Harness 侧的用量账本按需计算(预览模式用示例数据)。
87
+ const requestStats=range=>{
88
+ statsRange=['today','7d','30d'].includes(range)?range:statsRange;
89
+ if(demo){const data=demoStats(statsRange);setTimeout(()=>statsWin?.webContents.send('stats:data',data),120);return;}
90
+ if(process.send)process.send({type:'stats',range:statsRange});
91
+ else statsWin?.webContents.send('stats:data',null);
92
+ };
93
+ const openStats=()=>{
94
+ if(statsWin&&!statsWin.isDestroyed()){statsWin.show();statsWin.focus();requestStats(statsRange);return;}
95
+ statsWin=new BrowserWindow({width:860,height:680,minWidth:620,minHeight:480,show:false,title:'用量统计 · DeepSeek 灵动岛',backgroundColor:'#0b0e17',autoHideMenuBar:true,
96
+ webPreferences:{preload:path.join(__dirname,'stats-preload.cjs'),contextIsolation:true,nodeIntegration:false,sandbox:true}});
97
+ statsWin.setMenuBarVisibility(false);
98
+ statsWin.webContents.setWindowOpenHandler(({url})=>{if(/^https:\/\/api-docs\.deepseek\.com\//.test(url))shell.openExternal(url).catch(()=>{});return {action:'deny'};});
99
+ statsWin.webContents.on('will-navigate',event=>event.preventDefault());
100
+ statsWin.once('ready-to-show',()=>statsWin.show());
101
+ statsWin.on('closed',()=>{statsWin=null;});
102
+ statsWin.loadFile(path.join(__dirname,'stats.html'),{query:{preview:demo?'1':''}});
103
+ };
84
104
  const menu=()=>Menu.buildFromTemplate([
85
105
  {label:visible()?'隐藏灵动岛':'显示灵动岛',click:()=>visible()?hide():show()},
86
106
  {label:'显示器',submenu:screen.getAllDisplays().map((d,i)=>({label:d.label||`显示器 ${i+1}`,type:'radio',checked:(settings.displayId??screen.getPrimaryDisplay().id)===d.id,click:()=>update({displayId:d.id})}))},
@@ -91,6 +111,7 @@ async function start() {
91
111
  {label:'播放全部状态',click:()=>playDemo()}
92
112
  ]:[{label:'位置与大小:Harness 设置 › 灵动岛',click:openHarness}]),
93
113
  {type:'separator'},
114
+ {label:'用量统计',click:openStats},
94
115
  {label:'打开 DeepSeek Harness',click:openHarness},
95
116
  {label:'退出灵动岛',click:()=>app.quit()}
96
117
  ]);
@@ -118,6 +139,14 @@ async function start() {
118
139
  if(process.send)process.send({type:'answer',callId,answers:clean});
119
140
  });
120
141
  ipcMain.on('island:panel',event=>{if(trusted(event)&&process.send)process.send({type:'panel-open'});});
142
+ ipcMain.on('island:stats',event=>{if(trusted(event))openStats();});
143
+ ipcMain.on('stats:request',(event,range)=>{if(statsWin&&event.sender===statsWin.webContents&&event.senderFrame===statsWin.webContents.mainFrame)requestStats(range);});
144
+ ipcMain.on('island:approve',(event,id,outcome)=>{
145
+ if(!trusted(event)||typeof id!=='string'||!['allowed-once','rejected'].includes(outcome))return;
146
+ if(!(activity.approvals||[]).some(a=>a.id===id))return;
147
+ if(demo){approvalResults={[id]:{ok:true,at:Date.now()}};activity={...activity,approvals:activity.approvals.filter(a=>a.id!==id)};send();return;}
148
+ if(process.send)process.send({type:'approve',id,outcome});
149
+ });
121
150
  ipcMain.on('island:stop',(event,sessionId)=>{
122
151
  if(!trusted(event)||typeof sessionId!=='string'||sessionId!==activity.sessionId||!activity.stoppable)return;
123
152
  if(demo){setDemoState('paused');return;}
@@ -133,11 +162,12 @@ async function start() {
133
162
  const busy=!done;
134
163
  activity={state,title:'重构登录模块并补齐测试',sessionId:'preview',activeCount:busy?2:0,started:Date.now()-192000,ended:done?Date.now():0,
135
164
  tool:state==='tools'?'str_replace_editor':'',stoppable:busy&&state!=='offline',
136
- detail:state==='idle'?null:{plan:{total:7,done:state==='completed'?7:4,current:state==='completed'?'':'补齐登录失败场景的单元测试'},trail:['read','grep','str_replace_editor','bash'],toolCount:23,model:'deepseek-v4',contextTokens:63400,outputTokens:8210,cacheRate:94},
165
+ detail:state==='idle'?null:{plan:{total:7,done:state==='completed'?7:4,current:state==='completed'?'':'补齐登录失败场景的单元测试'},trail:['read','grep','str_replace_editor','bash'],toolCount:23,model:'deepseek-v4-pro',contextTokens:state==='compacting'?968000:412000,contextWindow:1048576,outputTokens:8210,cacheRate:94,...(state==='completed'?{reply:'登录模块已拆成 3 个文件,新增 14 个单元测试全部通过。验证码过期场景需要后端配合,我在 TODO 里留了说明。'}:{})},
137
166
  sessions:busy?[{id:'preview',title:'重构登录模块并补齐测试',state,started:Date.now()-192000},{id:'p2',title:'整理周报数据',state:'streaming',started:Date.now()-41000}]:[],
138
167
  questions:state==='waiting'?[{callId:'demo-ask',sessionId:'preview',state:'open',title:'重构登录模块并补齐测试',questions:[
139
168
  {id:'framework',header:'测试框架',question:'登录模块的单元测试用哪个框架?',multiSelect:false,options:[{label:'Vitest',description:'和现有构建工具一致,速度快'},{label:'Jest',description:'生态成熟,迁移成本略高'},{label:'node:test',description:'零依赖'}]},
140
169
  {id:'cases',header:'覆盖场景',question:'需要覆盖哪些失败场景?',multiSelect:true,options:[{label:'密码错误'},{label:'账号锁定'},{label:'验证码过期'}]}]}]:[],
170
+ approvals:state==='waiting'?[{id:'demo-approval',sessionId:'preview',title:'重构登录模块并补齐测试',at:Date.now(),toolName:'bash',reason:'运行测试需要执行命令',detail:{label:'命令',text:'npm test -- --coverage src/auth',mono:true}}]:[],
141
171
  finished:[]};
142
172
  if(demo&&!history.length){
143
173
  const sample=[['修复首页加载白屏','completed',22,312,18],['写一份竞品对比表','completed',17,146,9],['整理接口文档','completed',9,205,14],['升级依赖并跑测试','error',3,88,6],['生成周报图表','completed',1.5,131,11]];
@@ -146,12 +176,32 @@ async function start() {
146
176
  }
147
177
  send();
148
178
  };
179
+ const demoStats=range=>{
180
+ const days=range==='30d'?30:range==='7d'?7:1,byHour=range==='today',start=new Date();start.setHours(0,0,0,0);start.setDate(start.getDate()-(days-1));
181
+ const from=start.getTime(),n=byHour?24:days,nowH=new Date().getHours();
182
+ const wave=i=>byHour?(i<=nowH&&i>=Math.min(9,Math.max(0,nowH-6))?0.4+Math.abs(Math.sin(i*1.7)):0):(0.3+Math.abs(Math.sin(i*2.1))*(i%7===5||i%7===6?0.3:1));
183
+ const buckets=Array.from({length:n},(_,i)=>{const w=wave(i),d=new Date(from);if(byHour)d.setHours(i);else d.setDate(d.getDate()+i);
184
+ const cacheRead=Math.round(w*(byHour?380000:2.6e6)),input=Math.round(w*(byHour?21000:150000)),output=Math.round(w*(byHour?9000:64000));
185
+ return {start:d.getTime(),label:byHour?`${i}:00`:`${d.getMonth()+1}/${d.getDate()}`,cacheRead,input,output,cost:(cacheRead*0.02+input*1+output*4)/1e6*(i%3?1:2.2),turns:w?Math.max(1,Math.round(w*(byHour?3:14))):0};});
186
+ const sum=k=>buckets.reduce((a,b)=>a+b[k],0),tokens=sum('cacheRead')+sum('input')+sum('output');
187
+ const titles=['重构登录模块并补齐测试','整理周报数据','修复首页加载白屏','写一份竞品对比表','整理接口文档','升级依赖并跑测试'];
188
+ const sessions=titles.slice(0,byHour?4:6).map((title,i)=>{const share=[.38,.22,.16,.11,.08,.05][i],turns=Math.max(1,Math.round(sum('turns')*share)),last=Date.now()-i*(byHour?3600e3:86400e3*days/7);
189
+ const items=Array.from({length:Math.min(turns,8)},(_,k)=>{const end=last-k*1500e3,inp=9000+k*700,cr=120000+k*9000,out=3800+k*300,model=i===0&&k<2?'deepseek-v4-pro':'deepseek-flash';
190
+ return {turn:turns-k,start:end-(120+k*40)*1000,end,state:k===2&&i===3?'error':'completed',model,calls:3+k%4,tools:5+k%6,input:inp,cacheRead:cr,output:out,cost:model==='deepseek-flash'?(inp+cr*0.02+out*4)/1e6:(inp*4.5+cr*0.15+out*13.5)/1e6,priced:true};});
191
+ return {id:'demo'+i,title,tokens:Math.round(tokens*share),cost:sum('cost')*share,turns,last,items};});
192
+ return {range,from,to:Date.now(),byHour,buckets,preview:true,
193
+ totals:{tokens,input:sum('input'),cacheRead:sum('cacheRead'),output:sum('output'),cost:sum('cost'),unpricedTokens:0,turns:sum('turns'),completed:Math.round(sum('turns')*.93),ms:sum('turns')*184000,cacheRate:tokens?Math.round(sum('cacheRead')/(sum('cacheRead')+sum('input'))*100):null},
194
+ models:[{model:'deepseek-flash',priced:true,tokens:Math.round(tokens*.78),cost:sum('cost')*.46,turns:Math.round(sum('turns')*.8)},{model:'deepseek-v4-pro',priced:true,tokens:Math.round(tokens*.22),cost:sum('cost')*.54,turns:Math.round(sum('turns')*.2)}],
195
+ sessions,scanned:42};
196
+ };
149
197
  const playDemo=()=>{clearInterval(demoTimer);let i=0;const order=['idle','thinking','streaming','tools','waiting','completed','paused','error','reconnecting','compacting','offline'];setDemoState(order[i++]);demoTimer=setInterval(()=>setDemoState(order[i++%order.length]),4500);};
150
198
  process.on('message',message=>{
151
199
  if(message?.type==='shutdown')app.quit();
152
200
  if(message?.type==='prefs')applyPrefs(message.data);
153
201
  if(message?.type==='show')show();
154
202
  if(message?.type==='answer-result'&&typeof message.callId==='string'){answerResults={...Object.fromEntries(Object.entries(answerResults).filter(([,r])=>Date.now()-r.at<300000)),[message.callId]:{ok:!!message.ok,reason:String(message.reason||'').slice(0,80),at:Date.now()}};send();}
203
+ if(message?.type==='approval-result'&&typeof message.id==='string'){approvalResults={...Object.fromEntries(Object.entries(approvalResults).filter(([,r])=>Date.now()-r.at<300000)),[message.id]:{ok:!!message.ok,reason:String(message.reason||'').slice(0,80),at:Date.now()}};send();}
204
+ if(message?.type==='stats'&&statsWin&&!statsWin.isDestroyed())statsWin.webContents.send('stats:data',message.data??null);
155
205
  if(message?.type==='balance'){balance=message.data&&Array.isArray(message.data.wallets)?message.data:null;send();}
156
206
  if(message?.type==='activity'&&Object.hasOwn(STATES,message.data?.state)){activity=message.data;remember(activity.finished);send();}
157
207
  });
@@ -175,7 +225,8 @@ async function start() {
175
225
  screen.on('display-metrics-changed',position); screen.on('display-removed',position);screen.on('display-added',position);
176
226
  app.on('second-instance',show);
177
227
  win.on('close',event=>{if(!quitting){event.preventDefault();hide();}});
178
- app.on('before-quit',()=>{quitting=true;clearInterval(cursorTimer);clearInterval(demoTimer);tray?.destroy();});
228
+ app.on('before-quit',()=>{quitting=true;statsWin?.destroy();clearInterval(cursorTimer);clearInterval(demoTimer);tray?.destroy();});
179
229
  if(process.send) process.send({type:'ready'});
180
230
  if(demo && process.argv.includes('--cycle'))playDemo();
231
+ if(process.argv.includes('--stats'))openStats();
181
232
  }
@@ -10,5 +10,7 @@ contextBridge.exposeInMainWorld('island',{
10
10
  stop:sessionId=>ipcRenderer.send('island:stop',sessionId),
11
11
  panel:()=>ipcRenderer.send('island:panel'),
12
12
  answer:(callId,answers)=>ipcRenderer.send('island:answer',callId,answers),
13
+ approve:(id,outcome)=>ipcRenderer.send('island:approve',id,outcome),
14
+ stats:()=>ipcRenderer.send('island:stats'),
13
15
  preview:state=>ipcRenderer.send('island:preview',state)
14
16
  });
@@ -3,8 +3,8 @@ import {STATES} from '../src/state.js';
3
3
  const $=id=>document.getElementById(id);
4
4
  const island=$('island'),stage=$('stage'),bridge=window.island;
5
5
  let current={activity:{state:'idle'},settings:{position:'top-center',scale:1,motion:'full'}};
6
- let expanded=false,peek=false,burstUntil=0,lastState='',boundsFrame,burstTimer,peekTimer,leaveTimer,armTimer,listKey='',autoOpened=false;
7
- const drafts=new Map(),seenAsk=new Set();
6
+ let expanded=false,peek=false,burstUntil=0,lastState='',boundsFrame,burstTimer,peekTimer,leaveTimer,armTimer,hoverTimer,listKey='',autoOpened=false,hoverOpened=false,lastReply='';
7
+ const drafts=new Map(),seenAsk=new Set(),seenApproval=new Map(),sentApproval=new Map();
8
8
 
9
9
  // 出现时短暂"弹出"以示提醒;思考仅在新任务开始时弹出。
10
10
  const BURST={thinking:1700,waiting:3600,completed:3000,error:3800,paused:2200,offline:2600};
@@ -41,7 +41,8 @@ function applyShape(){
41
41
  function fitPanel(){island.style.height=expanded?`${Math.min(innerHeight-24,$('panel').scrollHeight+76)}px`:'';}
42
42
 
43
43
  function expand(value){
44
- expanded=value;peek=false;clearTimeout(peekTimer);
44
+ expanded=value;peek=false;clearTimeout(peekTimer);clearTimeout(hoverTimer);
45
+ if(!value)hoverOpened=false;
45
46
  island.dataset.expanded=String(value);
46
47
  $('capsule').setAttribute('aria-expanded',String(value));
47
48
  $('capsule').setAttribute('aria-label',value?'收起 DeepSeek 灵动岛':'展开 DeepSeek 灵动岛');
@@ -56,18 +57,21 @@ function burst(ms){
56
57
  burstTimer=setTimeout(applyShape,ms+20);applyShape();
57
58
  }
58
59
 
59
- // 悬停预览:轻停片刻再舒展,离开后收回;展开时离开过久自动收起。
60
+ // 悬停:轻停片刻先舒展;停留更久(可在设置里关闭)直接展开面板,离开后收回。
61
+ const busyHere=()=>!!(askOf()||approvalOf()||document.activeElement?.matches('select,input'));
60
62
  island.addEventListener('mouseenter',()=>{
61
- clearTimeout(leaveTimer);clearTimeout(peekTimer);
62
- if(!expanded)peekTimer=setTimeout(()=>{peek=true;applyShape();},140);
63
+ clearTimeout(leaveTimer);clearTimeout(peekTimer);clearTimeout(hoverTimer);
64
+ if(expanded)return;
65
+ peekTimer=setTimeout(()=>{peek=true;applyShape();},140);
66
+ if(current.prefs?.hoverExpand!==false)hoverTimer=setTimeout(()=>{if(!expanded){hoverOpened=true;autoOpened=false;expand(true);}},700);
63
67
  });
64
68
  island.addEventListener('mouseleave',()=>{
65
- clearTimeout(peekTimer);
69
+ clearTimeout(peekTimer);clearTimeout(hoverTimer);
66
70
  if(peek){peekTimer=setTimeout(()=>{peek=false;applyShape();},220);}
67
- if(expanded)leaveTimer=setTimeout(()=>{if(expanded&&!askOf()&&!document.activeElement?.matches('select,input'))expand(false);},6000);
71
+ if(expanded)leaveTimer=setTimeout(()=>{if(expanded&&!busyHere())expand(false);},hoverOpened?450:6000);
68
72
  });
69
73
 
70
- $('capsule').addEventListener('click',()=>{autoOpened=false;expand(!expanded);});
74
+ $('capsule').addEventListener('click',()=>{autoOpened=false;const pinned=hoverOpened;hoverOpened=false;clearTimeout(hoverTimer);if(pinned&&expanded){applyShape();return;}expand(!expanded);});
71
75
  document.addEventListener('keydown',e=>{if(e.key==='Escape'&&expanded)expand(false);});
72
76
  document.addEventListener('contextmenu',e=>{e.preventDefault();bridge.menu();});
73
77
  // 停止任务需要点两次:第一次变红确认,3 秒内再点才真正停止。
@@ -78,6 +82,8 @@ $('stop').addEventListener('click',()=>{
78
82
  });
79
83
  $('more').addEventListener('click',()=>bridge.menu());
80
84
  $('open-harness').addEventListener('click',()=>bridge.open());
85
+ $('stats').addEventListener('click',()=>bridge.stats?.());
86
+ $('reply').addEventListener('click',()=>bridge.open());
81
87
  $('hide').addEventListener('click',()=>{expand(false);bridge.hide();});
82
88
  for(const [key,value] of Object.entries(STATES)){const o=document.createElement('option');o.value=key;o.textContent=value.label;$('demo-state').append(o);}
83
89
  $('demo-state').addEventListener('change',()=>bridge.preview($('demo-state').value));
@@ -154,6 +160,42 @@ function advance(){
154
160
  $('ask').addEventListener('submit',e=>{e.preventDefault();if(!$('ask-next').disabled)advance();});
155
161
  $('ask-skip').addEventListener('click',()=>{const c=currentAsk();if(!c)return;c.ans.selected=[];c.ans.custom='';$('ask-custom').value='';advance();});
156
162
 
163
+ // 审批卡:在灵动岛里允许一次或拒绝工具调用;出现后短暂锁定按钮,防止误点。
164
+ function approvalOf(){return current.prefs?.approvals!==false?(current.activity?.approvals||[])[0]:undefined;}
165
+ function renderApprove(){
166
+ const a=approvalOf();$('approve').hidden=!a;
167
+ if(!a)return false;
168
+ if(!seenApproval.has(a.id))seenApproval.set(a.id,Date.now());
169
+ const details=current.prefs?.details!==false,count=(current.activity.approvals||[]).length;
170
+ $('approve-title').textContent=`${toolLabel(a.toolName)} 需要你批准`+(details&&a.title?` · ${a.title}`:'');
171
+ $('approve-count').textContent=count>1?`还有 ${count-1} 个`:'';
172
+ $('approve-reason').textContent=a.reason||'Agent 想执行一个需要授权的操作。';
173
+ const detail=details?a.detail:null;
174
+ $('approve-detail').hidden=!detail;
175
+ if(detail){$('approve-label').textContent=detail.label;$('approve-text').textContent=detail.text;$('approve-text').classList.toggle('plain',!detail.mono);}
176
+ const sent=sentApproval.get(a.id),result=current.approvalResults?.[a.id],failed=result&&!result.ok&&result.at>(sent||0);
177
+ if(failed)sentApproval.delete(a.id);
178
+ const sending=sentApproval.has(a.id),locked=Date.now()-seenApproval.get(a.id)<600;
179
+ $('approve-allow').disabled=$('approve-reject').disabled=sending||locked;
180
+ if(locked)setTimeout(renderApprove,620-(Date.now()-seenApproval.get(a.id)));
181
+ const st=$('approve-status');st.className='ask-status'+(failed?' err':'');
182
+ st.textContent=sending?'正在提交…':failed?result.reason:'持久授权请在 Harness 中设置';
183
+ return true;
184
+ }
185
+ function decide(outcome){const a=approvalOf();if(!a||sentApproval.has(a.id)||$('approve-allow').disabled)return;sentApproval.set(a.id,Date.now());bridge.approve(a.id,outcome);renderApprove();}
186
+ $('approve').addEventListener('submit',e=>{e.preventDefault();decide('allowed-once');});
187
+ $('approve-reject').addEventListener('click',()=>decide('rejected'));
188
+
189
+ // 上下文占用:鲸鱼外圈按占用比例描边,70% 起变黄,90% 起变红。
190
+ function contextOf(d){if(!d?.contextTokens||!d?.contextWindow)return null;return Math.min(100,Math.round(d.contextTokens/d.contextWindow*100));}
191
+ function renderRing(state,d){
192
+ const pct=['idle','offline'].includes(state)?null:contextOf(d);
193
+ if(pct==null){delete island.dataset.ctx;return;}
194
+ island.dataset.ctx=pct>=90?'full':pct>=70?'warn':'ok';
195
+ island.style.setProperty('--ctx-pct',String(Math.max(2,pct)));
196
+ $('capsule').title=`上下文已用 ${pct}%`;
197
+ }
198
+
157
199
  // 展开面板:进行中显示当前任务卡和其他任务,空闲时显示今天的统计和最近完成。
158
200
  function renderPanel({activity:a,prefs={},history={},balance,preview}){
159
201
  const state=Object.hasOwn(STATES,a.state)?a.state:'offline',details=prefs.details!==false;
@@ -168,7 +210,8 @@ function renderPanel({activity:a,prefs={},history={},balance,preview}){
168
210
  const trailKey=trail.join('|');
169
211
  if($('trail').dataset.key!==trailKey){$('trail').dataset.key=trailKey;$('trail').replaceChildren(...trail.map((n,i)=>{const c=document.createElement('span');c.className='chip';c.textContent=toolLabel(n);c.style.animationDelay=`${i*40}ms`;return c;}));}
170
212
  $('trail').hidden=!trail.length;
171
- const meta=[d.model,d.contextTokens?`上下文 ${tokens(d.contextTokens)}`:'',d.cacheRate!=null?`缓存命中 ${d.cacheRate}%`:'',d.toolCount?`工具 ${d.toolCount} 次`:'',d.outputTokens?`输出 ${tokens(d.outputTokens)}`:''].filter(Boolean);
213
+ const pct=contextOf(d);
214
+ const meta=[d.model,d.contextTokens?(pct!=null?`上下文 ${tokens(d.contextTokens)} / ${tokens(d.contextWindow)} (${pct}%)`:`上下文 ${tokens(d.contextTokens)}`):'',d.cacheRate!=null?`缓存命中 ${d.cacheRate}%`:'',d.toolCount?`工具 ${d.toolCount} 次`:'',d.outputTokens?`输出 ${tokens(d.outputTokens)}`:''].filter(Boolean);
172
215
  $('meta').replaceChildren(...meta.map(t=>{const s=document.createElement('span');s.textContent=t;return s;}));
173
216
  $('activity-note').textContent=preview?'预览模式 · 示例数据,不代表实际任务':'';
174
217
 
@@ -196,14 +239,21 @@ function renderPanel({activity:a,prefs={},history={},balance,preview}){
196
239
  const day=history.day;
197
240
  $('usage').hidden=!idle||!day||!day.tokens;
198
241
  if(day&&day.tokens){
199
- $('usage-sum').textContent=`${tokens(day.tokens)} tokens${day.cacheRate!=null?` · 缓存命中 ${day.cacheRate}%`:''}`;
242
+ const cost=history.today?.cost;
243
+ $('usage-sum').textContent=`${tokens(day.tokens)} tokens${day.cacheRate!=null?` · 缓存命中 ${day.cacheRate}%`:''}${cost>=0.005?` · 今日≈¥${cost.toFixed(2)}`:''}`;
200
244
  const max=Math.max(...day.hours,1),key=day.hours.join(',');
201
245
  if($('usage-bars').dataset.key!==key){$('usage-bars').dataset.key=key;$('usage-bars').replaceChildren(...day.hours.map((v,i)=>{const b=document.createElement('i');if(!v)b.className='zero';b.style.height=v?`${Math.max(8,Math.round(v/max*100))}%`:'';b.style.animationDelay=`${i*18}ms`;b.title=`${tokens(v)} tokens`;return b;}));}
202
246
  }
203
247
  $('recent').hidden=!(idle||['completed','paused','error'].includes(state))||!recent.length;
204
248
  $('hint').hidden=!idle||preview;
205
249
  $('stop').hidden=!a.stoppable;if(!a.stoppable)disarm();
206
- if(renderAsk())for(const id of ['task','others','today','usage','recent','hint'])$(id).hidden=true;
250
+ const reply=details&&state==='completed'?d.reply:'';
251
+ $('reply').hidden=!reply;$('reply-text').textContent=reply||'';
252
+ if(reply)$('recent').hidden=true;
253
+ const approving=renderApprove();
254
+ if(approving)$('ask').hidden=true;
255
+ const asking=!approving&&renderAsk();
256
+ if(approving||asking)for(const id of ['task','others','today','usage','recent','hint','reply'])$(id).hidden=true;
207
257
  }
208
258
 
209
259
  // 状态切换:文字与指示器做模糊交叉淡入,异常时整体轻摇。
@@ -225,22 +275,34 @@ function render(snapshot){
225
275
 
226
276
  $('status-label').textContent=copy.label;
227
277
  const details=snapshot.prefs?.details!==false;
228
- const ask=askOf();
278
+ const ask=askOf(),approval=approvalOf(),reply=details&&state==='completed'?a.detail?.reply:'';
229
279
  if(ask&&state==='waiting')$('status-label').textContent='有问题等你回答';
230
- $('mini-note').textContent=ask&&state==='waiting'&&!expanded?ask.questions[0].question:state==='idle'?(preview?'动画预览':'DeepSeek Harness'):details&&a.title?a.title:copy.detail;
280
+ if(approval)$('status-label').textContent='需要你批准';
281
+ else if(reply)$('status-label').textContent='有新回复';
282
+ $('mini-note').textContent=approval&&!expanded?(details&&approval.detail?.text?approval.detail.text:toolLabel(approval.toolName))
283
+ :ask&&state==='waiting'&&!expanded?ask.questions[0].question
284
+ :reply&&!expanded?reply
285
+ :state==='idle'?(preview?'动画预览':'DeepSeek Harness'):details&&a.title?a.title:copy.detail;
286
+ island.dataset.alert=approval?'approval':reply?'reply':'';
287
+ renderRing(state,a.detail);
231
288
  $('badge').hidden=!(a.activeCount>1);$('badge').textContent=a.activeCount>1?String(a.activeCount):'';
232
289
  $('demo-controls').hidden=!preview;$('demo-state').value=state;
233
290
  renderPanel(snapshot);
234
291
 
235
292
  for(const id of drafts.keys())if(!(a.questions||[]).some(q=>q.callId===id))drafts.delete(id);
236
- if(ask&&!seenAsk.has(ask.callId)){seenAsk.add(ask.callId);if(snapshot.prefs?.popups!==false&&!expanded){autoOpened=true;expand(true);}}
237
- if(!ask&&autoOpened){autoOpened=false;if(expanded)expand(false);}
293
+ for(const id of [...seenApproval.keys()])if(!(a.approvals||[]).some(x=>x.id===id)){seenApproval.delete(id);sentApproval.delete(id);}
294
+ const prompt=approval?'approval:'+approval.id:ask?'ask:'+ask.callId:'';
295
+ if(prompt&&!seenAsk.has(prompt)){seenAsk.add(prompt);if(snapshot.prefs?.popups!==false&&!expanded){autoOpened=true;expand(true);}}
296
+ if(!prompt&&autoOpened){autoOpened=false;if(expanded)expand(false);}
297
+ // 新回复:完成时在胶囊里预览回复开头,并多停留一会儿。
298
+ if(reply&&reply!==lastReply&&lastState===state&&!expanded&&snapshot.prefs?.popups!==false)burst(4200);
299
+ lastReply=reply||'';
238
300
  if(lastState!==state){
239
301
  const prev=lastState;lastState=state;
240
302
  $('announcement').textContent=copy.label;
241
303
  choreograph(state,prev);
242
304
  const fresh=state!=='thinking'||QUIET.includes(prev);
243
- if(BURST[state]&&fresh&&prev&&!expanded&&snapshot.prefs?.popups!==false)burst(BURST[state]);
305
+ if(BURST[state]&&fresh&&prev&&!expanded&&snapshot.prefs?.popups!==false)burst(reply?4200:BURST[state]);
244
306
  }
245
307
  // 切到减少动效时,正在进行的过渡直接落到终点。
246
308
  if(!motionOn())document.getAnimations().forEach(x=>{if(x instanceof CSSAnimation)return;try{x.finish();}catch{x.cancel();}});
@@ -250,4 +312,4 @@ function render(snapshot){
250
312
  const unsubscribe=bridge.subscribe(render);
251
313
  render(await bridge.initial());
252
314
  const timer=setInterval(tick,1000);
253
- window.addEventListener('beforeunload',()=>{unsubscribe();clearInterval(timer);cancelAnimationFrame(boundsFrame);clearTimeout(burstTimer);clearTimeout(peekTimer);clearTimeout(leaveTimer);clearTimeout(armTimer);});
315
+ window.addEventListener('beforeunload',()=>{unsubscribe();clearInterval(timer);cancelAnimationFrame(boundsFrame);clearTimeout(burstTimer);clearTimeout(peekTimer);clearTimeout(leaveTimer);clearTimeout(armTimer);clearTimeout(hoverTimer);});
@@ -0,0 +1,5 @@
1
+ const {contextBridge,ipcRenderer}=require('electron');
2
+ contextBridge.exposeInMainWorld('usageStats',{
3
+ request:range=>ipcRenderer.send('stats:request',range),
4
+ subscribe:callback=>{const fn=(_event,data)=>callback(data);ipcRenderer.on('stats:data',fn);return()=>ipcRenderer.removeListener('stats:data',fn);}
5
+ });
@@ -0,0 +1,86 @@
1
+ :root{
2
+ color-scheme:dark;
3
+ --bg:#0b0e17;--surface:#0d111c;--card:#121726;--line:rgba(255,255,255,.07);
4
+ --ink:#eef1fb;--muted:#8d95ad;--faint:#5c647c;--blue:#4d6bfe;--blue-hi:#7f95ff;
5
+ --s-cache:#3987e5;--s-input:#d95926;--s-output:#199e70;
6
+ font-family:"HarmonyOS Sans SC","MiSans","Microsoft YaHei UI","PingFang SC",system-ui,sans-serif;
7
+ }
8
+ *{box-sizing:border-box}
9
+ html,body{margin:0;background:var(--bg);color:var(--ink)}
10
+ body{min-height:100vh;padding:22px 26px 28px;font-size:13px}
11
+ .num{font-variant-numeric:tabular-nums}
12
+ button{font:inherit;color:inherit;border:0;background:none;cursor:pointer}
13
+ a{color:var(--blue-hi)}
14
+ .top{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:18px;flex-wrap:wrap}
15
+ .brand{display:flex;align-items:center;gap:12px}
16
+ .mark{width:34px;height:34px;border-radius:11px;background:var(--blue) url("../assets/deepseek.svg") center/22px no-repeat;box-shadow:0 6px 22px rgba(77,107,254,.35)}
17
+ h1{margin:0;font-size:18px;font-weight:650;letter-spacing:.3px}
18
+ h2{margin:0;font-size:13px;font-weight:600}
19
+ .sub{margin:2px 0 0;color:var(--muted);font-size:11.5px}
20
+ .sub .tag{margin-left:6px;padding:1px 7px;border-radius:999px;background:rgba(245,181,68,.14);color:#f5c46a;font-size:10.5px}
21
+ .tabs{display:flex;padding:3px;border-radius:12px;background:var(--card);border:1px solid var(--line)}
22
+ .tabs button{height:30px;padding:0 14px;border-radius:9px;color:var(--muted);font-size:12.5px;transition:background-color .2s,color .2s}
23
+ .tabs button:hover{color:var(--ink)}
24
+ .tabs button[aria-selected="true"]{background:var(--blue);color:#fff;box-shadow:0 4px 14px rgba(77,107,254,.35)}
25
+ .tabs button:focus-visible{outline:2px solid var(--blue-hi);outline-offset:2px}
26
+ main{transition:opacity .25s}
27
+ main[aria-busy="true"]{opacity:.55}
28
+ .kpis{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:10px;margin-bottom:12px}
29
+ .kpi{display:flex;flex-direction:column;gap:3px;padding:13px 14px;border-radius:14px;background:var(--card);border:1px solid var(--line);min-width:0}
30
+ .kpi .k{font-size:11px;color:var(--muted)}
31
+ .kpi strong{font-size:21px;font-weight:650;letter-spacing:.2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
32
+ .kpi .s{font-size:10.5px;color:var(--faint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
33
+ .card{position:relative;padding:14px 16px;border-radius:16px;background:var(--card);border:1px solid var(--line);min-width:0}
34
+ .card-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px;flex-wrap:wrap}
35
+ .hint{font-size:10.5px;color:var(--faint)}
36
+ .legend{display:flex;gap:14px;font-size:11px;color:var(--muted)}
37
+ .legend span{display:flex;align-items:center;gap:6px}
38
+ .legend i{width:10px;height:10px;border-radius:3px;background:var(--c)}
39
+ .chart{height:230px;background:var(--surface);border-radius:12px;padding:6px 4px 0}
40
+ .chart svg{display:block;width:100%;height:100%;overflow:visible}
41
+ .chart .grid{stroke:rgba(255,255,255,.06);stroke-width:1}
42
+ .chart .axis{fill:var(--faint);font-size:10px}
43
+ .chart .hit{fill:transparent}
44
+ .chart .seg{transition:opacity .15s}
45
+ .chart .col.dim .seg{opacity:.35}
46
+ .chart.still .seg{animation:none}
47
+ .chart .seg{transform-box:fill-box;transform-origin:50% 100%;animation:grow .6s cubic-bezier(.22,1,.36,1) backwards}
48
+ @keyframes grow{from{transform:scaleY(0)}}
49
+ .tip{position:absolute;z-index:3;pointer-events:none;min-width:168px;padding:9px 11px;border-radius:11px;background:rgba(9,12,22,.96);border:1px solid rgba(255,255,255,.1);box-shadow:0 10px 30px rgba(0,0,0,.45);font-size:11.5px;line-height:1.6}
50
+ .tip b{display:block;margin-bottom:3px;font-size:12px}
51
+ .tip .r{display:flex;justify-content:space-between;gap:14px;color:var(--muted)}
52
+ .tip .r span:first-child{display:flex;align-items:center;gap:6px}
53
+ .tip .r i{width:8px;height:8px;border-radius:2px;background:var(--c)}
54
+ .tip .r span:last-child{color:var(--ink);font-variant-numeric:tabular-nums}
55
+ .cols{display:grid;grid-template-columns:minmax(0,4fr) minmax(0,8fr);gap:12px;margin-top:12px;align-items:start}
56
+ table{width:100%;border-collapse:collapse}
57
+ th{font-size:10.5px;font-weight:500;color:var(--faint);text-align:left;padding:0 6px 7px}
58
+ td{padding:8px 6px;border-top:1px solid var(--line);font-size:12px;font-variant-numeric:tabular-nums}
59
+ .r{text-align:right}
60
+ .models .name{display:flex;flex-direction:column;gap:5px}
61
+ .models .share{height:4px;border-radius:2px;background:rgba(255,255,255,.06);overflow:hidden}
62
+ .models .share i{display:block;height:100%;border-radius:2px;background:linear-gradient(90deg,var(--blue),var(--blue-hi))}
63
+ .models .unpriced{font-size:10px;color:var(--faint)}
64
+ .sessions{display:flex;flex-direction:column;max-height:340px;overflow:auto;margin:0 -6px;padding:0 6px}
65
+ .sessions details{border-top:1px solid var(--line)}
66
+ .sessions details:first-child{border-top:0}
67
+ .sessions summary{display:grid;grid-template-columns:minmax(0,1fr) auto auto;gap:12px;align-items:center;padding:9px 2px;cursor:pointer;list-style:none;border-radius:8px}
68
+ .sessions summary::-webkit-details-marker{display:none}
69
+ .sessions summary:hover{background:rgba(255,255,255,.03)}
70
+ .sessions summary:focus-visible{outline:2px solid var(--blue-hi)}
71
+ .sessions .t{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12.5px}
72
+ .sessions .t::before{content:"›";display:inline-block;width:12px;color:var(--faint);transition:transform .2s}
73
+ .sessions details[open] .t::before{transform:rotate(90deg)}
74
+ .sessions .m{font-size:11px;color:var(--muted);font-variant-numeric:tabular-nums;white-space:nowrap}
75
+ .turns{margin:0 0 10px 12px;width:calc(100% - 12px)}
76
+ .turns td{font-size:11px;padding:6px 5px;color:#cfd5e6;white-space:nowrap}
77
+ .turns th{white-space:nowrap}
78
+ .turns th{padding:4px 5px}
79
+ .state{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:5px;background:#3ddc91}
80
+ .state.paused{background:#9aa3b8}.state.error{background:#ff6b6b}
81
+ .empty{margin:14px 0 0;text-align:center;color:var(--muted)}
82
+ .note{margin-top:14px;color:var(--faint);font-size:10.5px;line-height:1.7}
83
+ @media (max-width:760px){.kpis{grid-template-columns:repeat(2,minmax(0,1fr))}.cols{grid-template-columns:1fr}}
84
+ @media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
85
+ .models th,.models td{white-space:nowrap}
86
+ .models .name span{overflow:hidden;text-overflow:ellipsis}