dsh-island 0.5.0 → 0.6.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
@@ -47,7 +47,7 @@ DeepSeek Harness 的桌面灵动岛插件:像 iPhone 灵动岛一样停在屏
47
47
  ## 使用
48
48
 
49
49
  - 鼠标在灵动岛上停留片刻即展开任务控制台,移开后自动收起;悬停展开后再单击可固定,不随鼠标离开收起。可在设置中关闭悬停展开。
50
- - 单击胶囊展开或收起,按 Esc 收起;单击展开的面板在鼠标离开 6 秒后自动收起。
50
+ - 单击胶囊展开或收起,按 Esc 或点击灵动岛以外的任何地方收起;单击展开的面板在鼠标离开 6 秒后也会自动收起。提问卡里没提交的草稿会保留,重新展开可以接着答。
51
51
  - “打开 Harness”回到主窗口;面板底部的柱状图按钮或托盘菜单「用量统计」打开用量窗口。
52
52
  - “隐藏”只隐藏灵动岛,点系统托盘的蓝色鲸鱼可重新显示;右键托盘可选显示器或退出。
53
53
  - 透明区域不拦截鼠标;窗口置顶但出现时不抢输入焦点。
@@ -64,6 +64,15 @@ DeepSeek Harness 的桌面灵动岛插件:像 iPhone 灵动岛一样停在屏
64
64
 
65
65
  账户余额通过 Harness 自己的账户服务读取(与「设置 › 账户」相同),只在用 DeepSeek 账号登录时可用;灵动岛看不到任何令牌或 API Key。打开面板时最多每分钟刷新一次,另外每 10 分钟刷新一次。
66
66
 
67
+ ## 在灵动岛给 Agent 发消息
68
+
69
+ 展开面板顶部是对话卡:上面是这个任务最近一次的回复(最多 4000 字,可滚动、可复制),下面是输入框,**不用打开 Harness 就能接着聊**。Enter 发送,Shift+Enter 换行,中文输入法选字时按 Enter 不会误发。
70
+
71
+ - 发给谁:正在显示的任务;空闲时发给最近一个任务。子智能体不会成为对象。
72
+ - 空闲时点 **发送** 开始新一轮;任务进行中点 **排队**(这一轮结束后发送),或点 **插话**(插入正在进行的这一轮,Agent 下一步就会看到)。
73
+ - 走的是 Harness 自己输入框用的同一个接口(会话控制器的 `prompt`),消息会出现在 Harness 的对话记录里;已关闭的任务会被自动恢复。没有选好模型等原因发送失败时,输入框里的内容保留,可以直接重试。
74
+ - 暂不支持新建对话和发送图片、文件,这些仍在 Harness 中操作。可在设置中关闭「在灵动岛给 Agent 发消息」。
75
+
67
76
  ## 在灵动岛回答提问
68
77
 
69
78
  Agent 用 `ask_user_question` 提问时,灵动岛自动展开提问卡:单选、多选、自己输入、跳过都可以,多个问题逐个回答后一次提交。Harness 窗口里的提问卡照常可用,**哪边先答用哪边**;灵动岛先答时,Harness 里的那张卡会随之关闭。没有打开 Harness 窗口时,灵动岛就是回答入口。问题超时后转为“继续”状态,仍可在灵动岛回答,回答作为一条回复送回 Agent,与 Harness 的处理方式相同。
