cc-viewer 1.7.14 → 1.7.17

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.
@@ -18,6 +18,10 @@ export { messageFingerprint };
18
18
  * 无 live-port 配置下"提问气泡请求时即显示"依赖这一行为。
19
19
  * AppBase 的 SSE 与批量两个 merge 入口、以及单测共用此谓词,防三处逻辑漂移。
20
20
  *
21
+ * 批量路径的降级例外见 shouldDegradeBrokenMerge:当 broken 条目恰好是它那个
22
+ * session 的唯一载体(slim 只留最后一条 main)时,其 messages 是可信前缀,
23
+ * 降级合并比整会话空白更可取(会话打 _partialData 标记,ChatView 显示提示条)。
24
+ *
21
25
  * @param {object} entry
22
26
  * @param {object} [options]
23
27
  * @param {boolean} [options.batch=false] - 批量(强刷/历史加载)路径
@@ -30,6 +34,36 @@ export function isMergeBlockedEntry(entry, options = {}) {
30
34
  return false;
31
35
  }
32
36
 
37
+ /**
38
+ * 批量路径降级例外:broken 载体能否以"可信前缀"身份合并进 mainAgentSessions。
39
+ *
40
+ * 仅在两条安全路径放行(两条都只会走进 mergeMainAgentSessions 的创建分支,
41
+ * 不会触发 anchor/rebuild 等可能翻倍的对齐逻辑):
42
+ * - prevSessions 为空:会话直接创建,无对齐风险;
43
+ * - 双方都有 _seqEpoch 且不同:确定性会话边界,走 epochChanged append 分支
44
+ * (三部分守卫防 v1 无 epoch 的条目被误放行)。
45
+ *
46
+ * `_staleReorder` 明确不降级(流内稍后有权威副本自建会话);inProgress 不降级
47
+ * (批量下其 messages 是裸切片,不是可信前缀)。v1 / 无 epoch 会话的 broken
48
+ * 载体也刻意不降级(放松守卫会让 broken 条目掉进同会话分支,错位前缀 → 翻倍)。
49
+ *
50
+ * @param {object} entry
51
+ * @param {Array} prevSessions - 当前已合并的 sessions
52
+ * @returns {boolean} true = 允许降级合并
53
+ */
54
+ export function shouldDegradeBrokenMerge(entry, prevSessions) {
55
+ if (!entry || !entry._reconstructBroken) return false;
56
+ if (entry._staleReorder || entry.inProgress) return false;
57
+ if (!Array.isArray(prevSessions) || prevSessions.length === 0) return true;
58
+ const e = entry._seqEpoch;
59
+ if (!e) return false; // v1 / epoch-less carriers never degrade
60
+ // Reject when ANY already-merged session shares the entry's epoch (a same-
61
+ // session broken carrier preceded by a healthy one must not degrade into the
62
+ // same-session merge branch). Today batches are single-session per epoch, so
63
+ // this is a defensive closure for future non-monotonic epoch streams.
64
+ return prevSessions.every((s) => !s._seqEpoch || s._seqEpoch !== e);
65
+ }
66
+
33
67
  /**
34
68
  * 增量合并 mainAgent sessions。
35
69
  *
@@ -39,6 +73,8 @@ export function isMergeBlockedEntry(entry, options = {}) {
39
73
  * - 命中且 overlapLen < newLen:push `newMessages[overlapLen..]`,引用稳定。
40
74
  * - 未命中:newLen<curLen → rebuild(/compact summary);newLen===curLen → 整段 append(Plan Mode 全新片段);
41
75
  * newLen>curLen → 严格前缀扩展语义(push tail),fp 加固后真正存在重叠的窗口必被 anchor 命中。
76
+ * - 降级 partial 基底(lastSession._partialData)且 anchor 未命中:任何长度的全量条目都整段替换
77
+ * (partial 前缀有中段缺口、不可信,push tail 会翻倍)。
42
78
  *
43
79
  * 顶部守卫(isPostClearCheckpoint / userId / transient filter)维持 1.6.245 行为不变。
44
80
  *
@@ -68,7 +104,13 @@ export function mergeMainAgentSessions(prevSessions, entry, options = {}) {
68
104
  const entryModel = getEffectiveModel(entry);
69
105
 
70
106
  if (prevSessions.length === 0) {
71
- return [{ userId, messages: newMessages, response: newResponse, entryTimestamp, model: entryModel, _seqEpoch: seqEpoch }];
107
+ return [{
108
+ userId, messages: newMessages, response: newResponse, entryTimestamp, model: entryModel, _seqEpoch: seqEpoch,
109
+ // Degraded-broken carrier: truthful prefix with a mid-session hole. Only
110
+ // the create branches carry it (a same-session merge onto a partial base
111
+ // is defused by the whole-replace below); ChatView renders the banner.
112
+ ...(entry._partialData === true && { _partialData: true }),
113
+ }];
72
114
  }
73
115
 
74
116
  const lastSession = prevSessions[prevSessions.length - 1];
@@ -90,7 +132,10 @@ export function mergeMainAgentSessions(prevSessions, entry, options = {}) {
90
132
  for (let i = 0; i < newMessages.length; i++) {
91
133
  if (!newMessages[i]._timestamp) newMessages[i]._timestamp = entryTimestamp;
92
134
  }
93
- return [...prevSessions, { userId, messages: newMessages, response: newResponse, entryTimestamp, model: entryModel, _seqEpoch: seqEpoch }];
135
+ return [...prevSessions, {
136
+ userId, messages: newMessages, response: newResponse, entryTimestamp, model: entryModel, _seqEpoch: seqEpoch,
137
+ ...(entry._partialData === true && { _partialData: true }),
138
+ }];
94
139
  }
95
140
 
96
141
  if (!options.skipTransientFilter && isNewConversation && newMessages.length <= 4 && prevMsgCount > 4) {
@@ -119,6 +164,17 @@ export function mergeMainAgentSessions(prevSessions, entry, options = {}) {
119
164
  lastSession.messages.push(newMessages[i]);
120
165
  }
121
166
  }
167
+ // Anchor 对齐成功:新全量条目与 partial 前缀吻合,数据已恢复完整。
168
+ delete lastSession._partialData;
169
+ } else if (lastSession._partialData) {
170
+ // 降级合并的 partial 会话(中段缺口,前缀不可信):anchor 未命中时任何
171
+ // 同长/更长的全量条目都是权威真值,整段替换而非前缀扩展——否则会 push 出
172
+ // 尾部重复(partial 里已含 416..418,新全量 419 会被 append 一遍)。
173
+ for (let i = 0; i < newLen; i++) {
174
+ if (!newMessages[i]._timestamp) newMessages[i]._timestamp = entryTimestamp;
175
+ }
176
+ lastSession.messages = newMessages;
177
+ delete lastSession._partialData;
122
178
  } else if (newLen < curLen) {
123
179
  // /compact summary 等真重建:替换 messages 引用。
124
180
  for (let i = 0; i < newLen; i++) {
@@ -175,6 +231,9 @@ export function mergeMainAgentSessions(prevSessions, entry, options = {}) {
175
231
  if (entryModel) lastSession.model = entryModel; // latest wins; model-less entries keep the stamp
176
232
  return [...prevSessions];
177
233
  } else {
178
- return [...prevSessions, { userId, messages: newMessages, response: newResponse, entryTimestamp, model: entryModel, _seqEpoch: seqEpoch }];
234
+ return [...prevSessions, {
235
+ userId, messages: newMessages, response: newResponse, entryTimestamp, model: entryModel, _seqEpoch: seqEpoch,
236
+ ...(entry._partialData === true && { _partialData: true }),
237
+ }];
179
238
  }
180
239
  }
@@ -188,8 +188,26 @@ export function buildSubAgentResultMap(req, globalIndex) {
188
188
  return { ...localState.toolResultMap, ...filled };
189
189
  }
190
190
 
191
- export function appendToolResultMap(state, messages, startIndex) {
191
+ export function appendToolResultMap(state, messages, startIndex, extraToolUses) {
192
192
  const { toolUseMap, toolResultMap, readContentMap, editSnapshotMap, askAnswerMap, planApprovalMap, _fileState } = state;
193
+ // V2 live: tool_use blocks merged into an already-scanned message by a later
194
+ // split row are surfaced on the synthetic entry's _toolUses. Register them so
195
+ // their tool_result pairs with a real matchedTool (label / input / Read/Edit
196
+ // state). Side-effect tracking (Write/Edit/Ask/ExitPlanMode) stays in the
197
+ // message loop below — these blocks were already seen there when first merged.
198
+ if (Array.isArray(extraToolUses)) {
199
+ for (const block of extraToolUses) {
200
+ if (!block || !block.id || block.id in toolUseMap) continue;
201
+ let parsed = block;
202
+ if (typeof block.input === 'string') {
203
+ try {
204
+ const cleaned = block.input.replace(/^\[object Object\]/, '');
205
+ parsed = { ...block, input: JSON.parse(cleaned) };
206
+ } catch {}
207
+ }
208
+ toolUseMap[parsed.id] = parsed;
209
+ }
210
+ }
193
211
  for (let i = startIndex; i < messages.length; i++) {
194
212
  const msg = messages[i];
195
213
  if (msg.role === 'assistant' && Array.isArray(msg.content)) {
@@ -165,3 +165,30 @@ export function compactResultPreview(entry, opts = {}) {
165
165
  if (!hasImages && !text) return null;
166
166
  return { text, images: hasImages ? images : null };
167
167
  }
168
+
169
+ /**
170
+ * 简化模式是否应在消息流内联 tool_result 图片。
171
+ * 与 compactResultPreview 的跳过条件(isPermissionDenied / isInputValidationError)
172
+ * 保持同一语义;另外要求至少一张「可渲染」的图(有 src 的非 oversized),
173
+ * 纯 oversized 占位不内联(否则消息流里全是虚线占位噪音)。
174
+ *
175
+ * @param {object|null} entry - toolResultMap 条目(可能为 undefined:SubAgent 末轮
176
+ * tool_use 的 result 未到位 / WebSearch / 历史计数缺口等窗口)
177
+ * @returns {boolean}
178
+ */
179
+ export function shouldInlineToolImages(entry) {
180
+ if (!entry || typeof entry !== 'object') return false;
181
+ if (entry.isPermissionDenied || entry.isInputValidationError) return false;
182
+ if (!Array.isArray(entry.images) || entry.images.length === 0) return false;
183
+ return entry.images.some((img) => img && img.src && !img.oversized);
184
+ }
185
+
186
+ /**
187
+ * oversized 图片占位文案(3 处复用:ToolResultView / ChatMessage Popover / 内联块)。
188
+ * `[image png · 512 KB · too large to preview]`
189
+ */
190
+ export function formatOversizedImagePlaceholder(img) {
191
+ const mediaType = (img && img.mediaType ? String(img.mediaType).replace('image/', '') : 'image');
192
+ const sizeKB = img && typeof img.sizeBytes === 'number' ? Math.round(img.sizeBytes / 1024) : 0;
193
+ return `[image ${mediaType} · ${sizeKB} KB · too large to preview]`;
194
+ }
@@ -80,8 +80,20 @@ export function createV3Assembler() {
80
80
  _seqEpoch: `v2:${row.sessionId}`,
81
81
  _totalMessageCount: messages.length,
82
82
  _v3Assembled: true,
83
+ // The SOURCE row — lets chat classification read the server-side
84
+ // typeTag directly (same source the request list uses), instead of
85
+ // re-deriving from a body stripped of system/tools. Known limitation:
86
+ // AppBase._applyV3Delta dedups by (sessionId, seq, inProgress), so a
87
+ // classification-correction re-send (Preflight/Plan lookahead) does NOT
88
+ // rebuild this entry — the row ref stays the FIRST one until a cold
89
+ // reload. Only lookahead-dependent rows are affected (Teammate/SubAgent
90
+ // tags are lookahead-free); tracked as backlog.
91
+ _v3Row: row,
83
92
  };
84
93
  if (row.teammate) entry.teammate = row.teammate;
94
+ // Wire agent identity (x-claude-code-agent-id) — display name for native
95
+ // teammates, persisted server-side (see agent-id.js).
96
+ if (row.agent) entry.agent = row.agent;
85
97
  if (row.proxyUrl) entry.proxyUrl = row.proxyUrl;
86
98
  if (isSnapshot) entry._isCheckpoint = true;
87
99
  if (row.inProgress) {