cc-viewer 0.1.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/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 weiesky
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # CC Viewer
2
+
3
+ Claude Code API 请求监控系统,实时捕获并可视化展示 Claude Code 的所有 API 请求与响应。
4
+
5
+ ## 使用方法
6
+
7
+ ```bash
8
+ npm install -g cc-viewer
9
+ ```
10
+
11
+ 安装完成后运行:
12
+
13
+ ```bash
14
+ ccviewer
15
+ ```
16
+
17
+ 该命令会自动将监控脚本注入到本地安装的 Claude Code 中。之后正常使用 Claude Code,打开浏览器访问 `http://localhost:7008` 即可查看监控界面。
18
+
19
+ ## 功能
20
+
21
+ ### 请求监控(原文模式)
22
+
23
+ - 实时捕获 Claude Code 发出的所有 API 请求,包括流式响应
24
+ - 左侧请求列表展示请求方法、URL、耗时、状态码
25
+ - 自动识别并标记 Main Agent 和 Sub Agent 请求
26
+ - 右侧详情面板支持 Request / Response 两个 Tab 切换查看
27
+ - Request Body 中 `messages`、`system`、`tools` 默认展开一层子属性
28
+ - Response Body 默认全部展开
29
+ - 支持 JSON 视图与纯文本视图切换
30
+ - 支持一键复制 JSON 内容
31
+
32
+ ### 对话模式
33
+
34
+ 点击右上角「打开对话模式」按钮,将最新 Main Agent 请求的 messages 解析为聊天界面:
35
+
36
+ - 用户消息右对齐(蓝色气泡)
37
+ - Main Agent 回复左对齐(深灰气泡),支持 Markdown 渲染
38
+ - Sub Agent 工具返回左对齐(灰色边框气泡),关联显示工具名称
39
+ - `thinking` 块默认折叠,点击展开查看思考过程
40
+ - `tool_use` 显示为紧凑的工具调用卡片,包含工具名和参数预览
41
+ - 系统注入标签(`<system-reminder>`、`<project-reminder>` 等)自动折叠,点击标签名展开内容
42
+ - 自动过滤系统注入文本,只展示用户的真实输入
43
+
44
+ ### 数据解析
45
+
46
+ | 消息类型 | 识别方式 | 展示 |
47
+ |---------|---------|------|
48
+ | 用户输入 | `role: "user"` + 非系统标签文本 | 右对齐蓝色气泡 |
49
+ | Agent 文本回复 | `role: "assistant"` + `type: "text"` | 左对齐 Markdown 渲染 |
50
+ | Agent 工具调用 | `role: "assistant"` + `type: "tool_use"` | 工具调用卡片 |
51
+ | Agent 思考 | `role: "assistant"` + `type: "thinking"` | 可折叠思考块 |
52
+ | 工具返回 | `role: "user"` + `type: "tool_result"` | 关联到对应 tool_use |
53
+
54
+ 对于 `Task` 类型的工具调用,会从 `input` 中提取 `subagent_type` 和 `description` 来标识具体的 Sub Agent。
55
+
56
+ ## 技术栈
57
+
58
+ - 前端:原生 HTML/CSS/JS,暗色主题
59
+ - JSON 渲染:[@alenaksu/json-viewer](https://github.com/nicolo-ribaudo/json-viewer)
60
+ - Markdown 渲染:[marked](https://github.com/markedjs/marked)
61
+ - 后端:Node.js 原生 HTTP 服务 + SSE 实时推送
62
+ - 请求拦截:通过 `globalThis.fetch` 拦截,支持流式响应组装
63
+
64
+ ## License
65
+
66
+ MIT
package/cli.js ADDED
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFileSync, writeFileSync } from 'node:fs';
4
+ import { resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const __dirname = fileURLToPath(new URL('.', import.meta.url));
8
+
9
+ const INJECT_START = '// >>> Start CC Viewer Web Service >>>';
10
+ const INJECT_END = '// <<< Start CC Viewer Web Service <<<';
11
+ const INJECT_IMPORT = "import '../../cc-viewer/interceptor.js';";
12
+ const INJECT_BLOCK = `${INJECT_START}\n${INJECT_IMPORT}\n${INJECT_END}`;
13
+
14
+ // Claude Code cli.js 的路径
15
+ const cliPath = resolve(__dirname, '../@anthropic-ai/claude-code/cli.js');
16
+
17
+ try {
18
+ const content = readFileSync(cliPath, 'utf-8');
19
+
20
+ if (content.includes(INJECT_START)) {
21
+ console.log('✅ CC Viewer 已注入,无需重复操作');
22
+ } else {
23
+ // 在第3行(第2行之后)插入注入代码
24
+ const lines = content.split('\n');
25
+ lines.splice(2, 0, INJECT_BLOCK);
26
+ writeFileSync(cliPath, lines.join('\n'));
27
+ console.log('✅ CC Viewer 运行成功');
28
+ }
29
+ console.log(`直接运行 claude 就可以看到cc-viewer的本地运行地址,默认是:127.0.0.1:7008`);
30
+ console.log(`如果后续功能失效,请重新执行一次 ccviewer 命令即可`);
31
+ } catch (err) {
32
+ if (err.code === 'ENOENT') {
33
+ console.error('❌ 找不到 Claude Code cli.js:', cliPath);
34
+ console.error(' 请确认 @anthropic-ai/claude-code 已安装');
35
+ } else {
36
+ console.error('❌ 注入失败:', err.message);
37
+ }
38
+ process.exit(1);
39
+ }
package/interceptor.js ADDED
@@ -0,0 +1,341 @@
1
+ // LLM Request Interceptor
2
+ // 拦截并记录所有Claude API请求
3
+ import { appendFileSync, mkdirSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { dirname, join, basename } from 'node:path';
6
+
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = dirname(__filename);
9
+
10
+ // 每次启动生成带时间戳的独立日志文件
11
+ function generateLogFilePath() {
12
+ const now = new Date();
13
+ const ts = now.getFullYear().toString()
14
+ + String(now.getMonth() + 1).padStart(2, '0')
15
+ + String(now.getDate()).padStart(2, '0')
16
+ + '_'
17
+ + String(now.getHours()).padStart(2, '0')
18
+ + String(now.getMinutes()).padStart(2, '0')
19
+ + String(now.getSeconds()).padStart(2, '0');
20
+ const projectName = basename(process.cwd()).replace(/[^a-zA-Z0-9_\-\.]/g, '_');
21
+ const dir = '/tmp/claude-requests';
22
+ try { mkdirSync(dir, { recursive: true }); } catch {}
23
+ return join(dir, `${projectName}_${ts}.jsonl`);
24
+ }
25
+
26
+ export const LOG_FILE = generateLogFilePath();
27
+
28
+ // 从环境变量 ANTHROPIC_BASE_URL 提取域名用于请求匹配
29
+ function getBaseUrlHost() {
30
+ try {
31
+ const baseUrl = process.env.ANTHROPIC_BASE_URL;
32
+ if (baseUrl) {
33
+ return new URL(baseUrl).hostname;
34
+ }
35
+ } catch {}
36
+ return null;
37
+ }
38
+ const CUSTOM_API_HOST = getBaseUrlHost();
39
+
40
+ // 不再需要折叠函数,保存完整 JSON 供前端渲染
41
+
42
+ // 组装流式消息为完整的 message 对象
43
+ function assembleStreamMessage(events) {
44
+ let message = null;
45
+ const contentBlocks = [];
46
+ let currentBlockIndex = -1;
47
+
48
+ for (const event of events) {
49
+ if (typeof event !== 'object' || !event.type) continue;
50
+
51
+ switch (event.type) {
52
+ case 'message_start':
53
+ // 初始化消息对象
54
+ message = { ...event.message };
55
+ message.content = [];
56
+ break;
57
+
58
+ case 'content_block_start':
59
+ // 开始新的内容块
60
+ currentBlockIndex = event.index;
61
+ contentBlocks[currentBlockIndex] = { ...event.content_block };
62
+ if (contentBlocks[currentBlockIndex].type === 'text') {
63
+ contentBlocks[currentBlockIndex].text = '';
64
+ } else if (contentBlocks[currentBlockIndex].type === 'thinking') {
65
+ contentBlocks[currentBlockIndex].thinking = '';
66
+ }
67
+ break;
68
+
69
+ case 'content_block_delta':
70
+ // 累积内容块的增量数据
71
+ if (event.index >= 0 && contentBlocks[event.index] && event.delta) {
72
+ if (event.delta.type === 'text_delta' && event.delta.text) {
73
+ contentBlocks[event.index].text += event.delta.text;
74
+ } else if (event.delta.type === 'input_json_delta' && event.delta.partial_json) {
75
+ if (!contentBlocks[event.index].input) {
76
+ contentBlocks[event.index].input = '';
77
+ }
78
+ contentBlocks[event.index].input += event.delta.partial_json;
79
+ } else if (event.delta.type === 'thinking_delta' && event.delta.thinking) {
80
+ contentBlocks[event.index].thinking += event.delta.thinking;
81
+ } else if (event.delta.type === 'signature_delta' && event.delta.signature) {
82
+ contentBlocks[event.index].signature = event.delta.signature;
83
+ }
84
+ }
85
+ break;
86
+
87
+ case 'content_block_stop':
88
+ // 内容块结束
89
+ if (event.index >= 0 && contentBlocks[event.index]) {
90
+ // 如果是 tool_use 且有累积的 input JSON,尝试解析
91
+ if (contentBlocks[event.index].type === 'tool_use' && contentBlocks[event.index].input) {
92
+ try {
93
+ contentBlocks[event.index].input = JSON.parse(contentBlocks[event.index].input);
94
+ } catch {
95
+ // 保持字符串形式
96
+ }
97
+ }
98
+ }
99
+ break;
100
+
101
+ case 'message_delta':
102
+ // 更新消息的增量数据(如 stop_reason, usage)
103
+ if (message && event.delta) {
104
+ if (event.delta.stop_reason) {
105
+ message.stop_reason = event.delta.stop_reason;
106
+ }
107
+ if (event.delta.stop_sequence !== undefined) {
108
+ message.stop_sequence = event.delta.stop_sequence;
109
+ }
110
+ }
111
+ if (message && event.usage) {
112
+ message.usage = { ...message.usage, ...event.usage };
113
+ }
114
+ break;
115
+
116
+ case 'message_stop':
117
+ // 消息结束
118
+ break;
119
+ }
120
+ }
121
+
122
+ // 将内容块添加到消息中
123
+ if (message) {
124
+ message.content = contentBlocks.filter(block => block !== undefined);
125
+ }
126
+
127
+ return message;
128
+ }
129
+
130
+ // 保存 viewer 模块引用
131
+ let viewerModule = null;
132
+
133
+ export function setupInterceptor() {
134
+ // 避免重复拦截
135
+ if (globalThis._ccViewerInterceptorInstalled) {
136
+ return;
137
+ }
138
+ globalThis._ccViewerInterceptorInstalled = true;
139
+
140
+ // 启动 viewer 服务(优先根目录 server.js,fallback 到 lib/server.js)
141
+ const rootServerPath = join(__dirname, 'server.js');
142
+ const libServerPath = join(__dirname, 'lib', 'server.js');
143
+ import(rootServerPath).then(module => {
144
+ viewerModule = module;
145
+ }).catch(() => {
146
+ import(libServerPath).then(module => {
147
+ viewerModule = module;
148
+ }).catch(() => {
149
+ // Silently fail if viewer service cannot start
150
+ });
151
+ });
152
+
153
+ // 注册退出处理器
154
+ const cleanupViewer = () => {
155
+ if (viewerModule && typeof viewerModule.stopViewer === 'function') {
156
+ try {
157
+ viewerModule.stopViewer();
158
+ } catch (err) {
159
+ // Silently fail
160
+ }
161
+ }
162
+ };
163
+
164
+ process.on('SIGINT', () => {
165
+ cleanupViewer();
166
+ process.exit(0);
167
+ });
168
+
169
+ process.on('SIGTERM', () => {
170
+ cleanupViewer();
171
+ process.exit(0);
172
+ });
173
+
174
+ process.on('beforeExit', () => {
175
+ cleanupViewer();
176
+ });
177
+
178
+ const _originalFetch = globalThis.fetch;
179
+
180
+ globalThis.fetch = async function(url, options) {
181
+ const startTime = Date.now();
182
+ let requestEntry = null;
183
+
184
+ try {
185
+ const urlStr = typeof url === 'string' ? url : url?.url || String(url);
186
+ if ((urlStr.includes('anthropic') || urlStr.includes('claude') || (CUSTOM_API_HOST && urlStr.includes(CUSTOM_API_HOST))) && !urlStr.includes('/messages/count_tokens')) {
187
+ const timestamp = new Date().toISOString();
188
+ let body = null;
189
+ if (options?.body) {
190
+ try {
191
+ body = JSON.parse(options.body);
192
+ } catch {
193
+ body = String(options.body).slice(0, 500);
194
+ }
195
+ }
196
+
197
+ // 转换 headers 为普通对象
198
+ let headers = {};
199
+ if (options?.headers) {
200
+ if (options.headers instanceof Headers) {
201
+ headers = Object.fromEntries(options.headers.entries());
202
+ } else if (typeof options.headers === 'object') {
203
+ headers = { ...options.headers };
204
+ }
205
+ }
206
+
207
+ requestEntry = {
208
+ timestamp,
209
+ project: basename(process.cwd()),
210
+ url: urlStr,
211
+ method: options?.method || 'GET',
212
+ headers,
213
+ body: body,
214
+ response: null,
215
+ duration: 0,
216
+ isStream: body?.stream === true,
217
+ mainAgent: !!body?.system && Array.isArray(body?.tools) && body.tools.length > 10 &&
218
+ ['Task', 'Edit', 'Bash'].every(n => body.tools.some(t => t.name === n))
219
+ };
220
+ }
221
+ } catch {}
222
+
223
+ const response = await _originalFetch.apply(this, arguments);
224
+
225
+ if (requestEntry) {
226
+ const duration = Date.now() - startTime;
227
+ requestEntry.duration = duration;
228
+
229
+ // 对于流式响应,拦截并捕获内容
230
+ if (requestEntry.isStream) {
231
+ try {
232
+ requestEntry.response = {
233
+ status: response.status,
234
+ statusText: response.statusText,
235
+ headers: Object.fromEntries(response.headers.entries()),
236
+ body: { events: [] }
237
+ };
238
+
239
+ const originalBody = response.body;
240
+ const reader = originalBody.getReader();
241
+ const decoder = new TextDecoder();
242
+ let streamedContent = '';
243
+
244
+ const stream = new ReadableStream({
245
+ async start(controller) {
246
+ try {
247
+ while (true) {
248
+ const { done, value } = await reader.read();
249
+ if (done) {
250
+ // flush decoder 残留字节
251
+ streamedContent += decoder.decode();
252
+ // 流结束,组装完整的消息对象
253
+ try {
254
+ const events = streamedContent.split('\n\n')
255
+ .filter(block => block.trim())
256
+ .map(block => {
257
+ // SSE 块可能包含多行: event: xxx\ndata: {...}
258
+ const lines = block.split('\n');
259
+ const dataLine = lines.find(l => l.startsWith('data: '));
260
+ if (dataLine) {
261
+ try {
262
+ return JSON.parse(dataLine.substring(6));
263
+ } catch {
264
+ return dataLine.substring(6);
265
+ }
266
+ }
267
+ return null;
268
+ })
269
+ .filter(Boolean);
270
+
271
+ // 组装完整的 message 对象
272
+ const assembledMessage = assembleStreamMessage(events);
273
+
274
+ // 直接使用组装后的 message 对象作为 response.body
275
+ requestEntry.response.body = assembledMessage;
276
+ appendFileSync(LOG_FILE, JSON.stringify(requestEntry, null, 2) + '\n---\n');
277
+ } catch (err) {
278
+ requestEntry.response.body = streamedContent.slice(0, 1000);
279
+ appendFileSync(LOG_FILE, JSON.stringify(requestEntry, null, 2) + '\n---\n');
280
+ }
281
+ controller.close();
282
+ break;
283
+ }
284
+ const chunk = decoder.decode(value, { stream: true });
285
+ streamedContent += chunk;
286
+ controller.enqueue(value);
287
+ }
288
+ } catch (err) {
289
+ controller.error(err);
290
+ }
291
+ }
292
+ });
293
+
294
+ // 返回带有代理流的新响应
295
+ return new Response(stream, {
296
+ status: response.status,
297
+ statusText: response.statusText,
298
+ headers: response.headers
299
+ });
300
+ } catch (err) {
301
+ requestEntry.response = {
302
+ status: response.status,
303
+ statusText: response.statusText,
304
+ headers: Object.fromEntries(response.headers.entries()),
305
+ body: '[Streaming Response - Capture failed]'
306
+ };
307
+ appendFileSync(LOG_FILE, JSON.stringify(requestEntry, null, 2) + '\n---\n');
308
+ }
309
+ } else {
310
+ // 对于非流式响应,可以安全读取body
311
+ try {
312
+ const clonedResponse = response.clone();
313
+ const responseText = await clonedResponse.text();
314
+ let responseData = null;
315
+
316
+ try {
317
+ responseData = JSON.parse(responseText);
318
+ } catch {
319
+ responseData = responseText.slice(0, 1000);
320
+ }
321
+
322
+ requestEntry.response = {
323
+ status: response.status,
324
+ statusText: response.statusText,
325
+ headers: Object.fromEntries(response.headers.entries()),
326
+ body: responseData
327
+ };
328
+
329
+ appendFileSync(LOG_FILE, JSON.stringify(requestEntry, null, 2) + '\n---\n');
330
+ } catch (err) {
331
+ appendFileSync(LOG_FILE, JSON.stringify(requestEntry, null, 2) + '\n---\n');
332
+ }
333
+ }
334
+ }
335
+
336
+ return response;
337
+ };
338
+ }
339
+
340
+ // 自动执行拦截器设置
341
+ setupInterceptor();