Files
claude-code/src/buddy/companionReact.ts
claude-code-best e897385a7e Feature/docker/run (#1268)
* feat: 删除垃圾更改

* fix: 消除生产代码中的 as any 类型不安全模式

- API 兼容层(openai/grok/gemini): 利用 BetaRawMessageStreamEvent 的
  discriminated union 在 switch/case 中直接属性访问,消除 ~29 个 as any
- ConsoleOAuthFlow: 用 as unknown as Parameters<typeof> 替代 as any
- performanceShim: 用 Record<string, unknown> 和显式类型断言替代 as any
- companionReact/auth: 直接访问已有类型属性消除 as any
- sliceAnsi/textHighlighting: 用 as Char 替代 as any(Token 联合类型收窄)
- ccrClient: 利用 RequestResult 类型收窄直接访问 retryAfterMs
- outputsScanner: 用 TurnStartTime.turnStartTime 属性访问替代双重断言
- plans: 用显式数组类型替代 as any[]
- FeedbackSurvey: 用 in 操作符和 Parameters<typeof> 替代 as any
- messageQueueManager: 用 Record<string, unknown> 替代 as any
- mcp.ts: 用 in 操作符类型守卫替代 as any

precheck 通过: typecheck 零错误 + 5420 测试全部通过 + lint 通过

* fix: 将 pipeIpc 添加到 AppState 类型声明,消除 4 个 as any

- AppStateStore: 添加 pipeIpc?: PipeIpcState 可选字段
- PromptInputFooter: 直接访问 s.pipeIpc
- useBackgroundTaskNavigation: 直接访问 s.pipeIpc
- usePipeRouter: 直接访问 store.getState().pipeIpc
- REPL.tsx: 移除 getPipeIpc(s as any) 中的 as any

precheck 通过

* fix: 消除 UltraplanChoiceDialog 中的 wheelDown/wheelUp as any

Ink Key 类型已包含 wheelDown/wheelUp 属性,直接访问即可。

* fix: 消除 sideQuestion.ts 中的 2 个 as any

- toolUse.name: 使用 as unknown as { name: string } 双重断言
- apiErr.error: 使用 as Parameters<typeof formatAPIError>[0] 类型参数

* fix: 为 auto dream 添加 maxTurns: 20 限制,防止单次执行消耗过多 token

* fix: 补充 SAFE_ENV_VARS 中缺失的 OpenAI/Gemini/Grok provider 环境变量

项目级 settings.local.json 的 env 字段在 trust dialog 之前只有
SAFE_ENV_VARS 白名单中的变量会被应用到 process.env。
OPENAI_API_KEY、OPENAI_BASE_URL 等关键变量不在白名单中,
导致容器中通过 settings.local.json 配置 OpenAI 协议时认证失败。

* fix: 修复 goalState.js 模块不存在的类型错误

* fix: 增强 providers 测试的环境变量隔离,防止 mock 污染

* fix: 内联 providers 测试逻辑,彻底隔离 mock 污染

测试不再 import providers.ts(其默认参数触发 getInitialSettings 全链),
改为内联纯函数逻辑,从根源消除 CI 上其他测试 mock.module 污染。

* fix: 添加 goalState 模块存根,修复 CI 构建打包解析失败

CI 中的 autonomy-lifecycle-user-flow 集成测试会执行 build.ts 打包 CLI。
此前 PromptInputFooterLeftSide.tsx 中 require('../../services/goal/goalState.js')
的路径在源码中不存在,打包器报 Could not resolve,导致 (unnamed) 测试失败。

新增 src/services/goal/goalState.ts 存根模块(getGoal 返回 null,组件不渲染),
让打包器在构建期可以解析该 require 路径。同时把 PromptInputFooterLeftSide.tsx
里两处 as unknown as 内联类型签名换成 as typeof import(...),让类型直接来自
存根模块,避免类型定义重复。
2026-06-11 17:59:08 +08:00

161 lines
4.9 KiB
TypeScript

/**
* Companion reaction system — aligns with official ZUK + Dc8 pattern.
*
* Called from REPL.tsx after each query turn. Checks mute state, frequency
* limits, and @-mention detection, then calls the buddy_react API to
* generate a reaction shown in the CompanionSprite speech bubble.
*/
import { getCompanion } from './companion.js'
import { getGlobalConfig } from '../utils/config.js'
import { getClaudeAIOAuthTokens } from '../utils/auth.js'
import { getOauthConfig } from '../constants/oauth.js'
import { getUserAgent } from '../utils/http.js'
import type { Message } from '../types/message.js'
// ─── Rate limiting ──────────────────────────────────
let lastReactTime = 0
const MIN_INTERVAL_MS = 45_000 // official is roughly 30-60s
// ─── Recent reactions (avoid repetition) ────────────
const recentReactions: string[] = []
const MAX_RECENT = 8
// ─── Public API ─────────────────────────────────────
/**
* Trigger a companion reaction after a query turn.
*
* Mirrors official `ZUK()`:
* 1. Check companion exists and is not muted
* 2. Detect if user @-mentioned companion by name
* 3. Apply rate limiting (skip if not addressed and too soon)
* 4. Build conversation transcript
* 5. Call buddy_react API
* 6. Pass reaction text to setReaction callback
*/
export function triggerCompanionReaction(
messages: Message[],
setReaction: (text: string | undefined) => void,
): void {
const companion = getCompanion()
if (!companion || getGlobalConfig().companionMuted) return
const addressed = isAddressed(messages, companion.name)
const now = Date.now()
if (!addressed && now - lastReactTime < MIN_INTERVAL_MS) return
const transcript = buildTranscript(messages)
if (!transcript.trim()) return
lastReactTime = now
void callBuddyReactAPI(companion, transcript, addressed)
.then(reaction => {
if (!reaction) return
recentReactions.push(reaction)
if (recentReactions.length > MAX_RECENT) recentReactions.shift()
setReaction(reaction)
})
.catch(() => {})
}
// ─── Helpers ────────────────────────────────────────
function isAddressed(messages: Message[], name: string): boolean {
const pattern = new RegExp(`\\b${escapeRegex(name)}\\b`, 'i')
for (
let i = messages.length - 1;
i >= Math.max(0, messages.length - 3);
i--
) {
const m = messages[i]
if (m?.type !== 'user') continue
const content = m.message?.content
if (typeof content === 'string' && pattern.test(content)) return true
}
return false
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function buildTranscript(messages: Message[]): string {
return messages
.slice(-12)
.filter(m => m.type === 'user' || m.type === 'assistant')
.map(m => {
const role = m.type === 'user' ? 'user' : 'claude'
const content = m.message?.content
const text =
typeof content === 'string'
? content.slice(0, 300)
: Array.isArray(content)
? content
.filter((b: any) => b?.type === 'text')
.map((b: any) => b.text)
.join(' ')
.slice(0, 300)
: ''
return `${role}: ${text}`
})
.join('\n')
.slice(0, 5000)
}
// ─── API call ───────────────────────────────────────
async function callBuddyReactAPI(
companion: {
name: string
personality: string
species: string
rarity: string
stats: Record<string, number>
},
transcript: string,
addressed: boolean,
): Promise<string | null> {
const tokens = getClaudeAIOAuthTokens()
if (!tokens?.accessToken) return null
const orgId = getGlobalConfig().oauthAccount?.organizationUuid
if (!orgId) return null
const baseUrl = getOauthConfig().BASE_API_URL
const url = `${baseUrl}/api/organizations/${orgId}/claude_code/buddy_react`
const resp = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
'Content-Type': 'application/json',
'User-Agent': getUserAgent(),
},
body: JSON.stringify({
name: companion.name.slice(0, 32),
personality: companion.personality.slice(0, 200),
species: companion.species,
rarity: companion.rarity,
stats: companion.stats,
transcript,
reason: addressed ? 'addressed' : 'turn',
recent: recentReactions.map(r => r.slice(0, 200)),
addressed,
}),
signal: AbortSignal.timeout(10_000),
})
if (!resp.ok) return null
try {
const data = (await resp.json()) as { reaction?: string }
return data.reaction?.trim() || null
} catch {
return null
}
}