@lyhue1991/dsh-soup 0.5.7 → 0.5.9

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.
Files changed (2) hide show
  1. package/lib/host/speed.js +45 -20
  2. package/package.json +1 -1
package/lib/host/speed.js CHANGED
@@ -83,45 +83,66 @@ function extractChunk(chunk) {
83
83
  }
84
84
 
85
85
  export function createSpeedTracker() {
86
+ // sessionId -> concurrent stream states. Background tasks (subagents, title
87
+ // generation) open LLM streams under the same sessionId as the main reply,
88
+ // so a session may genuinely have several live streams at once; a single
89
+ // slot per session let a short background stream clobber the main reply's
90
+ // state (badge vanished while the main answer was still streaming).
86
91
  const streams = new Map()
92
+ const MAX_STATES_PER_KEY = 4
87
93
 
88
94
  function wrap(options, next) {
89
95
  const key = sessionKey(options) || '_'
90
96
  const now = Date.now()
91
97
  const state = { phase: 'waiting', startedAt: now, firstChunkAt: null, charTokens: 0, realTokens: 0, hasReal: false, lastChunkAt: null, lastContentAt: null, samples: [], lastSeen: now }
92
- streams.set(key, state)
98
+ const states = streams.get(key) || []
99
+ states.push(state)
100
+ while (states.length > MAX_STATES_PER_KEY) states.shift()
101
+ streams.set(key, states)
93
102
  const innerPromise = Promise.resolve(next())
94
103
  return (async function* () {
95
104
  try {
96
105
  const inner = await innerPromise
97
106
  for await (const chunk of inner) {
98
- // A newer stream for the same session supersedes this one. Ignore
99
- // late chunks from the old stream so they cannot corrupt its status.
100
- if (streams.get(key) === state) {
101
- const parsed = extractChunk(chunk)
102
- const time = Date.now()
103
- if (state.firstChunkAt === null) { state.firstChunkAt = time; state.phase = 'streaming' }
104
- if (parsed.text) state.charTokens += estimateChars(parsed.text)
105
- if (parsed.realTokens != null) { state.realTokens = parsed.realTokens; state.hasReal = true }
106
- state.lastChunkAt = time
107
- if (parsed.text || parsed.realTokens != null) state.lastContentAt = time
108
- state.samples.push([time, state.hasReal ? state.realTokens : state.charTokens])
109
- if (state.samples.length > 128) state.samples.splice(0, state.samples.length - 128)
110
- state.lastSeen = time
111
- }
107
+ // Each stream state is independent: a concurrent (or later) stream
108
+ // for the same session never blocks or corrupts this one's bookkeeping.
109
+ const parsed = extractChunk(chunk)
110
+ const time = Date.now()
111
+ if (state.firstChunkAt === null) { state.firstChunkAt = time; state.phase = 'streaming' }
112
+ if (parsed.text) state.charTokens += estimateChars(parsed.text)
113
+ if (parsed.realTokens != null) { state.realTokens = parsed.realTokens; state.hasReal = true }
114
+ state.lastChunkAt = time
115
+ if (parsed.text || parsed.realTokens != null) state.lastContentAt = time
116
+ state.samples.push([time, state.hasReal ? state.realTokens : state.charTokens])
117
+ if (state.samples.length > 128) state.samples.splice(0, state.samples.length - 128)
118
+ state.lastSeen = time
112
119
  yield chunk
113
120
  }
114
121
  } finally {
115
- if (streams.get(key) === state) { state.phase = 'done'; state.lastSeen = Date.now() }
122
+ state.phase = 'done'
123
+ state.lastSeen = Date.now()
116
124
  }
117
125
  })()
118
126
  }
119
127
 
120
128
  function status(sessionId) {
121
- let state = streams.get(sessionId)
122
- if (!state) {
123
- for (const candidate of streams.values()) if (!state || candidate.lastSeen > state.lastSeen) state = candidate
129
+ let pool = streams.get(sessionId) || []
130
+ if (pool.length === 0 && !sessionId) {
131
+ // No-id fallback (badge could not resolve any session id): pick the
132
+ // session with the freshest activity. A real sessionId must never see
133
+ // another session's traffic, so an unknown id simply stays idle.
134
+ let best = null
135
+ for (const states of streams.values()) {
136
+ const latest = states[states.length - 1]
137
+ if (latest && (!best || latest.lastSeen > best.lastSeen)) best = latest
138
+ }
139
+ pool = best ? [best] : []
124
140
  }
141
+ // Prefer the most recently active still-running stream (the main reply),
142
+ // falling back to the most recent settled one only when nothing is live.
143
+ const live = pool.filter((s) => s.phase !== 'done')
144
+ const sorted = (live.length ? live : pool).slice().sort((a, b) => b.lastSeen - a.lastSeen)
145
+ const state = sorted[0]
125
146
  if (!state) return { phase: 'idle' }
126
147
  const now = Date.now()
127
148
  const tokens = state.hasReal ? state.realTokens : Math.round(state.charTokens)
@@ -145,7 +166,11 @@ export function createSpeedTracker() {
145
166
  }
146
167
 
147
168
  function reap(now = Date.now()) {
148
- for (const [key, state] of streams) if (state.phase === 'done' && now - state.lastSeen > 4000) streams.delete(key)
169
+ for (const [key, states] of streams) {
170
+ const kept = states.filter((state) => state.phase !== 'done' || now - state.lastSeen <= 4000)
171
+ if (kept.length === 0) streams.delete(key)
172
+ else if (kept.length !== states.length) streams.set(key, kept)
173
+ }
149
174
  }
150
175
 
151
176
  return { wrap, status, reap }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lyhue1991/dsh-soup",
3
3
  "description": "DSH 项目资源管理器:右侧 details 列文件树(并列网格、宽度对齐宿主 details 合同 300-520px),有 Session log 时在其右侧、空白新会话在输入框上方右端提供 📁 切换按钮;支持多选、双击展开/打开、右键菜单(打开/系统打开/重命名/废纸篓/新建/复制路径)、拖拽移动、上传。文件「打开」在会话区新增「文件」标签页(原生 conversation.view,与对话/轨迹同级):文本可编辑 ⌘S 保存、图片预览、二进制提示。附带模型吞吐徽标:输入框正上方显示「正在等待模型...」与彩色 t/s 徽标(数据来自 llm/stream 真实流)。附带 GoalBar 多行展示:把原生单行截断 GoalBar 放开为多行完整显示,编辑用 textarea,工具与数据仍走原生 goal。",
4
- "version": "0.5.7",
4
+ "version": "0.5.9",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "scripts": {