@lyhue1991/dsh-soup 0.5.7 → 0.5.8

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 +43 -20
  2. package/package.json +1 -1
package/lib/host/speed.js CHANGED
@@ -83,45 +83,64 @@ 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) {
131
+ // Cold-session fallback: pick the session with the freshest activity.
132
+ let best = null
133
+ for (const states of streams.values()) {
134
+ const latest = states[states.length - 1]
135
+ if (latest && (!best || latest.lastSeen > best.lastSeen)) best = latest
136
+ }
137
+ pool = best ? [best] : []
124
138
  }
139
+ // Prefer the most recently active still-running stream (the main reply),
140
+ // falling back to the most recent settled one only when nothing is live.
141
+ const live = pool.filter((s) => s.phase !== 'done')
142
+ const sorted = (live.length ? live : pool).slice().sort((a, b) => b.lastSeen - a.lastSeen)
143
+ const state = sorted[0]
125
144
  if (!state) return { phase: 'idle' }
126
145
  const now = Date.now()
127
146
  const tokens = state.hasReal ? state.realTokens : Math.round(state.charTokens)
@@ -145,7 +164,11 @@ export function createSpeedTracker() {
145
164
  }
146
165
 
147
166
  function reap(now = Date.now()) {
148
- for (const [key, state] of streams) if (state.phase === 'done' && now - state.lastSeen > 4000) streams.delete(key)
167
+ for (const [key, states] of streams) {
168
+ const kept = states.filter((state) => state.phase !== 'done' || now - state.lastSeen <= 4000)
169
+ if (kept.length === 0) streams.delete(key)
170
+ else if (kept.length !== states.length) streams.set(key, kept)
171
+ }
149
172
  }
150
173
 
151
174
  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.8",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "scripts": {