@@ -107,6 +116,7 @@ Agent 要执行需要授权的操作(运行命令、修改文件、访问网
107
116
  | 显示任务内容 | 关闭后不显示任务标题、计划内容、工具名、审批详情和回复预览,只显示状态 |
108
117
  | 在灵动岛回答提问 | Agent 提问时自动展开提问卡,可直接回答 |
109
118
  | 在灵动岛批准权限请求 | 需要授权时显示审批卡,可允许一次或拒绝 |
119
+ | 在灵动岛给 Agent 发消息 | 面板里显示输入框,接着当前或最近的任务对话 |
110
120
  | 悬停展开面板 | 鼠标停留即展开,移开收起;关闭后需要单击 |
111
121
  | 显示账户余额 | 空闲时显示 DeepSeek 账户余额 |
112
122
  | 减少动态效果 | 关闭循环动画和弹性过渡(也跟随系统设置) |
@@ -161,7 +171,7 @@ npm run preview
161
171
 
162
172
  ## 开发与验证
163
173
 
164
- 代码无需打包器和运行时 npm 依赖。`src/host.js` 使用 Cordis 生命周期及 `session/event`、`agent/assistant-stream`、`agent/status`、`agent/error`,通过父子进程 IPC 向隔离的 Electron 页面发送状态;`src/settings.js` 用 Harness 自带的 schemastery 声明 volatile 配置,`loader/volatile-update` 时把新设置转发给窗口;`web/client.js` 是 Harness 设置页的浏览器半侧(通过 `dsh.client` 声明加载)。传给灵动岛的只有状态、任务标题、Agent 计划条目、工具名、模型名、token 数、账户余额,以及等你回答的问题和选项;不会读取密钥,也不传递对话、回复、思考正文或工具参数。没有新增 HTTP 监听端口。加载时接管正在运行任务的概括状态,之后使用实时事件细分;不重放历史对话。
174
+ 代码无需打包器和运行时 npm 依赖。`src/host.js` 使用 Cordis 生命周期及 `session/event`、`agent/assistant-stream`、`agent/status`、`agent/error`,通过父子进程 IPC 向隔离的 Electron 页面发送状态;`src/settings.js` 用 Harness 自带的 schemastery 声明 volatile 配置,`loader/volatile-update` 时把新设置转发给窗口;`web/client.js` 是 Harness 设置页的浏览器半侧(通过 `dsh.client` 声明加载)。传给灵动岛的只有状态、任务标题、Agent 计划条目、工具名、模型名、token 数、账户余额、等你回答的问题和选项,以及已完成一轮的最终回复(关闭「显示任务内容」后不传);不会读取密钥,也不传递你的提问、思考正文、进行中的文本或工具参数。你在灵动岛输入的消息经 IPC 交给 Harness 的会话控制器,灵动岛本身不保存。没有新增 HTTP 监听端口。加载时接管正在运行任务的概括状态,之后使用实时事件细分;不重放历史对话。
165
175
 
166
176
  ```powershell
167
177
  npm test
@@ -53,6 +53,12 @@
53
53
  <input id="ask-custom" class="ask-custom" type="text" maxlength="2000" placeholder="或者输入你自己的回答…" aria-label="自定义回答">
54
54
  <div class="ask-actions"><span id="ask-status" class="ask-status" role="status"></span><button id="ask-skip" type="button" class="ask-skip">跳过</button><button id="ask-next" type="submit" class="ask-next">提交</button></div>
55
55
  </form>
56
+ <form id="chat" class="card chat" hidden autocomplete="off">
57
+ <div class="chat-top"><span class="chat-dot" aria-hidden="true"></span><span id="chat-title">继续对话</span><button id="chat-open" type="button" class="chat-link">在 Harness 中查看 ↗</button></div>
58
+ <div id="chat-reply" class="chat-reply" tabindex="0" aria-label="最近的回复" hidden></div>
59
+ <textarea id="chat-input" class="chat-input" rows="2" maxlength="8000" placeholder="给 Agent 发消息… Enter 发送,Shift+Enter 换行" aria-label="给 Agent 发消息"></textarea>
60
+ <div class="ask-actions"><span id="chat-status" class="ask-status" role="status"></span><button id="chat-steer" type="button" class="ask-skip" title="插入正在进行的这一轮,Agent 下一步就会看到" hidden>插话</button><button id="chat-send" type="submit" class="ask-next chat-send">发送</button></div>
61
+ </form>
56
62
  <div id="task" class="card task">
57
63
  <div class="task-top"><span id="session-title">DeepSeek Harness</span><time id="elapsed"></time></div>
58
64
  <p id="status-detail">开始一个任务,让想法继续。</p>
@@ -300,6 +300,21 @@ p{margin:3px 0 0;font-size:11.5px;line-height:1.5;color:var(--muted)}
300
300
  .reply-head{display:flex;align-items:center;gap:7px;font-size:11px;color:#9ff0c6}
301
301
  .reply-hint{margin-left:auto;font-size:10px;color:var(--faint)}
302
302
  .reply-dot{width:7px;height:7px;border-radius:50%;background:#3ddc91}
303
+ .chat{display:flex;flex-direction:column;gap:8px;margin:0;border-color:color-mix(in srgb,var(--ds-blue) 32%,transparent);background:linear-gradient(180deg,color-mix(in srgb,var(--ds-blue) 9%,transparent),rgba(255,255,255,.03))}
304
+ .chat-top{display:flex;align-items:center;gap:8px;font-size:11px;color:#b9c6ff}
305
+ .chat-top #chat-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
306
+ .chat-dot{flex:none;width:7px;height:7px;border-radius:50%;background:var(--ds-blue)}
307
+ .chat-link{flex:none;height:20px;padding:0 4px;border-radius:6px;font-size:10px;color:var(--faint)}
308
+ .chat-link:hover{background:rgba(255,255,255,.06);color:#e5e9f7}
309
+ .chat-reply{margin:0;font-size:12px;line-height:1.55;color:#e8ecf8;white-space:pre-wrap;word-break:break-word;max-height:calc(1.55em * 5);overflow:auto;padding-right:4px;user-select:text;outline:none}
310
+ .chat-reply.live{color:var(--muted)}
311
+ .chat-reply::-webkit-scrollbar{width:4px}.chat-reply::-webkit-scrollbar-thumb{border-radius:2px;background:rgba(255,255,255,.18)}
312
+ .chat-input{box-sizing:border-box;display:block;width:100%;min-height:50px;max-height:96px;resize:none;padding:7px 10px;border-radius:10px;border:1px solid rgba(255,255,255,.1);background:rgba(0,0,0,.35);color:#f3f5ff;font:inherit;font-size:12px;line-height:1.5;outline:none;transition:border-color .2s,box-shadow .2s}
313
+ .chat-input::placeholder{color:#5f6780}
314
+ .chat-input:focus{border-color:color-mix(in srgb,var(--ds-blue) 70%,transparent);box-shadow:0 0 0 3px color-mix(in srgb,var(--ds-blue) 22%,transparent)}
315
+ .chat-input:disabled{opacity:.6}
316
+ .ask-next.chat-send{background:var(--ds-blue);color:#fff}
317
+ .ask-next.chat-send:hover{background:var(--ds-blue-hi)}
303
318
  #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
319
  .ghost{height:30px;padding:0 10px;border-radius:10px;font-size:11.5px;color:var(--muted)}
305
320
  .ghost:hover{background:rgba(255,255,255,.06);color:#e5e9f7}
package/desktop/main.cjs CHANGED
@@ -11,7 +11,7 @@ const single = app.requestSingleInstanceLock();
11
11
  if(!single) app.quit();
12
12
  else start().catch(error=>{ console.error(error); app.exit(1); });
13
13
 
14
- const WIDTH = 440, HEIGHT = 460;
14
+ const WIDTH = 440, HEIGHT = 540;
15
15
  const SIZES = { small: 0.85, medium: 1, large: 1.15 };
16
16
  const DAY = 24 * 3600 * 1000;
17
17
 
@@ -27,12 +27,12 @@ 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,approvals:true,hoverExpand:true};
30
+ let prefs={enabled:true,popups:true,details:true,balance:true,questions:true,approvals:true,chat:true,hoverExpand:true};
31
31
  let balance=null;
32
- let answerResults={},approvalResults={};
32
+ let answerResults={},approvalResults={},chatResults={};
33
33
  let statsWin=null,statsRange='today';
34
34
  let history=Array.isArray(read('history.json',[]))?read('history.json',[]):[];
35
- let win, tray, ignore=true, hitbox=null, quitting=false, activity={state:'idle',activeCount:0}, demoTimer;
35
+ let win, tray, ignore=true, hitbox=null, quitting=false, menuOpen=false, activity={state:'idle',activeCount:0}, demoTimer;
36
36
  const save=()=>write('settings.json',settings);
37
37
  const visible=()=>settings.enabled&&prefs.enabled;
38
38
 
@@ -64,7 +64,7 @@ async function start() {
64
64
  };
65
65
  };
66
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}`}))});
67
+ const payload=()=>({activity,settings,prefs,answerResults,approvalResults,chatResults,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}`}))});
68
68
  const send=()=>{ if(win && !win.isDestroyed())win.webContents.send('island:snapshot',payload()); };
69
69
  const position=()=>{
70
70
  const display=screen.getAllDisplays().find(d=>d.id===settings.displayId)||screen.getPrimaryDisplay();
@@ -78,7 +78,7 @@ async function start() {
78
78
  const update=value=>{settings=normalizeSettings({...settings,...value});save();sync();};
79
79
  const applyPrefs=value=>{
80
80
  if(!value||typeof value!=='object')return;
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};
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,chat:value.chat!==false,hoverExpand:value.hoverExpand!==false};
82
82
  settings=normalizeSettings({...settings,position:value.position,scale:SIZES[value.size]??1,motion:value.reduceMotion?'reduced':'full'});
83
83
  save();sync();
84
84
  };
@@ -128,7 +128,13 @@ async function start() {
128
128
  if(!trusted(event)||!rect||!['x','y','width','height'].every(k=>Number.isFinite(rect[k])))return;
129
129
  hitbox={x:Math.max(0,rect.x),y:Math.max(0,rect.y),width:Math.min(WIDTH,Math.max(0,rect.width)),height:Math.min(HEIGHT,Math.max(0,rect.height)),radius:Number.isFinite(rect.radius)?Math.max(0,rect.radius):0};
130
130
  });
131
- ipcMain.on('island:menu',event=>{if(trusted(event))menu().popup({window:win});});
131
+ // 点击灵动岛以外的地方:窗口失去焦点,通知页面收起面板。弹出菜单期间不算,菜单关闭后再判断。
132
+ const dismiss=()=>{if(win&&!win.isDestroyed())win.webContents.send('island:blur');};
133
+ const blurred=()=>{if(!menuOpen)dismiss();};
134
+ const popupMenu=()=>{menuOpen=true;menu().popup({window:win,callback:()=>{menuOpen=false;setTimeout(()=>{if(!win.isDestroyed()&&!win.isFocused())dismiss();},60);}});};
135
+ ipcMain.on('island:menu',event=>{if(trusted(event))popupMenu();});
136
+ // 用户在灵动岛上按下鼠标时确保窗口拿到焦点,之后点别处才会触发失焦;悬停和自动弹出不抢焦点。
137
+ ipcMain.on('island:engage',event=>{if(trusted(event)&&win.isVisible()&&!win.isFocused())win.focus();});
132
138
  ipcMain.on('island:open',event=>{if(trusted(event))openHarness();});
133
139
  ipcMain.on('island:hide',event=>{if(trusted(event))hide();});
134
140
  ipcMain.on('island:answer',(event,callId,answers)=>{
@@ -138,6 +144,14 @@ async function start() {
138
144
  if(demo){answerResults={[callId]:{ok:true,at:Date.now()}};send();setTimeout(()=>setDemoState('thinking'),700);return;}
139
145
  if(process.send)process.send({type:'answer',callId,answers:clean});
140
146
  });
147
+ // 在灵动岛里发消息:只发给 Harness 告诉我们的那个对话,内容交给 Harness 的会话控制器处理。
148
+ ipcMain.on('island:chat',(event,id,sessionId,text,mode)=>{
149
+ if(!trusted(event)||typeof id!=='string'||id.length>80||typeof sessionId!=='string'||typeof text!=='string'||!text.trim()||text.length>8000)return;
150
+ if(!prefs.chat||activity.chat?.sessionId!==sessionId)return;
151
+ const how=mode==='steer'?'steer':'queue';
152
+ if(demo){chatResults={[id]:{ok:true,mode:how,at:Date.now()}};send();setTimeout(()=>setDemoState('thinking'),600);return;}
153
+ if(process.send)process.send({type:'chat',id,sessionId,text,mode:how});
154
+ });
141
155
  ipcMain.on('island:panel',event=>{if(trusted(event)&&process.send)process.send({type:'panel-open'});});
142
156
  ipcMain.on('island:stats',event=>{if(trusted(event))openStats();});
143
157
  ipcMain.on('stats:request',(event,range)=>{if(statsWin&&event.sender===statsWin.webContents&&event.senderFrame===statsWin.webContents.mainFrame)requestStats(range);});
@@ -168,6 +182,7 @@ async function start() {
168
182
  {id:'framework',header:'测试框架',question:'登录模块的单元测试用哪个框架?',multiSelect:false,options:[{label:'Vitest',description:'和现有构建工具一致,速度快'},{label:'Jest',description:'生态成熟,迁移成本略高'},{label:'node:test',description:'零依赖'}]},
169
183
  {id:'cases',header:'覆盖场景',question:'需要覆盖哪些失败场景?',multiSelect:true,options:[{label:'密码错误'},{label:'账号锁定'},{label:'验证码过期'}]}]}]:[],
170
184
  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}}]:[],
185
+ chat:{sessionId:'preview',title:'重构登录模块并补齐测试',running:busy,state,reply:busy?'':'登录模块已拆成 3 个文件,新增 14 个单元测试全部通过。\n\n验证码过期场景需要后端配合,我在 TODO 里留了说明。要我顺便把注册流程也按同样的方式拆一下吗?'},
171
186
  finished:[]};
172
187
  if(demo&&!history.length){
173
188
  const sample=[['修复首页加载白屏','completed',22,312,18],['写一份竞品对比表','completed',17,146,9],['整理接口文档','completed',9,205,14],['升级依赖并跑测试','error',3,88,6],['生成周报图表','completed',1.5,131,11]];
@@ -200,6 +215,7 @@ async function start() {
200
215
  if(message?.type==='prefs')applyPrefs(message.data);
201
216
  if(message?.type==='show')show();
202
217
  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();}
218
+ if(message?.type==='chat-result'&&typeof message.id==='string'){chatResults={...Object.fromEntries(Object.entries(chatResults).filter(([,r])=>Date.now()-r.at<300000)),[message.id]:{ok:!!message.ok,mode:message.mode,reason:String(message.reason||'').slice(0,80),at:Date.now()}};send();}
203
219
  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
220
  if(message?.type==='stats'&&statsWin&&!statsWin.isDestroyed())statsWin.webContents.send('stats:data',message.data??null);
205
221
  if(message?.type==='balance'){balance=message.data&&Array.isArray(message.data.wallets)?message.data:null;send();}
@@ -224,6 +240,7 @@ async function start() {
224
240
  },40);
225
241
  screen.on('display-metrics-changed',position); screen.on('display-removed',position);screen.on('display-added',position);
226
242
  app.on('second-instance',show);
243
+ win.on('blur',blurred);
227
244
  win.on('close',event=>{if(!quitting){event.preventDefault();hide();}});
228
245
  app.on('before-quit',()=>{quitting=true;statsWin?.destroy();clearInterval(cursorTimer);clearInterval(demoTimer);tray?.destroy();});
229
246
  if(process.send) process.send({type:'ready'});
@@ -2,6 +2,8 @@ const {contextBridge,ipcRenderer}=require('electron');
2
2
  contextBridge.exposeInMainWorld('island',{
3
3
  initial:()=>ipcRenderer.invoke('island:initial'),
4
4
  subscribe:callback=>{const fn=(_event,data)=>callback(data);ipcRenderer.on('island:snapshot',fn);return()=>ipcRenderer.removeListener('island:snapshot',fn);},
5
+ onBlur:callback=>{const fn=()=>callback();ipcRenderer.on('island:blur',fn);return()=>ipcRenderer.removeListener('island:blur',fn);},
6
+ engage:()=>ipcRenderer.send('island:engage'),
5
7
  settings:value=>ipcRenderer.send('island:settings',value),
6
8
  bounds:value=>ipcRenderer.send('island:bounds',value),
7
9
  menu:()=>ipcRenderer.send('island:menu'),
@@ -11,6 +13,7 @@ contextBridge.exposeInMainWorld('island',{
11
13
  panel:()=>ipcRenderer.send('island:panel'),
12
14
  answer:(callId,answers)=>ipcRenderer.send('island:answer',callId,answers),
13
15
  approve:(id,outcome)=>ipcRenderer.send('island:approve',id,outcome),
16
+ chat:(id,sessionId,text,mode)=>ipcRenderer.send('island:chat',id,sessionId,text,mode),
14
17
  stats:()=>ipcRenderer.send('island:stats'),
15
18
  preview:state=>ipcRenderer.send('island:preview',state)
16
19
  });
@@ -58,7 +58,7 @@ function burst(ms){
58
58
  }
59
59
 
60
60
  // 悬停:轻停片刻先舒展;停留更久(可在设置里关闭)直接展开面板,离开后收回。
61
- const busyHere=()=>!!(askOf()||approvalOf()||document.activeElement?.matches('select,input'));
61
+ const busyHere=()=>!!(askOf()||approvalOf()||document.activeElement?.matches('select,input,textarea'));
62
62
  island.addEventListener('mouseenter',()=>{
63
63
  clearTimeout(leaveTimer);clearTimeout(peekTimer);clearTimeout(hoverTimer);
64
64
  if(expanded)return;
@@ -73,6 +73,14 @@ island.addEventListener('mouseleave',()=>{
73
73
 
74
74
  $('capsule').addEventListener('click',()=>{autoOpened=false;const pinned=hoverOpened;hoverOpened=false;clearTimeout(hoverTimer);if(pinned&&expanded){applyShape();return;}expand(!expanded);});
75
75
  document.addEventListener('keydown',e=>{if(e.key==='Escape'&&expanded)expand(false);});
76
+ // 点击灵动岛以外的地方即收起:按下时让窗口拿到焦点,失焦时收起(提问草稿保留,重新展开可继续)。
77
+ island.addEventListener('pointerdown',()=>bridge.engage?.(),true);
78
+ function dismiss(){
79
+ clearTimeout(leaveTimer);clearTimeout(hoverTimer);clearTimeout(peekTimer);
80
+ if(peek){peek=false;applyShape();}
81
+ if(expanded){autoOpened=false;expand(false);}
82
+ }
83
+ const offBlur=bridge.onBlur?.(dismiss);
76
84
  document.addEventListener('contextmenu',e=>{e.preventDefault();bridge.menu();});
77
85
  // 停止任务需要点两次:第一次变红确认,3 秒内再点才真正停止。
78
86
  function disarm(){clearTimeout(armTimer);$('stop').dataset.armed='false';$('stop-label').textContent='停止任务';}
@@ -186,6 +194,51 @@ function decide(outcome){const a=approvalOf();if(!a||sentApproval.has(a.id)||$('
186
194
  $('approve').addEventListener('submit',e=>{e.preventDefault();decide('allowed-once');});
187
195
  $('approve-reject').addEventListener('click',()=>decide('rejected'));
188
196
 
197
+ // 继续对话:给当前(或最近一个)任务发消息,和在 Harness 输入框里发一样;任务进行中可以排队或插话。
198
+ let chatSent=null,chatStatusTimer;
199
+ function chatOf(){return current.prefs?.chat!==false?current.activity?.chat:null;}
200
+ function renderChat(){
201
+ const c=chatOf();$('chat').hidden=!c;
202
+ if(!c)return false;
203
+ const details=current.prefs?.details!==false,running=!!c.running;
204
+ const onShow=current.activity?.sessionId===c.sessionId&&!['idle','offline'].includes(current.activity?.state);
205
+ $('chat-title').textContent=onShow?'给这个任务发消息':details&&c.title?c.title:'继续对话';
206
+ const text=running?`${STATES[c.state]?.label??'正在处理'}…`:details?(c.reply||''):'';
207
+ const box=$('chat-reply');box.classList.toggle('live',running);box.hidden=!text;
208
+ if(box.textContent!==text){box.textContent=text;box.scrollTop=0;}
209
+ $('chat-steer').hidden=!running;
210
+ $('chat-send').textContent=running?'排队':'发送';
211
+ $('chat-send').title=running?'这一轮结束后发送':'';
212
+ const r=chatSent&&current.chatResults?.[chatSent.id],sending=!!chatSent&&!r;
213
+ if(r){
214
+ if(r.ok&&!chatSent.done){if($('chat-input').value===chatSent.text){$('chat-input').value='';grow();}chatSent.done=Date.now();clearTimeout(chatStatusTimer);chatStatusTimer=setTimeout(()=>{if(chatSent?.done){chatSent=null;renderChat();}},4000);}
215
+ else if(!r.ok&&!chatSent.failed)chatSent.failed=true;
216
+ }
217
+ const empty=!$('chat-input').value.trim();
218
+ $('chat-input').disabled=sending;
219
+ $('chat-send').disabled=$('chat-steer').disabled=sending||empty;
220
+ const st=$('chat-status');
221
+ st.className='ask-status'+(r&&!r.ok?' err':r?.ok?' ok':'');
222
+ st.textContent=sending?'正在发送…':r?(r.ok?(r.mode==='steer'?'已插话,Agent 下一步会看到':chatSent.running?'已排队,这一轮结束后发送':'已发送'):r.reason||'发送失败'):'';
223
+ return true;
224
+ }
225
+ function grow(){const t=$('chat-input');t.style.height='auto';t.style.height=`${Math.min(96,t.scrollHeight+2)}px`;if(expanded)fitPanel();}
226
+ function sendChat(mode){
227
+ const c=chatOf(),text=$('chat-input').value.trim();
228
+ if(!c||!text||(chatSent&&!current.chatResults?.[chatSent.id]))return;
229
+ const id=`${Date.now().toString(36)}-${Math.random().toString(36).slice(2,8)}`;
230
+ chatSent={id,text:$('chat-input').value,running:!!c.running};clearTimeout(chatStatusTimer);
231
+ bridge.chat(id,c.sessionId,text,mode);renderChat();
232
+ }
233
+ $('chat').addEventListener('submit',e=>{e.preventDefault();sendChat('queue');});
234
+ $('chat-steer').addEventListener('click',()=>sendChat('steer'));
235
+ $('chat-open').addEventListener('click',()=>bridge.open());
236
+ $('chat-input').addEventListener('input',()=>{if(chatSent&&current.chatResults?.[chatSent.id]){chatSent=null;}grow();renderChat();});
237
+ $('chat-input').addEventListener('keydown',e=>{
238
+ if(e.key==='Enter'&&!e.shiftKey&&!e.isComposing&&e.keyCode!==229){e.preventDefault();if(!$('chat-send').disabled)sendChat('queue');}
239
+ if(e.key==='Escape')e.stopPropagation(),expand(false);
240
+ });
241
+
189
242
  // 上下文占用:鲸鱼外圈按占用比例描边,70% 起变黄,90% 起变红。
190
243
  function contextOf(d){if(!d?.contextTokens||!d?.contextWindow)return null;return Math.min(100,Math.round(d.contextTokens/d.contextWindow*100));}
191
244
  function renderRing(state,d){
@@ -253,7 +306,10 @@ function renderPanel({activity:a,prefs={},history={},balance,preview}){
253
306
  const approving=renderApprove();
254
307
  if(approving)$('ask').hidden=true;
255
308
  const asking=!approving&&renderAsk();
256
- if(approving||asking)for(const id of ['task','others','today','usage','recent','hint','reply'])$(id).hidden=true;
309
+ const chatting=renderChat();
310
+ if(approving||asking)for(const id of ['task','others','today','usage','recent','hint','reply','chat'])$(id).hidden=true;
311
+ // 对话卡里已经有最近的回复;空闲时收起柱状图和最近任务(用量窗口里都有),给输入框留出位置。
312
+ else if(chatting){$('reply').hidden=true;if(idle)for(const id of ['usage','recent','hint'])$(id).hidden=true;}
257
313
  }
258
314
 
259
315
  // 状态切换:文字与指示器做模糊交叉淡入,异常时整体轻摇。
@@ -312,4 +368,4 @@ function render(snapshot){
312
368
  const unsubscribe=bridge.subscribe(render);
313
369
  render(await bridge.initial());
314
370
  const timer=setInterval(tick,1000);
315
- window.addEventListener('beforeunload',()=>{unsubscribe();clearInterval(timer);cancelAnimationFrame(boundsFrame);clearTimeout(burstTimer);clearTimeout(peekTimer);clearTimeout(leaveTimer);clearTimeout(armTimer);clearTimeout(hoverTimer);});
371
+ window.addEventListener('beforeunload',()=>{unsubscribe();offBlur?.();clearTimeout(chatStatusTimer);clearInterval(timer);cancelAnimationFrame(boundsFrame);clearTimeout(burstTimer);clearTimeout(peekTimer);clearTimeout(leaveTimer);clearTimeout(armTimer);clearTimeout(hoverTimer);});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-island",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "DeepSeek Harness 桌面灵动岛插件:实时任务状态、上下文占用、在灵动岛批准权限与回答提问、用量与费用统计(社区插件)",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/chat.js ADDED
@@ -0,0 +1,66 @@
1
+ // 在灵动岛里继续对话:通过 Harness 自己的会话控制器(sessionController.prompt,与 Harness 输入框是同一个入口)
2
+ // 把消息发给当前显示的、或最近一个顶层任务。空闲时开始新一轮;任务进行中可以排队(本轮结束后发送)或插话(插入本轮)。
3
+ import { randomUUID } from 'node:crypto';
4
+
5
+ export const MAX_MESSAGE = 8000;
6
+ const MODES = new Set(['queue', 'steer']);
7
+ const clean = text => typeof text === 'string' ? text.replace(/\r\n?/g, '\n').replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '').trim() : '';
8
+ const timeZone = () => { try { const z = Intl.DateTimeFormat().resolvedOptions().timeZone; return /^(UTC|[A-Za-z_]+(\/[A-Za-z0-9_+\-]+)+)$/.test(z ?? '') ? z : undefined; } catch { return undefined; } };
9
+
10
+ const REASONS = {
11
+ 'session/not-found': '找不到这个对话,可能已被删除或归档',
12
+ 'session/model-unavailable': '请先在 Harness 中选择一个可用的模型',
13
+ 'session/agent-busy': 'Agent 暂时无法接收消息',
14
+ 'session/steer-unavailable': '这一轮已经结束,无法插话',
15
+ 'session/attachment-invalid': '消息内容无效',
16
+ 'gateway/bad-request': '消息内容无效',
17
+ 'gateway/cancelled': '发送超时,请重试'
18
+ };
19
+ export function reasonOf(error) {
20
+ const code = error?.code ?? error?.name;
21
+ return REASONS[code] ?? 'Harness 没有接收这条消息';
22
+ }
23
+
24
+ export class ChatDesk {
25
+ constructor(ctx) { this.ctx = ctx; this.enabled = true; this.last = null; }
26
+ running(id) { try { return this.ctx.agents?.get?.(id)?.status === 'running'; } catch { return false; } }
27
+ /**
28
+ * The conversation the island continues: the task on show, else the most recently active top-level one.
29
+ * @param known - session ids seen as top-level tasks; sub-agents never become a target.
30
+ */
31
+ target(store, known, shownId) {
32
+ if (!this.enabled) return null;
33
+ const ok = id => typeof id === 'string' && known.has(id);
34
+ let entry = ok(shownId) ? store.sessions.get(shownId) : undefined;
35
+ if (!entry) for (const e of store.sessions.values()) if (ok(e.id) && (e.started || e.title || e.reply) && (!entry || e.updated > entry.updated)) entry = e;
36
+ if (entry) {
37
+ const same = this.last?.sessionId === entry.id;
38
+ this.last = { sessionId: entry.id, title: entry.title || (same ? this.last.title : ''), reply: entry.reply || '', state: entry.state };
39
+ }
40
+ if (!this.last) return null;
41
+ return { ...this.last, running: this.running(this.last.sessionId) };
42
+ }
43
+ /**
44
+ * Send one text message to the target conversation, exactly as Harness's own composer would.
45
+ * @param mode - 'queue' starts a turn when idle or waits for the running one; 'steer' joins the running turn.
46
+ */
47
+ async send(sessionId, text, mode = 'queue', { timeout = 30000 } = {}) {
48
+ if (!this.enabled) return { ok: false, reason: '已在设置中关闭' };
49
+ if (!this.last || this.last.sessionId !== sessionId) return { ok: false, reason: '这个对话已经切换,请重新发送' };
50
+ const body = clean(text);
51
+ if (!body) return { ok: false, reason: '消息是空的' };
52
+ if (body.length > MAX_MESSAGE) return { ok: false, reason: `消息太长(最多 ${MAX_MESSAGE} 字)` };
53
+ if (!MODES.has(mode)) mode = 'queue';
54
+ if (mode === 'steer' && !this.running(sessionId)) mode = 'queue';
55
+ const service = this.ctx.get?.('sessionController');
56
+ if (typeof service?.prompt !== 'function') return { ok: false, reason: '当前 Harness 版本不支持从灵动岛发送' };
57
+ const tz = timeZone();
58
+ const request = { requestId: randomUUID(), sessionId, mode, content: [{ type: 'text', text: body }], ...(tz ? { clientTimeZone: tz } : {}) };
59
+ try {
60
+ await service.prompt(request, AbortSignal.timeout(timeout));
61
+ return { ok: true, mode };
62
+ } catch (error) {
63
+ return { ok: false, reason: reasonOf(error) };
64
+ }
65
+ }
66
+ }
package/src/host.js CHANGED
@@ -9,6 +9,7 @@ import { Config, readPrefs } from './settings.js';
9
9
  import { QuestionDesk } from './questions.js';
10
10
  import { ApprovalDesk } from './approvals.js';
11
11
  import { UsageLedger } from './usage.js';
12
+ import { ChatDesk } from './chat.js';
12
13
 
13
14
  export { Config };
14
15
  export const name = 'dsh-island';
@@ -68,6 +69,9 @@ export function islandSnapshot(ctx, store, known, desk, extra = {}) {
68
69
  if (extra.prefs && extra.prefs.details === false) data.detail.reply = '';
69
70
  }
70
71
  if (extra.usage) data.usage = extra.usage;
72
+ // 继续对话的目标:当前显示的任务,或最近一个顶层任务(不会是子智能体)。
73
+ const chat = extra.chat?.target(store, known, data.sessionId);
74
+ data.chat = chat ? (extra.prefs?.details === false ? { ...chat, title: '', reply: '' } : chat) : null;
71
75
  return data;
72
76
  }
73
77
 
@@ -109,6 +113,8 @@ export function apply(ctx, config = {}) {
109
113
  const approvals = new ApprovalDesk(ctx, () => publish());
110
114
  approvals.enabled = prefs.approvals;
111
115
  const offApprovals = approvals.attach();
116
+ const chat = new ChatDesk(ctx);
117
+ chat.enabled = prefs.chat;
112
118
  // 用量账本:实时事件 + 历史会话补算(通过 Harness 的 sessionQuery,读一次即缓存)。
113
119
  const ledger = new UsageLedger({ file: join(dataDir, 'usage.json') }).load();
114
120
  const offLedger = ctx.on('session/event', (session, event) => ledger.ingest(session?.id, event), { global: true });
@@ -125,7 +131,7 @@ export function apply(ctx, config = {}) {
125
131
  const send = () => {
126
132
  pending = undefined;
127
133
  if (stopped || !child?.connected) return;
128
- const data=islandSnapshot(ctx,store,known,desk,{approvals,prefs,usage:usageNow()}), encoded=JSON.stringify(data);
134
+ const data=islandSnapshot(ctx,store,known,desk,{approvals,chat,prefs,usage:usageNow()}), encoded=JSON.stringify(data);
129
135
  if(encoded===last)return;
130
136
  last=encoded;
131
137
  child.send({type:'activity',data}, error=>{ if(error) last=''; });
@@ -146,7 +152,7 @@ export function apply(ctx, config = {}) {
146
152
  const next = readPrefs(config);
147
153
  if (next.enabled && !prefs.enabled) message({ type: 'show' });
148
154
  const balanceChanged = next.balance !== prefs.balance;
149
- prefs = next; desk.enabled = prefs.questions; approvals.enabled = prefs.approvals; message({ type: 'prefs', data: prefs }); publish();
155
+ prefs = next; desk.enabled = prefs.questions; approvals.enabled = prefs.approvals; chat.enabled = prefs.chat; message({ type: 'prefs', data: prefs }); publish();
150
156
  if (balanceChanged) refreshBalance(true);
151
157
  });
152
158
  try {
@@ -161,6 +167,7 @@ export function apply(ctx, config = {}) {
161
167
  if(msg?.type==='answer' && typeof msg.callId==='string'){ const result = desk.answer(msg.callId, msg.answers, rootsOf(ctx)); message({ type: 'answer-result', callId: msg.callId, ...result }); publish(); }
162
168
  if(msg?.type==='stop' && stopSession(ctx, msg.sessionId)) publish();
163
169
  if(msg?.type==='approve' && typeof msg.id==='string'){ const result = approvals.decide(msg.id, msg.outcome); message({ type: 'approval-result', id: msg.id, ...result }); publish(); }
170
+ if(msg?.type==='chat' && typeof msg.id==='string'){ chat.send(msg.sessionId, msg.text, msg.mode).then(result => { message({ type: 'chat-result', id: msg.id, ...result }); publish(); }); }
164
171
  if(msg?.type==='stats'){ message({ type: 'stats', data: ledger.stats(['today','7d','30d'].includes(msg.range) ? msg.range : '7d') }); if (!ledger.sessions.size) backfill(); }
165
172
  });
166
173
  child.on('exit',code=>{ if(!stopped && code)ctx.logger.warn(`灵动岛窗口已退出 (${code}),重新启用插件可恢复。`); });
package/src/settings.js CHANGED
@@ -4,7 +4,7 @@ import { dirname, join } from 'node:path';
4
4
  // 灵动岛在 Harness「设置 › 灵动岛」中的选项。字段都是 volatile:修改后即时生效,不重启插件。
5
5
  export const POSITIONS = ['top-center', 'top-left', 'top-right', 'left-center', 'right-center', 'bottom-center'];
6
6
  export const SIZES = { small: 0.85, medium: 1, large: 1.15 };
7
- export const DEFAULT_PREFS = { enabled: true, position: 'top-center', size: 'medium', reduceMotion: false, popups: true, details: true, balance: true, questions: true, approvals: true, hoverExpand: true };
7
+ export const DEFAULT_PREFS = { enabled: true, position: 'top-center', size: 'medium', reduceMotion: false, popups: true, details: true, balance: true, questions: true, approvals: true, chat: true, hoverExpand: true };
8
8
 
9
9
  async function loadSchemastery() {
10
10
  try { const m = await import('@deepseek-ai/schemastery'); return m.default ?? m; } catch {}
@@ -30,6 +30,7 @@ export const Config = z ? z.object({
30
30
  balance: z.boolean().default(true).description('空闲时显示 DeepSeek 账户余额').volatile(),
31
31
  questions: z.boolean().default(true).description('在灵动岛中回答 Agent 的提问').volatile(),
32
32
  approvals: z.boolean().default(true).description('在灵动岛中批准或拒绝权限请求').volatile(),
33
+ chat: z.boolean().default(true).description('在灵动岛中给 Agent 发消息').volatile(),
33
34
  hoverExpand: z.boolean().default(true).description('鼠标停留时直接展开面板').volatile(),
34
35
  allowStandalone: z.boolean().default(false).hidden(),
35
36
  dataDir: z.string().hidden()
@@ -46,6 +47,6 @@ export function readPrefs(config = {}) {
46
47
  }
47
48
  if (!POSITIONS.includes(prefs.position)) prefs.position = DEFAULT_PREFS.position;
48
49
  if (!Object.hasOwn(SIZES, prefs.size)) prefs.size = DEFAULT_PREFS.size;
49
- for (const key of ['enabled', 'reduceMotion', 'popups', 'details', 'balance', 'questions', 'approvals', 'hoverExpand']) prefs[key] = prefs[key] !== false && prefs[key] !== 'false' ? Boolean(prefs[key]) : false;
50
+ for (const key of ['enabled', 'reduceMotion', 'popups', 'details', 'balance', 'questions', 'approvals', 'chat', 'hoverExpand']) prefs[key] = prefs[key] !== false && prefs[key] !== 'false' ? Boolean(prefs[key]) : false;
50
51
  return prefs;
51
52
  }
package/src/state.js CHANGED
@@ -17,7 +17,8 @@ const clip = (value, max = 100) => typeof value === 'string' ? value.replace(/[\
17
17
  const count = value => Number.isFinite(value) && value > 0 ? value : 0;
18
18
 
19
19
  // Crosses the process boundary: state, titles, the agent's own plan items, tool names,
20
- // model id and token counts. Never prompts, replies, reasoning or tool arguments.
20
+ // model id, token counts and the final reply of a finished turn. Never prompts, reasoning,
21
+ // in-progress text or tool arguments.
21
22
  export class ActivityStore {
22
23
  constructor(now = Date.now) { this.now = now; this.sessions = new Map(); this.finished = []; }
23
24
  entry(id) {
@@ -76,7 +77,7 @@ export class ActivityStore {
76
77
  case 'request/context': if (Number.isFinite(d.contextWindow) && d.contextWindow > 0) e.contextWindow = d.contextWindow; break;
77
78
  case 'assistant/message': {
78
79
  const text = Array.isArray(d.message?.content) ? d.message.content.filter(b => b?.type === 'text' && typeof b.text === 'string').map(b => b.text).join(' ') : '';
79
- if (text.trim()) e.lastText = clip(text.replace(/[#*`>_~|]+/g, ' ').replace(/\s+/g, ' '), 160);
80
+ if (text.trim()) e.lastText = text.replace(/\r\n?/g, '\n').replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, ' ').replace(/```[^\n]*\n?/g, '').replace(/^#{1,6}\s+/gm, '').replace(/\*\*|__|`/g, '').replace(/\n{3,}/g, '\n\n').trim().slice(0, 4000);
80
81
  const u = d.usage;
