126 lines
3.7 KiB
TypeScript
126 lines
3.7 KiB
TypeScript
import { feature } from 'bun:bundle'
|
||
import { useEffect, useRef } from 'react'
|
||
import {
|
||
getTerminalFocusState,
|
||
subscribeTerminalFocus,
|
||
} from '../ink/terminal-focus-state.js'
|
||
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'
|
||
import { generateAwaySummary } from '../services/awaySummary.js'
|
||
import type { Message } from '../types/message.js'
|
||
import { createAwaySummaryMessage } from '../utils/messages.js'
|
||
|
||
const BLUR_DELAY_MS = 5 * 60_000
|
||
|
||
type SetMessages = (updater: (prev: Message[]) => Message[]) => void
|
||
|
||
function hasSummarySinceLastUserTurn(messages: readonly Message[]): boolean {
|
||
for (let i = messages.length - 1; i >= 0; i--) {
|
||
const m = messages[i]!
|
||
if (m.type === 'user' && !m.isMeta && !m.isCompactSummary) return false
|
||
if (m.type === 'system' && m.subtype === 'away_summary') return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
/**
|
||
* 在终端失焦 5 分钟后追加"离开期间摘要"消息。
|
||
* 仅在以下条件满足时触发:(a) 失焦后 5 分钟,(b) 当前无进行中的对话,
|
||
* (c) 自上次用户消息以来没有已有的 away_summary。
|
||
*
|
||
* Focus state 为 'unknown'(终端不支持 DECSET 1004)时无操作。
|
||
*/
|
||
export function useAwaySummary(
|
||
messages: readonly Message[],
|
||
setMessages: SetMessages,
|
||
isLoading: boolean,
|
||
): void {
|
||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||
const abortRef = useRef<AbortController | null>(null)
|
||
const messagesRef = useRef(messages)
|
||
const isLoadingRef = useRef(isLoading)
|
||
const pendingRef = useRef(false)
|
||
const generateRef = useRef<(() => Promise<void>) | null>(null)
|
||
|
||
messagesRef.current = messages
|
||
isLoadingRef.current = isLoading
|
||
|
||
// 3P 默认值: false
|
||
const gbEnabled = getFeatureValue_CACHED_MAY_BE_STALE(
|
||
'tengu_sedge_lantern',
|
||
false,
|
||
)
|
||
|
||
useEffect(() => {
|
||
if (!feature('AWAY_SUMMARY')) return
|
||
if (!gbEnabled) return
|
||
|
||
function clearTimer(): void {
|
||
if (timerRef.current !== null) {
|
||
clearTimeout(timerRef.current)
|
||
timerRef.current = null
|
||
}
|
||
}
|
||
|
||
function abortInFlight(): void {
|
||
abortRef.current?.abort()
|
||
abortRef.current = null
|
||
}
|
||
|
||
async function generate(): Promise<void> {
|
||
pendingRef.current = false
|
||
if (hasSummarySinceLastUserTurn(messagesRef.current)) return
|
||
abortInFlight()
|
||
const controller = new AbortController()
|
||
abortRef.current = controller
|
||
const text = await generateAwaySummary(
|
||
messagesRef.current,
|
||
controller.signal,
|
||
)
|
||
if (controller.signal.aborted || text === null) return
|
||
setMessages(prev => [...prev, createAwaySummaryMessage(text)])
|
||
}
|
||
|
||
function onBlurTimerFire(): void {
|
||
timerRef.current = null
|
||
if (isLoadingRef.current) {
|
||
pendingRef.current = true
|
||
return
|
||
}
|
||
void generate()
|
||
}
|
||
|
||
function onFocusChange(): void {
|
||
const state = getTerminalFocusState()
|
||
if (state === 'blurred') {
|
||
clearTimer()
|
||
timerRef.current = setTimeout(onBlurTimerFire, BLUR_DELAY_MS)
|
||
} else if (state === 'focused') {
|
||
clearTimer()
|
||
abortInFlight()
|
||
pendingRef.current = false
|
||
}
|
||
// 'unknown' → 无操作
|
||
}
|
||
|
||
const unsubscribe = subscribeTerminalFocus(onFocusChange)
|
||
// 处理效果挂载时已经失焦的情况
|
||
onFocusChange()
|
||
generateRef.current = generate
|
||
|
||
return () => {
|
||
unsubscribe()
|
||
clearTimer()
|
||
abortInFlight()
|
||
generateRef.current = null
|
||
}
|
||
}, [gbEnabled, setMessages])
|
||
|
||
// 定时器在对话期间触发 → 在对话结束时触发(如果仍然失焦)
|
||
useEffect(() => {
|
||
if (isLoading) return
|
||
if (!pendingRef.current) return
|
||
if (getTerminalFocusState() !== 'blurred') return
|
||
void generateRef.current?.()
|
||
}, [isLoading])
|
||
}
|