Skip to content

Commit e60cf06

Browse files
author
Bot
committed
2 parents 5591d9b + dad3ad2 commit e60cf06

9 files changed

Lines changed: 216 additions & 12 deletions

File tree

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@
2222
| Web Search | 内置网页搜索工具 | [文档](https://ccb.agent-aura.top/docs/features/web-browser-tool) |
2323
| 自定义模型供应商 | OpenAI/Anthropic/Gemini/Grok 兼容 | [文档](https://ccb.agent-aura.top/docs/features/custom-platform-login) |
2424
| Voice Mode | Push-to-Talk 语音输入 | [文档](https://ccb.agent-aura.top/docs/features/voice-mode) |
25-
| Computer Use / Chrome Use | 截图、键鼠控制、浏览器操控 | [Computer Use](https://ccb.agent-aura.top/docs/features/computer-use)<br>[Chrome Use](https://ccb.agent-aura.top/docs/features/claude-in-chrome-mcp) |
26-
| Sentry / GrowthBook 企业监控 | 企业级错误追踪与特性开关 | [Sentry](https://ccb.agent-aura.top/docs/internals/sentry-setup)<br>[GrowthBook](https://ccb.agent-aura.top/docs/internals/growthbook-adapter) |
25+
| Computer Use | 屏幕截图、键鼠控制 | [文档](https://ccb.agent-aura.top/docs/features/computer-use) |
26+
| Chrome Use | 浏览器自动化、表单填写、数据抓取 | [魔改版](docs/features/chrome-use-mcp) [原生版](https://ccb.agent-aura.top/docs/features/claude-in-chrome-mcp) |
27+
| Sentry | 企业级错误追踪 | [文档](https://ccb.agent-aura.top/docs/internals/sentry-setup) |
28+
| GrowthBook | 企业级特性开关 | [文档](https://ccb.agent-aura.top/docs/internals/growthbook-adapter) |
2729
| Langfuse 监控 | LLM 调用/工具执行/多 Agent 全链路追踪 | [文档](https://ccb.agent-aura.top/docs/features/langfuse-monitoring) |
2830
| Poor Mode | 穷鬼模式,关闭记忆提取和键入建议 | /poor 可以开关 |
2931

build.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,15 @@ if (typeof globalThis.Bun === "undefined") {
145145
function $(parts, ...args) {
146146
throw new Error("Bun.$ shell API is not available in Node.js. Use Bun runtime for this feature.");
147147
}
148-
globalThis.Bun = { which, $ };
148+
function hash(data, seed) {
149+
let h = ((seed || 0) ^ 0x811c9dc5) >>> 0;
150+
for (let i = 0; i < data.length; i++) {
151+
h ^= data.charCodeAt(i);
152+
h = Math.imul(h, 0x01000193) >>> 0;
153+
}
154+
return h;
155+
}
156+
globalThis.Bun = { which, $, hash };
149157
}
150158
import "./cli.js"
151159
`

bun.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/features/chrome-use-mcp.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Chrome Use — 浏览器自动化快速指南
2+
3+
让 Claude Code 直接控制你的 Chrome 浏览器,用自然语言完成网页操作。
4+
5+
## 快速开始(3 分钟)
6+
7+
### 第一步:安装 Chrome 扩展
8+
9+
1. 下载扩展:https://github.com/hangwin/mcp-chrome/releases(下载最新 zip)
10+
2. 解压 zip 文件
11+
3. 打开 Chrome 访问 `chrome://extensions/`
12+
4. 开启右上角「开发者模式」
13+
5. 点击「加载已解压的扩展程序」,选择解压后的文件夹
14+
15+
### 第二步:启动 Claude Code
16+
17+
```bash
18+
bun run dev
19+
ccb # 或者 ccb 安装版也行
20+
```
21+
22+
### 第三步:启用 Chrome MCP
23+
24+
1. 在 REPL 中输入 `/mcp` 打开 MCP 面板
25+
2. 找到 `mcp-chrome`,按空格键启用
26+
3. 按 Enter 确认
27+
28+
## 相关文档
29+
30+
- GitHub 仓库:https://github.com/hangwin/mcp-chrome

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656
"rcs": "bun run scripts/rcs.ts"
5757
},
5858
"dependencies": {
59-
"@claude-code-best/mcp-chrome-bridge": "^2.0.6"
59+
"@claude-code-best/mcp-chrome-bridge": "^2.0.7"
6060
},
6161
"devDependencies": {
6262
"@types/he": "^1.2.3",

src/entrypoints/init.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import { isBetaTracingEnabled } from '../utils/telemetry/betaSessionTracing.js'
4949
import { getTelemetryAttributes } from '../utils/telemetryAttributes.js'
5050
import { setShellIfWindows } from '../utils/windowsPaths.js'
5151
import { initSentry } from '../utils/sentry.js'
52+
import { initUser } from '../utils/user.js'
5253
import { initLangfuse, shutdownLangfuse } from '../services/langfuse/index.js'
5354

5455
// initialize1PEventLogging is dynamically imported to defer OpenTelemetry sdk-logs/resources
@@ -156,6 +157,8 @@ export const init = memoize(async (): Promise<void> => {
156157
initSentry()
157158

158159
// Initialize Langfuse tracing (no-op if keys not configured)
160+
// Pre-warm user email cache so Langfuse traces include userId
161+
await initUser()
159162
initLangfuse()
160163
registerCleanup(shutdownLangfuse)
161164

src/services/api/claude.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2907,7 +2907,12 @@ async function* queryModel(
29072907
provider: getAPIProvider(),
29082908
input: convertMessagesToLangfuse(messagesForAPI, systemPrompt),
29092909
output: convertOutputToLangfuse(newMessages),
2910-
usage: { input_tokens: usage.input_tokens, output_tokens: usage.output_tokens },
2910+
usage: {
2911+
input_tokens: usage.input_tokens,
2912+
output_tokens: usage.output_tokens,
2913+
cache_creation_input_tokens: usage.cache_creation_input_tokens,
2914+
cache_read_input_tokens: usage.cache_read_input_tokens,
2915+
},
29112916
startTime: new Date(startIncludingRetries),
29122917
endTime: new Date(),
29132918
completionStartTime: ttftMs > 0 ? new Date(start + ttftMs) : undefined,

src/services/langfuse/__tests__/langfuse.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const mockRootEnd = mock(() => {})
2929
// Mock LangfuseOtelSpanAttributes (re-exported from @langfuse/core)
3030
const mockLangfuseOtelSpanAttributes: Record<string, string> = {
3131
TRACE_SESSION_ID: 'session.id',
32+
TRACE_USER_ID: 'user.id',
3233
OBSERVATION_TYPE: 'observation.type',
3334
OBSERVATION_INPUT: 'observation.input',
3435
OBSERVATION_OUTPUT: 'observation.output',
@@ -74,6 +75,14 @@ mock.module('src/utils/debug.js', () => ({
7475
logForDebugging: mock(() => {}),
7576
}))
7677

78+
// Mock user data — resolveLangfuseUserId uses getCoreUserData().email and .deviceId
79+
mock.module('src/utils/user.js', () => ({
80+
getCoreUserData: mock(() => ({
81+
email: 'test-device-id',
82+
deviceId: 'test-device-id',
83+
})),
84+
}))
85+
7786
describe('Langfuse integration', () => {
7887
beforeEach(() => {
7988
// Reset env
@@ -275,6 +284,48 @@ describe('Langfuse integration', () => {
275284
}))
276285
expect(mockRootEnd).toHaveBeenCalled()
277286
})
287+
288+
test('includes cache tokens in usageDetails when provided', async () => {
289+
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
290+
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
291+
const { createTrace, recordLLMObservation } = await import('../tracing.js')
292+
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
293+
mockStartObservation.mockClear()
294+
mockRootUpdate.mockClear()
295+
recordLLMObservation(span, {
296+
model: 'claude-3',
297+
provider: 'firstParty',
298+
input: [],
299+
output: [],
300+
usage: { input_tokens: 10000, output_tokens: 50, cache_creation_input_tokens: 2000, cache_read_input_tokens: 7000 },
301+
})
302+
expect(mockRootUpdate).toHaveBeenCalledWith(expect.objectContaining({
303+
usageDetails: {
304+
input: 19000, // 10000 + 2000 + 7000
305+
output: 50,
306+
cache_read: 7000,
307+
cache_creation: 2000,
308+
},
309+
}))
310+
})
311+
312+
test('omits cache fields when not provided', async () => {
313+
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
314+
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
315+
const { createTrace, recordLLMObservation } = await import('../tracing.js')
316+
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
317+
mockRootUpdate.mockClear()
318+
recordLLMObservation(span, {
319+
model: 'claude-3',
320+
provider: 'firstParty',
321+
input: [],
322+
output: [],
323+
usage: { input_tokens: 100, output_tokens: 20 },
324+
})
325+
expect(mockRootUpdate).toHaveBeenCalledWith(expect.objectContaining({
326+
usageDetails: { input: 100, output: 20 },
327+
}))
328+
})
278329
})
279330

280331
describe('recordToolObservation', () => {
@@ -477,6 +528,70 @@ describe('Langfuse integration', () => {
477528
})
478529
})
479530

531+
describe('createTrace with username', () => {
532+
test('sets user.id attribute when username is provided', async () => {
533+
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
534+
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
535+
mockSetAttribute.mockClear()
536+
const { createTrace } = await import('../tracing.js')
537+
const span = createTrace({
538+
sessionId: 's1',
539+
model: 'claude-3',
540+
provider: 'firstParty',
541+
username: 'user@example.com',
542+
})
543+
expect(span).not.toBeNull()
544+
expect(mockSetAttribute).toHaveBeenCalledWith('user.id', 'user@example.com')
545+
})
546+
547+
test('falls back to LANGFUSE_USER_ID env when username not provided', async () => {
548+
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
549+
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
550+
process.env.LANGFUSE_USER_ID = 'env-user@test.com'
551+
mockSetAttribute.mockClear()
552+
const { createTrace } = await import('../tracing.js')
553+
const span = createTrace({
554+
sessionId: 's1',
555+
model: 'claude-3',
556+
provider: 'firstParty',
557+
})
558+
expect(span).not.toBeNull()
559+
expect(mockSetAttribute).toHaveBeenCalledWith('user.id', 'env-user@test.com')
560+
delete process.env.LANGFUSE_USER_ID
561+
})
562+
563+
test('falls back to deviceId when neither username nor env is provided', async () => {
564+
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
565+
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
566+
delete process.env.LANGFUSE_USER_ID
567+
mockSetAttribute.mockClear()
568+
const { createTrace } = await import('../tracing.js')
569+
createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
570+
// Falls back to getCoreUserData().deviceId (mocked as 'test-device-id')
571+
expect(mockSetAttribute).toHaveBeenCalledWith('user.id', 'test-device-id')
572+
})
573+
574+
test('username takes precedence over LANGFUSE_USER_ID env', async () => {
575+
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
576+
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
577+
process.env.LANGFUSE_USER_ID = 'env-user@test.com'
578+
mockSetAttribute.mockClear()
579+
const { createTrace } = await import('../tracing.js')
580+
createTrace({
581+
sessionId: 's1',
582+
model: 'claude-3',
583+
provider: 'firstParty',
584+
username: 'param-user@test.com',
585+
})
586+
const userIdCalls = mockSetAttribute.mock.calls.filter(
587+
(call: unknown[]) => Array.isArray(call) && call[0] === 'user.id',
588+
)
589+
expect(userIdCalls.length).toBe(1)
590+
expect((userIdCalls[0] as unknown[])[1]).toBe('param-user@test.com')
591+
delete process.env.LANGFUSE_USER_ID
592+
})
593+
})
594+
480595
describe('nested agent scenario', () => {
481596
test('sub-agent trace shares sessionId with parent', async () => {
482597
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'

src/services/langfuse/tracing.ts

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,17 @@ import type { LangfuseSpan, LangfuseGeneration, LangfuseAgent } from '@langfuse/
33
import { isLangfuseEnabled } from './client.js'
44
import { sanitizeToolInput, sanitizeToolOutput } from './sanitize.js'
55
import { logForDebugging } from 'src/utils/debug.js'
6+
import { getCoreUserData } from 'src/utils/user.js'
67

78
export type { LangfuseSpan }
89

910
// Root trace is an agent observation — represents one full agentic turn/session
10-
type RootTrace = LangfuseAgent & { _sessionId?: string }
11+
type RootTrace = LangfuseAgent & { _sessionId?: string; _userId?: string }
12+
13+
/** Resolve the user ID for Langfuse traces: explicit param > env var > email > deviceId */
14+
function resolveLangfuseUserId(username?: string): string | undefined {
15+
return username ?? process.env.LANGFUSE_USER_ID ?? getCoreUserData().email ?? getCoreUserData().deviceId
16+
}
1117

1218
export function createTrace(params: {
1319
sessionId: string
@@ -16,6 +22,7 @@ export function createTrace(params: {
1622
input?: unknown
1723
name?: string
1824
querySource?: string
25+
username?: string
1926
}): LangfuseSpan | null {
2027
if (!isLangfuseEnabled()) return null
2128
try {
@@ -31,6 +38,11 @@ export function createTrace(params: {
3138
}, { asType: 'agent' }) as RootTrace
3239
rootSpan.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_SESSION_ID, params.sessionId)
3340
rootSpan._sessionId = params.sessionId
41+
const userId = resolveLangfuseUserId(params.username)
42+
if (userId) {
43+
rootSpan.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_USER_ID, userId)
44+
rootSpan._userId = userId
45+
}
3446
logForDebugging(`[langfuse] Trace created: ${rootSpan.id}`)
3547
return rootSpan as unknown as LangfuseSpan
3648
} catch (e) {
@@ -56,7 +68,12 @@ export function recordLLMObservation(
5668
provider: string
5769
input: unknown
5870
output: unknown
59-
usage: { input_tokens: number; output_tokens: number }
71+
usage: {
72+
input_tokens: number
73+
output_tokens: number
74+
cache_creation_input_tokens?: number
75+
cache_read_input_tokens?: number
76+
}
6077
startTime?: Date
6178
endTime?: Date
6279
completionStartTime?: Date
@@ -87,17 +104,27 @@ export function recordLLMObservation(
87104
},
88105
)
89106

90-
// Propagate session ID to generation span so Langfuse links it correctly
107+
// Propagate session ID and user ID to generation span so Langfuse links it correctly
91108
const sessionId = (rootSpan as unknown as RootTrace)._sessionId
92109
if (sessionId) {
93110
gen.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_SESSION_ID, sessionId)
94111
}
112+
const userId = (rootSpan as unknown as RootTrace)._userId
113+
if (userId) {
114+
gen.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_USER_ID, userId)
115+
}
95116

117+
// Anthropic splits input into uncached + cache_read + cache_creation.
118+
// Langfuse's "input" should be the total prompt tokens so cost calc is correct.
119+
const cacheRead = params.usage.cache_read_input_tokens ?? 0
120+
const cacheCreation = params.usage.cache_creation_input_tokens ?? 0
96121
gen.update({
97122
output: params.output,
98123
usageDetails: {
99-
input: params.usage.input_tokens,
124+
input: params.usage.input_tokens + cacheCreation + cacheRead,
100125
output: params.usage.output_tokens,
126+
...(cacheRead > 0 && { cache_read: cacheRead }),
127+
...(cacheCreation > 0 && { cache_creation: cacheCreation }),
101128
},
102129
})
103130

@@ -142,11 +169,15 @@ export function recordToolObservation(
142169
},
143170
)
144171

145-
// Propagate session ID to tool span so Langfuse links it correctly
172+
// Propagate session ID and user ID to tool span so Langfuse links it correctly
146173
const sessionId = (rootSpan as unknown as RootTrace)._sessionId
147174
if (sessionId) {
148175
toolObs.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_SESSION_ID, sessionId)
149176
}
177+
const userId = (rootSpan as unknown as RootTrace)._userId
178+
if (userId) {
179+
toolObs.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_USER_ID, userId)
180+
}
150181

151182
toolObs.update({
152183
output: sanitizeToolOutput(params.toolName, params.output),
@@ -190,6 +221,10 @@ export function createToolBatchSpan(
190221
if (sessionId) {
191222
batchSpan.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_SESSION_ID, sessionId)
192223
}
224+
const userId = (rootSpan as unknown as RootTrace)._userId
225+
if (userId) {
226+
batchSpan.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_USER_ID, userId)
227+
}
193228

194229
logForDebugging(`[langfuse] Tool batch span created: ${batchSpan.id} (tools=${params.toolNames.join(',')})`)
195230
return batchSpan
@@ -216,6 +251,7 @@ export function createSubagentTrace(params: {
216251
model: string
217252
provider: string
218253
input?: unknown
254+
username?: string
219255
}): LangfuseSpan | null {
220256
if (!isLangfuseEnabled()) return null
221257
try {
@@ -230,6 +266,11 @@ export function createSubagentTrace(params: {
230266
}, { asType: 'agent' }) as RootTrace
231267
rootSpan.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_SESSION_ID, params.sessionId)
232268
rootSpan._sessionId = params.sessionId
269+
const userId = resolveLangfuseUserId(params.username)
270+
if (userId) {
271+
rootSpan.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_USER_ID, userId)
272+
rootSpan._userId = userId
273+
}
233274
logForDebugging(`[langfuse] Sub-agent trace created: ${rootSpan.id} (type=${params.agentType})`)
234275
return rootSpan as unknown as LangfuseSpan
235276
} catch (e) {

0 commit comments

Comments
 (0)