81
82
  if (u && typeof u === 'object') {
82
83
  e.contextTokens = count(u.inputTokens) + count(u.cacheReadTokens) + count(u.cacheWriteTokens);
package/web/client.js CHANGED
@@ -75,6 +75,8 @@ window.__ModuleLoader__.load({
75
75
  balanceHint: "空闲时在灵动岛里显示 DeepSeek 账户余额(需在 设置 › 账户 中登录 DeepSeek 账号)。",
76
76
  approvals: "在灵动岛批准权限请求",
77
77
  approvalsHint: "Agent 需要授权时(如执行命令、修改文件),灵动岛显示要做什么,可直接“允许一次”或“拒绝”;Harness 窗口里也照常可批,先批的生效。始终允许等持久授权仍在 Harness 中设置。",
78
+ chat: "在灵动岛给 Agent 发消息",
79
+ chatHint: "展开面板里有输入框,可以接着当前或最近的任务对话,不用打开 Harness;任务进行中可以排队(这一轮结束后发送)或插话。消息和在 Harness 输入框里发的一样,会出现在对话记录里。",
78
80
  hoverExpand: "悬停展开面板",
79
81
  hoverExpandHint: "鼠标在灵动岛上停留片刻即展开面板,移开后自动收起;关闭后需要点击展开。",
80
82
  reduceMotion: "减少动态效果",
@@ -107,6 +109,8 @@ window.__ModuleLoader__.load({
107
109
  balanceHint: "Show your DeepSeek account balance when idle (requires signing in under Settings › Account).",
108
110
  approvals: "Approve permission requests in the island",
109
111
  approvalsHint: "When the agent needs permission (running a command, editing a file), the island shows what it wants to do so you can allow once or reject; Harness can still answer too, and whichever is first counts. Persistent grants stay in Harness.",
112
+ chat: "Message the agent from the island",
113
+ chatHint: "The panel has a message box to continue the current or latest task without opening Harness; while a task runs you can queue a message for after this turn or steer the turn. Messages are the same as ones typed in Harness and appear in the conversation.",
110
114
  hoverExpand: "Expand on hover",
111
115
  hoverExpandHint: "Resting the pointer on the island opens the panel and moving away closes it; when off, click to expand.",
112
116
  reduceMotion: "Reduce motion",
@@ -122,7 +126,7 @@ window.__ModuleLoader__.load({
122
126
 
123
127
  const ENTRY = "dsh-island";
124
128
  const NS = "settings.dshIsland";
125
- const DEFAULTS = { enabled: true, position: "top-center", size: "medium", reduceMotion: false, popups: true, details: true, balance: true, questions: true, approvals: true, hoverExpand: true };
129
+ const DEFAULTS = { enabled: true, position: "top-center", size: "medium", reduceMotion: false, popups: true, details: true, balance: true, questions: true, approvals: true, chat: true, hoverExpand: true };
126
130
  const SPOTS = [
127
131
  ["top-left", { left: 6, top: 6 }], ["top-center", { left: 90, top: 6 }], ["top-right", { right: 6, top: 6 }],
128
132
  ["left-center", { left: 6, top: 45 }, true], ["right-center", { right: 6, top: 45 }, true], ["bottom-center", { left: 90, bottom: 16 }]
@@ -175,6 +179,7 @@ window.__ModuleLoader__.load({
175
179
  jsx(Row, { label: t("details"), hint: t("detailsHint"), children: jsx(Switch, { checked: value("details"), label: t("details"), disabled, onChange: (v) => set("details", v) }) }),
176
180
  jsx(Row, { label: t("questions"), hint: t("questionsHint"), children: jsx(Switch, { checked: value("questions"), label: t("questions"), disabled, onChange: (v) => set("questions", v) }) }),
177
181
  jsx(Row, { label: t("approvals"), hint: t("approvalsHint"), children: jsx(Switch, { checked: value("approvals"), label: t("approvals"), disabled, onChange: (v) => set("approvals", v) }) }),
182
+ jsx(Row, { label: t("chat"), hint: t("chatHint"), children: jsx(Switch, { checked: value("chat"), label: t("chat"), disabled, onChange: (v) => set("chat", v) }) }),
178
183
  jsx(Row, { label: t("hoverExpand"), hint: t("hoverExpandHint"), children: jsx(Switch, { checked: value("hoverExpand"), label: t("hoverExpand"), disabled, onChange: (v) => set("hoverExpand", v) }) }),
179
184
  jsx(Row, { label: t("balance"), hint: t("balanceHint"), children: jsx(Switch, { checked: value("balance"), label: t("balance"), disabled, onChange: (v) => set("balance", v) }) }),
180
185
  jsx(Row, { label: t("reduceMotion"), hint: t("reduceMotionHint"), children: jsx(Switch, { checked: value("reduceMotion"), label: t("reduceMotion"), disabled, onChange: (v) => set("reduceMotion", v) }) })