mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-10-25 17:36:32 +08:00
2488 lines
106 KiB
TypeScript
2488 lines
106 KiB
TypeScript
import { createHash } from 'crypto'
|
|
import { tag as tagJieba } from 'jieba-wasm/node'
|
|
|
|
export const AGENT_RAW_PAGE_FORMAT_VERSION = 1
|
|
export const AGENT_RAW_PAGE_MIN_TOKENS = 800
|
|
export const AGENT_RAW_PAGE_DEFAULT_TOKENS = 10_000
|
|
export const AGENT_RAW_PAGE_MAX_TOKENS = 16_000
|
|
export const AGENT_TIMELINE_WINDOW_DEFAULT_TOKENS = 1_200
|
|
export const AGENT_TIMELINE_WINDOW_MAX_TOKENS = 3_000
|
|
|
|
export type AgentRawPageDirection = 'forward' | 'backward'
|
|
|
|
export type AgentTimelineSampleSelectionMode = 'uniform' | 'mixed'
|
|
|
|
export type AgentManifestStructuralSession = {
|
|
sessionId: string
|
|
displayName?: string
|
|
messageCount?: number
|
|
activeDayCount?: number
|
|
activeMonthCount?: number
|
|
spanMonthCount?: number
|
|
firstAt?: string
|
|
lastAt?: string
|
|
}
|
|
|
|
export type AgentUnreadManifestStructuralAnchor = AgentManifestStructuralSession & {
|
|
structuralDimensions: Array<'message_count' | 'active_months' | 'time_span' | 'recent_activity'>
|
|
bestStructuralRank: number
|
|
}
|
|
|
|
function normalizedManifestSourceKey(value: unknown): string {
|
|
return String(value ?? '').replace(/\u0000/g, '').trim().toLocaleLowerCase()
|
|
}
|
|
|
|
/**
|
|
* 返回确定性的导航盲区,而不是语义候选项。
|
|
* 每个维度都会在完整的输入目录上排序,随后只保留接近结构极值的未读行。
|
|
*/
|
|
export function selectAgentUnreadManifestStructuralAnchors(
|
|
sessions: readonly AgentManifestStructuralSession[],
|
|
readSourceKeys: Iterable<string>,
|
|
limit = 6,
|
|
): AgentUnreadManifestStructuralAnchor[] {
|
|
const seenIds = new Set<string>()
|
|
const rows = sessions.filter((session) => {
|
|
const sessionId = normalizedManifestSourceKey(session.sessionId)
|
|
if (!sessionId || seenIds.has(sessionId)) return false
|
|
seenIds.add(sessionId)
|
|
return true
|
|
})
|
|
if (rows.length === 0 || limit <= 0) return []
|
|
|
|
const readKeys = new Set(Array.from(readSourceKeys, normalizedManifestSourceKey).filter(Boolean))
|
|
const isRead = (session: AgentManifestStructuralSession) => (
|
|
readKeys.has(normalizedManifestSourceKey(session.sessionId))
|
|
|| readKeys.has(normalizedManifestSourceKey(session.displayName))
|
|
)
|
|
const structuralWindow = Math.min(16, Math.max(8, Math.ceil(rows.length * 0.12)))
|
|
const dimensions: Array<{
|
|
name: AgentUnreadManifestStructuralAnchor['structuralDimensions'][number]
|
|
value: (session: AgentManifestStructuralSession) => number
|
|
}> = [
|
|
{ name: 'message_count', value: (session) => Math.max(0, Number(session.messageCount) || 0) },
|
|
{ name: 'active_months', value: (session) => Math.max(0, Number(session.activeMonthCount) || 0) },
|
|
{ name: 'time_span', value: (session) => Math.max(0, Number(session.spanMonthCount) || 0) },
|
|
{
|
|
name: 'recent_activity',
|
|
value: (session) => {
|
|
const timestamp = Date.parse(String(session.lastAt || '').replace(' ', 'T'))
|
|
return Number.isFinite(timestamp) ? timestamp : 0
|
|
},
|
|
},
|
|
]
|
|
const candidates = new Map<string, {
|
|
session: AgentManifestStructuralSession
|
|
ranks: Map<AgentUnreadManifestStructuralAnchor['structuralDimensions'][number], number>
|
|
}>()
|
|
|
|
for (const dimension of dimensions) {
|
|
const ranked = [...rows]
|
|
.filter((session) => dimension.value(session) > 0)
|
|
.sort((left, right) => (
|
|
dimension.value(right) - dimension.value(left)
|
|
|| normalizedManifestSourceKey(left.sessionId).localeCompare(normalizedManifestSourceKey(right.sessionId))
|
|
))
|
|
ranked.slice(0, structuralWindow).forEach((session, index) => {
|
|
if (isRead(session)) return
|
|
const key = normalizedManifestSourceKey(session.sessionId)
|
|
const candidate = candidates.get(key) || { session, ranks: new Map() }
|
|
candidate.ranks.set(dimension.name, index + 1)
|
|
candidates.set(key, candidate)
|
|
})
|
|
}
|
|
|
|
return Array.from(candidates.values())
|
|
.sort((left, right) => {
|
|
const leftBestRank = Math.min(...left.ranks.values())
|
|
const rightBestRank = Math.min(...right.ranks.values())
|
|
const leftScore = Array.from(left.ranks.values()).reduce((sum, rank) => sum + (structuralWindow - rank + 1), 0)
|
|
const rightScore = Array.from(right.ranks.values()).reduce((sum, rank) => sum + (structuralWindow - rank + 1), 0)
|
|
return right.ranks.size - left.ranks.size
|
|
|| rightScore - leftScore
|
|
|| leftBestRank - rightBestRank
|
|
|| normalizedManifestSourceKey(left.session.sessionId).localeCompare(normalizedManifestSourceKey(right.session.sessionId))
|
|
})
|
|
.slice(0, Math.max(1, Math.floor(limit)))
|
|
.map(({ session, ranks }) => ({
|
|
...session,
|
|
structuralDimensions: dimensions
|
|
.map((dimension) => dimension.name)
|
|
.filter((dimension) => ranks.has(dimension)),
|
|
bestStructuralRank: Math.min(...ranks.values()),
|
|
}))
|
|
}
|
|
|
|
export type AgentTimelineSampleDate = {
|
|
date: string
|
|
count: number
|
|
reason: 'range-start' | 'range-end' | 'time-quantile' | 'segment-activity-peak' | 'activity-change-before' | 'activity-change-after' | 'trailing-change-before' | 'trailing-change-after'
|
|
}
|
|
|
|
export type AgentTimelineSamplePosition = AgentTimelineSampleDate & {
|
|
position: 'start' | 'middle' | 'end'
|
|
offset: number
|
|
}
|
|
|
|
export type AgentTimelineCoverageMonth = {
|
|
month: string
|
|
messageCount: number
|
|
activeDays: number
|
|
firstActiveDate: string
|
|
lastActiveDate: string
|
|
representativeDates: string[]
|
|
rawPageCoverage: 'touched' | 'untouched'
|
|
}
|
|
|
|
export type AgentTimelineCoverageSpan = {
|
|
startMonth: string
|
|
endMonth: string
|
|
monthCount: number
|
|
messageCount: number
|
|
activeDays: number
|
|
firstActiveDate: string
|
|
representativeDate: string
|
|
lastActiveDate: string
|
|
}
|
|
|
|
export type AgentRawPageCursor = {
|
|
version: 1
|
|
sessionId: string
|
|
startTime: number
|
|
endTime: number
|
|
direction: AgentRawPageDirection
|
|
offset: number
|
|
}
|
|
|
|
export type AgentRawMessageRecord = {
|
|
sessionId: string
|
|
displayName: string
|
|
localId: number
|
|
messageKey?: string
|
|
createTime: number
|
|
sortSeq: number
|
|
sender: string
|
|
messageType: string
|
|
content: string
|
|
quotedSender?: string
|
|
quotedContent?: string
|
|
voiceRef?: string
|
|
imageRef?: string
|
|
/** false 表示消息存在,但原始记录没有 md5/datName,不能读取真实图片像素。 */
|
|
imageLocatable?: boolean
|
|
}
|
|
|
|
export type AgentRawPageSelection = {
|
|
records: AgentRawMessageRecord[]
|
|
consumed: number
|
|
estimatedTokens: number
|
|
}
|
|
|
|
export type AgentDistinctiveConversationSegment = {
|
|
records: AgentRawMessageRecord[]
|
|
score: number
|
|
date: string
|
|
alternations: number
|
|
longTextCount: number
|
|
authoredLongTextCount?: number
|
|
authoredTextRatio?: number
|
|
authoredLongTextBalance?: number
|
|
quotedMessageCount: number
|
|
messageTypeCount: number
|
|
lexicalTerms?: string[]
|
|
}
|
|
|
|
export type AgentAnchoredConversationSegment = {
|
|
records: AgentRawMessageRecord[]
|
|
sourceWindowMessageCount: number
|
|
segmentCount: number
|
|
anchorSegmentIndex: number
|
|
omittedBeforeMessageCount: number
|
|
omittedAfterMessageCount: number
|
|
largestWindowGapSeconds: number
|
|
largestReturnedGapSeconds: number
|
|
gapBeforeSeconds?: number
|
|
gapAfterSeconds?: number
|
|
touchesWindowStart: boolean
|
|
touchesWindowEnd: boolean
|
|
}
|
|
|
|
export type AgentLexicalAnchorCandidate = {
|
|
term: string
|
|
messageCount: number
|
|
totalMessageCount: number
|
|
monthCount: number
|
|
dates: Array<{ date: string; count: number }>
|
|
navigationKinds?: AgentLexicalNavigationKind[]
|
|
}
|
|
|
|
export type AgentLexicalNavigationKind = 'location' | 'named-entity' | 'temporal'
|
|
|
|
export type AgentLexicalAnchor = {
|
|
term: string
|
|
messageCount: number
|
|
dates: string[]
|
|
navigationKinds?: AgentLexicalNavigationKind[]
|
|
navigationScore?: number
|
|
}
|
|
|
|
export type AgentSemanticMonthNavigation = {
|
|
messageCount: number
|
|
lexicalAnchors?: AgentLexicalAnchor[]
|
|
}
|
|
|
|
export type AgentRawRangeBatchPage = {
|
|
requestIndex: number
|
|
success: boolean
|
|
pageId?: unknown
|
|
pageHash?: unknown
|
|
conversation?: unknown
|
|
range?: unknown
|
|
requestedRange?: unknown
|
|
coverageStatus?: unknown
|
|
messageCount?: unknown
|
|
estimatedTokens?: unknown
|
|
cacheHit?: unknown
|
|
pageText?: unknown
|
|
mediaRefs?: unknown
|
|
nextCursor?: unknown
|
|
hasMore?: unknown
|
|
noNewRangeRead?: unknown
|
|
pages?: unknown[]
|
|
error?: string
|
|
}
|
|
|
|
export function resolveAgentTimelineWindowCount(
|
|
requestedWindowsValue: unknown,
|
|
activeDateCountValue: unknown,
|
|
activeMonthCountValue: unknown,
|
|
): number {
|
|
const activeDateCount = Math.max(0, Math.floor(Number(activeDateCountValue) || 0))
|
|
if (activeDateCount === 0) return 0
|
|
const activeMonthCount = Math.max(0, Math.floor(Number(activeMonthCountValue) || 0))
|
|
const requestedWindows = Math.max(2, Math.min(24, Math.floor(Number(requestedWindowsValue) || 8)))
|
|
const rangeFloor = activeMonthCount >= 30
|
|
? 24
|
|
: activeMonthCount >= 24
|
|
? 20
|
|
: activeMonthCount >= 18
|
|
? 18
|
|
: activeMonthCount >= 12
|
|
? 14
|
|
: activeMonthCount >= 6
|
|
? 10
|
|
: 2
|
|
return Math.min(activeDateCount, Math.max(requestedWindows, rangeFloor))
|
|
}
|
|
|
|
export function resolveAgentTimelineDistinctDateCount(
|
|
totalWindowCountValue: unknown,
|
|
preserveRequestedWindows = false,
|
|
): number {
|
|
const totalWindowCount = Math.max(2, Math.min(24, Math.floor(Number(totalWindowCountValue) || 8)))
|
|
return preserveRequestedWindows
|
|
? totalWindowCount
|
|
: Math.max(2, Math.ceil(totalWindowCount * 0.82))
|
|
}
|
|
|
|
function safeText(value: unknown): string {
|
|
return String(value ?? '')
|
|
.replace(/\u0000/g, '')
|
|
.replace(/\r\n?/g, '\n')
|
|
.trim()
|
|
}
|
|
|
|
export function compactAgentTimelineWorkspacePage(
|
|
value: unknown,
|
|
options: { pageText?: unknown; note?: unknown } = {},
|
|
): Record<string, unknown> {
|
|
const record = value && typeof value === 'object' && !Array.isArray(value)
|
|
? value as Record<string, unknown>
|
|
: {}
|
|
const pageText = safeText(options.pageText === undefined ? record.pageText : options.pageText)
|
|
const note = safeText(options.note)
|
|
const lexicalAnchors = Array.isArray(record.lexicalAnchors)
|
|
? record.lexicalAnchors.slice(0, 12).map((anchor) => {
|
|
const item = anchor && typeof anchor === 'object' && !Array.isArray(anchor)
|
|
? anchor as Record<string, unknown>
|
|
: {}
|
|
return {
|
|
term: safeText(item.term),
|
|
messageCount: Math.max(0, Math.floor(Number(item.messageCount) || 0)),
|
|
dates: Array.isArray(item.dates) ? item.dates.map(safeText).filter(Boolean).slice(0, 4) : [],
|
|
}
|
|
}).filter((anchor) => anchor.term)
|
|
: []
|
|
return {
|
|
success: record.success,
|
|
pageId: record.pageId,
|
|
pageHash: record.pageHash,
|
|
conversation: record.conversation,
|
|
range: record.range,
|
|
scanMonth: record.scanMonth,
|
|
structuralDates: Array.isArray(record.scanAnchorDates)
|
|
? record.scanAnchorDates.map(safeText).filter(Boolean).slice(0, 8)
|
|
: undefined,
|
|
lexicalAnchors: lexicalAnchors.length > 0 ? lexicalAnchors : undefined,
|
|
pageText: pageText || undefined,
|
|
error: record.error,
|
|
note: note || undefined,
|
|
}
|
|
}
|
|
|
|
const AGENT_LEXICAL_NAVIGATION_STOP_TERMS = new Set([
|
|
'一个', '一些', '一直', '不是', '不会', '不能', '不过', '为什么', '什么', '他们', '你们', '但是',
|
|
'其实', '可以', '因为', '如果', '已经', '应该', '怎么', '感觉', '我们', '所以', '没有', '然后',
|
|
'现在', '真的', '知道', '自己', '这个', '这么', '这里', '还是', '还有', '那个', '那么', '那里',
|
|
'就是', '这样', '觉得', '图片', '动画表情', '文件', '视频', '语音', '表情',
|
|
'attachment', 'attachments', 'cache', 'emojis', 'files', 'images', 'media', 'thumbs', 'voices',
|
|
'html', 'jpeg', 'jpg', 'json', 'png', 'webp', 'wav', 'xml',
|
|
'今天', '明天', '昨天', '前天', '后天', '这周', '本周', '上周', '下周', '今年', '明年', '去年',
|
|
'早上', '上午', '中午', '下午', '晚上', '凌晨', '时候', '时间', '当时', '刚才', '刚刚', '本来',
|
|
'以前', '以后', '过去', '未来', '目前', '平时', '最近', '现在', '正在', '上次', '下次', '这次',
|
|
'今晚', '昨晚', '昨天晚上', '白天', '周末', '周一', '周二', '周三', '周四', '周五', '周六', '周日',
|
|
'上来', '下来', '上去', '下去', '过来', '过去了',
|
|
'东西', '南北', '上下', '左右', '东边', '西边', '南边', '北边',
|
|
])
|
|
|
|
const AGENT_NAMED_ENTITY_TAGS = new Set([
|
|
'nr', 'nr1', 'nr2', 'nrj', 'nrf',
|
|
'nt', 'nz',
|
|
])
|
|
const AGENT_LOCATION_TAGS = new Set(['ns', 'nsf'])
|
|
|
|
function normalizeAgentLexicalTerm(value: unknown): string {
|
|
return safeText(value).normalize('NFKC').toLocaleLowerCase('zh-CN').replace(/\s+/g, '')
|
|
}
|
|
|
|
function isAgentLexicalNavigationTerm(term: string): boolean {
|
|
const length = Array.from(term).length
|
|
if (length < 2 || length > 20 || AGENT_LEXICAL_NAVIGATION_STOP_TERMS.has(term)) return false
|
|
if (!/^[\p{L}\p{N}]+$/u.test(term) || /^\p{N}+$/u.test(term)) return false
|
|
if (/^(.)\1+$/u.test(term)) return false
|
|
if (/^[a-z0-9]+$/i.test(term) && length < 3) return false
|
|
return true
|
|
}
|
|
|
|
export function extractAgentLexicalTermSignals(value: unknown): Array<{
|
|
term: string
|
|
navigationKinds: AgentLexicalNavigationKind[]
|
|
}> {
|
|
const source = safeText(value)
|
|
.slice(0, 800)
|
|
.replace(/https?:\/\/\S+/gi, ' ')
|
|
.replace(/<[^>\n]{1,200}>/g, ' ')
|
|
.replace(/\[[^\]\n]{1,40}\]/g, ' ')
|
|
.replace(/\b(?:attachments?|cache|emojis?|files?|images?|media|thumbs?|voices?)[\\/][^\s<>"']*/gi, ' ')
|
|
.replace(/\b[a-z0-9_-]+\.(?:gif|jpe?g|m4a|mov|mp3|mp4|ogg|png|silk|wav|webm|webp)\b/gi, ' ')
|
|
if (!source) return []
|
|
const signals = new Map<string, Set<AgentLexicalNavigationKind>>()
|
|
for (const tagged of tagJieba(source, true)) {
|
|
const term = normalizeAgentLexicalTerm(tagged.word)
|
|
if (!isAgentLexicalNavigationTerm(term)) continue
|
|
const kinds = signals.get(term) || new Set<AgentLexicalNavigationKind>()
|
|
if (AGENT_LOCATION_TAGS.has(tagged.tag)) kinds.add('location')
|
|
else if (AGENT_NAMED_ENTITY_TAGS.has(tagged.tag)) kinds.add('named-entity')
|
|
if (tagged.tag === 't') kinds.add('temporal')
|
|
signals.set(term, kinds)
|
|
}
|
|
return Array.from(signals, ([term, kinds]) => ({
|
|
term,
|
|
navigationKinds: Array.from(kinds),
|
|
}))
|
|
}
|
|
|
|
export function extractAgentLexicalTerms(value: unknown): string[] {
|
|
return extractAgentLexicalTermSignals(value).map((signal) => signal.term)
|
|
}
|
|
|
|
export async function executeAgentRawRangeBatch<TRequest>(
|
|
requests: TRequest[],
|
|
read: (request: TRequest, index: number) => Promise<unknown>,
|
|
): Promise<AgentRawRangeBatchPage[]> {
|
|
return Promise.all(requests.map(async (request, requestIndex) => {
|
|
const value = await read(request, requestIndex)
|
|
const record = value && typeof value === 'object' && !Array.isArray(value)
|
|
? value as Record<string, unknown>
|
|
: null
|
|
return {
|
|
requestIndex,
|
|
success: record?.success === true,
|
|
pageId: record?.pageId,
|
|
pageHash: record?.pageHash,
|
|
conversation: record?.conversation,
|
|
range: record?.range,
|
|
requestedRange: record?.requestedRange,
|
|
coverageStatus: record?.coverageStatus,
|
|
messageCount: record?.messageCount,
|
|
estimatedTokens: record?.estimatedTokens,
|
|
cacheHit: record?.cacheHit,
|
|
pageText: record?.pageText,
|
|
mediaRefs: record?.mediaRefs,
|
|
nextCursor: record?.nextCursor,
|
|
hasMore: record?.hasMore,
|
|
noNewRangeRead: record?.noNewRangeRead,
|
|
pages: Array.isArray(record?.pages) ? record.pages : undefined,
|
|
error: record?.error ? safeText(record.error).slice(0, 300) : undefined,
|
|
}
|
|
}))
|
|
}
|
|
|
|
export function selectAgentLiteralMatchContexts<T extends {
|
|
sentAt?: unknown
|
|
conversation?: unknown
|
|
messageRef?: unknown
|
|
}>(matches: T[], limitValue: number): T[] {
|
|
const limit = Math.max(1, Math.floor(Number(limitValue) || 1))
|
|
const clusters = new Map<string, T[]>()
|
|
matches.forEach((match, index) => {
|
|
const sentAt = safeText(match.sentAt)
|
|
const date = /^\d{4}-\d{2}-\d{2}/.test(sentAt) ? sentAt.slice(0, 10) : `unknown-${index}`
|
|
const key = `${safeText(match.conversation)}\u0001${date}`
|
|
const cluster = clusters.get(key) || []
|
|
cluster.push(match)
|
|
clusters.set(key, cluster)
|
|
})
|
|
const ranked = Array.from(clusters.entries())
|
|
.sort((left, right) => right[1].length - left[1].length || right[0].localeCompare(left[0], 'zh-CN'))
|
|
.map(([key, cluster]) => ({
|
|
key,
|
|
date: key.split('\u0001').at(-1) || '',
|
|
size: cluster.length,
|
|
match: cluster[Math.floor((cluster.length - 1) / 2)],
|
|
}))
|
|
.filter((item) => Boolean(safeText(item.match.messageRef)))
|
|
if (ranked.length <= limit) {
|
|
return ranked
|
|
.sort((left, right) => left.date.localeCompare(right.date) || right.size - left.size)
|
|
.map((item) => item.match)
|
|
}
|
|
|
|
const chronological = [...ranked].sort((left, right) => left.date.localeCompare(right.date)
|
|
|| right.size - left.size || left.key.localeCompare(right.key, 'zh-CN'))
|
|
const selected = new Map<string, (typeof ranked)[number]>()
|
|
const add = (item: (typeof ranked)[number] | undefined) => {
|
|
if (!item || selected.size >= limit || selected.has(item.key)) return
|
|
selected.set(item.key, item)
|
|
}
|
|
add(ranked[0])
|
|
if (limit >= 2) {
|
|
const strongestDate = ranked[0].date
|
|
const earliestDistance = Math.abs(Date.parse(chronological[0].date) - Date.parse(strongestDate))
|
|
const latestDistance = Math.abs(Date.parse(chronological.at(-1)!.date) - Date.parse(strongestDate))
|
|
add(latestDistance >= earliestDistance ? chronological.at(-1) : chronological[0])
|
|
}
|
|
if (limit >= 3) {
|
|
add(chronological[0])
|
|
add(chronological.at(-1))
|
|
}
|
|
for (let index = 1; selected.size < limit && index < limit * 2; index += 1) {
|
|
add(chronological[Math.round((index * (chronological.length - 1)) / Math.max(1, limit - 1))])
|
|
}
|
|
for (const item of ranked) add(item)
|
|
return Array.from(selected.values())
|
|
.sort((left, right) => left.date.localeCompare(right.date) || right.size - left.size)
|
|
.map((item) => item.match)
|
|
}
|
|
|
|
export function selectAgentDenseConversationSegment(
|
|
recordsValue: AgentRawMessageRecord[],
|
|
gapSecondsValue = 45 * 60,
|
|
): AgentRawMessageRecord[] {
|
|
const records = [...recordsValue]
|
|
.filter((record) => Number(record.createTime) > 0)
|
|
.sort((left, right) => left.createTime - right.createTime || left.sortSeq - right.sortSeq || left.localId - right.localId)
|
|
if (records.length <= 1) return records
|
|
|
|
const gapSeconds = Math.max(5 * 60, Math.min(6 * 60 * 60, Math.floor(Number(gapSecondsValue) || 45 * 60)))
|
|
const segments: AgentRawMessageRecord[][] = []
|
|
for (const record of records) {
|
|
const current = segments.at(-1)
|
|
if (!current) {
|
|
segments.push([record])
|
|
continue
|
|
}
|
|
const previous = current.at(-1)!
|
|
const previousSeconds = previous.createTime > 10_000_000_000 ? previous.createTime / 1000 : previous.createTime
|
|
const currentSeconds = record.createTime > 10_000_000_000 ? record.createTime / 1000 : record.createTime
|
|
if (currentSeconds - previousSeconds >= gapSeconds) segments.push([record])
|
|
else current.push(record)
|
|
}
|
|
|
|
return segments
|
|
.map((segment) => {
|
|
let alternations = 0
|
|
const senders = new Set<string>()
|
|
for (let index = 0; index < segment.length; index += 1) {
|
|
const sender = safeText(segment[index].sender)
|
|
if (sender) senders.add(sender)
|
|
if (index > 0 && sender && sender !== safeText(segment[index - 1].sender)) alternations += 1
|
|
}
|
|
const firstSeconds = segment[0].createTime > 10_000_000_000 ? segment[0].createTime / 1000 : segment[0].createTime
|
|
const lastSeconds = segment.at(-1)!.createTime > 10_000_000_000 ? segment.at(-1)!.createTime / 1000 : segment.at(-1)!.createTime
|
|
const activeMinutes = Math.max(1, (lastSeconds - firstSeconds) / 60)
|
|
const density = Math.min(40, segment.length / activeMinutes)
|
|
const score = segment.length + alternations * 2 + (senders.size >= 2 ? 30 : 0) + density
|
|
return { segment, score, alternations, startAt: segment[0].createTime }
|
|
})
|
|
.sort((left, right) => right.score - left.score
|
|
|| right.alternations - left.alternations
|
|
|| right.segment.length - left.segment.length
|
|
|| left.startAt - right.startAt)[0]?.segment || []
|
|
}
|
|
|
|
/**
|
|
* 只选择包含锚点、且在局部时间上连续的对话。
|
|
* 数据库跳转通常返回固定数量的记录;在稀疏会话中,这些记录可能跨越互不相关的日期
|
|
* 甚至月份。它们适合用于导航,但把整个窗口呈现为一个事件,会让模型误以为自己已经
|
|
* 读到了连贯的开头和结尾。
|
|
*
|
|
* 此函数刻意不引入语义:只按时间间隔切分,并返回最接近请求锚点的片段。
|
|
* 相邻片段仍会保留可供导航的度量信息,但不会混入返回的事件正文。
|
|
*/
|
|
export function selectAgentAnchoredConversationSegment(
|
|
recordsValue: AgentRawMessageRecord[],
|
|
anchorTimeValue: number,
|
|
gapSecondsValue = 4 * 60 * 60,
|
|
): AgentAnchoredConversationSegment {
|
|
const normalizeSeconds = (value: number) => value > 10_000_000_000 ? value / 1_000 : value
|
|
const records = [...recordsValue]
|
|
.filter((record) => Number(record.createTime) > 0)
|
|
.sort((left, right) => left.createTime - right.createTime || left.sortSeq - right.sortSeq || left.localId - right.localId)
|
|
if (records.length === 0) {
|
|
return {
|
|
records: [],
|
|
sourceWindowMessageCount: 0,
|
|
segmentCount: 0,
|
|
anchorSegmentIndex: -1,
|
|
omittedBeforeMessageCount: 0,
|
|
omittedAfterMessageCount: 0,
|
|
largestWindowGapSeconds: 0,
|
|
largestReturnedGapSeconds: 0,
|
|
touchesWindowStart: false,
|
|
touchesWindowEnd: false,
|
|
}
|
|
}
|
|
|
|
const gapSeconds = Math.max(5 * 60, Math.min(24 * 60 * 60, Math.floor(Number(gapSecondsValue) || 4 * 60 * 60)))
|
|
const segments: AgentRawMessageRecord[][] = []
|
|
let largestWindowGapSeconds = 0
|
|
for (const record of records) {
|
|
const current = segments.at(-1)
|
|
if (!current) {
|
|
segments.push([record])
|
|
continue
|
|
}
|
|
const previous = current.at(-1)!
|
|
const elapsed = Math.max(0, normalizeSeconds(record.createTime) - normalizeSeconds(previous.createTime))
|
|
largestWindowGapSeconds = Math.max(largestWindowGapSeconds, elapsed)
|
|
if (elapsed > gapSeconds) segments.push([record])
|
|
else current.push(record)
|
|
}
|
|
|
|
const anchorSeconds = normalizeSeconds(Number(anchorTimeValue) || records[Math.floor(records.length / 2)].createTime)
|
|
let anchorSegmentIndex = 0
|
|
let nearestDistance = Number.POSITIVE_INFINITY
|
|
segments.forEach((segment, index) => {
|
|
const distance = segment.reduce((minimum, record) => (
|
|
Math.min(minimum, Math.abs(normalizeSeconds(record.createTime) - anchorSeconds))
|
|
), Number.POSITIVE_INFINITY)
|
|
if (distance < nearestDistance) {
|
|
nearestDistance = distance
|
|
anchorSegmentIndex = index
|
|
}
|
|
})
|
|
const selected = segments[anchorSegmentIndex]
|
|
const beforeSegments = segments.slice(0, anchorSegmentIndex)
|
|
const afterSegments = segments.slice(anchorSegmentIndex + 1)
|
|
let largestReturnedGapSeconds = 0
|
|
for (let index = 1; index < selected.length; index += 1) {
|
|
largestReturnedGapSeconds = Math.max(
|
|
largestReturnedGapSeconds,
|
|
Math.max(0, normalizeSeconds(selected[index].createTime) - normalizeSeconds(selected[index - 1].createTime)),
|
|
)
|
|
}
|
|
const previousRecord = beforeSegments.at(-1)?.at(-1)
|
|
const nextRecord = afterSegments[0]?.[0]
|
|
return {
|
|
records: selected,
|
|
sourceWindowMessageCount: records.length,
|
|
segmentCount: segments.length,
|
|
anchorSegmentIndex,
|
|
omittedBeforeMessageCount: beforeSegments.reduce((sum, segment) => sum + segment.length, 0),
|
|
omittedAfterMessageCount: afterSegments.reduce((sum, segment) => sum + segment.length, 0),
|
|
largestWindowGapSeconds,
|
|
largestReturnedGapSeconds,
|
|
gapBeforeSeconds: previousRecord
|
|
? Math.max(0, normalizeSeconds(selected[0].createTime) - normalizeSeconds(previousRecord.createTime))
|
|
: undefined,
|
|
gapAfterSeconds: nextRecord
|
|
? Math.max(0, normalizeSeconds(nextRecord.createTime) - normalizeSeconds(selected.at(-1)!.createTime))
|
|
: undefined,
|
|
touchesWindowStart: anchorSegmentIndex === 0,
|
|
touchesWindowEnd: anchorSegmentIndex === segments.length - 1,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 按结构独特性对局部连贯的对话簇排序。
|
|
* 这只用于导航:刻意避开关系词汇和语义标签,模型仍须阅读并解释原文。
|
|
*/
|
|
export function selectAgentDistinctiveConversationSegments(
|
|
recordsValue: AgentRawMessageRecord[],
|
|
limitValue = 2,
|
|
gapSecondsValue = 45 * 60,
|
|
): AgentDistinctiveConversationSegment[] {
|
|
const records = [...recordsValue]
|
|
.filter((record) => Number(record.createTime) > 0)
|
|
.sort((left, right) => left.createTime - right.createTime || left.sortSeq - right.sortSeq || left.localId - right.localId)
|
|
if (records.length === 0) return []
|
|
|
|
const gapSeconds = Math.max(5 * 60, Math.min(6 * 60 * 60, Math.floor(Number(gapSecondsValue) || 45 * 60)))
|
|
const localDate = (seconds: number) => {
|
|
const date = new Date(seconds * 1000)
|
|
return [
|
|
date.getFullYear(),
|
|
String(date.getMonth() + 1).padStart(2, '0'),
|
|
String(date.getDate()).padStart(2, '0'),
|
|
].join('-')
|
|
}
|
|
const segments: AgentRawMessageRecord[][] = []
|
|
for (const record of records) {
|
|
const current = segments.at(-1)
|
|
if (!current) {
|
|
segments.push([record])
|
|
continue
|
|
}
|
|
const previous = current.at(-1)!
|
|
const previousSeconds = previous.createTime > 10_000_000_000 ? previous.createTime / 1000 : previous.createTime
|
|
const currentSeconds = record.createTime > 10_000_000_000 ? record.createTime / 1000 : record.createTime
|
|
const previousDate = localDate(previousSeconds)
|
|
const currentDate = localDate(currentSeconds)
|
|
if (currentDate !== previousDate || currentSeconds - previousSeconds >= gapSeconds) segments.push([record])
|
|
else current.push(record)
|
|
}
|
|
|
|
const ranked = segments.map((segment): AgentDistinctiveConversationSegment => {
|
|
let alternations = 0
|
|
let longTextCount = 0
|
|
let quotedMessageCount = 0
|
|
let totalTextLength = 0
|
|
let longestTextLength = 0
|
|
let authoredTextCount = 0
|
|
let authoredTextLength = 0
|
|
let authoredLongTextCount = 0
|
|
let messageTypeChanges = 0
|
|
const senders = new Map<string, number>()
|
|
const authoredLongTextsBySender = new Map<string, number>()
|
|
const messageTypes = new Set<string>()
|
|
for (let index = 0; index < segment.length; index += 1) {
|
|
const record = segment[index]
|
|
const sender = safeText(record.sender)
|
|
const content = safeText(record.content)
|
|
const messageType = safeText(record.messageType) || 'unknown'
|
|
if (sender) senders.set(sender, (senders.get(sender) || 0) + 1)
|
|
messageTypes.add(messageType)
|
|
totalTextLength += content.length
|
|
longestTextLength = Math.max(longestTextLength, content.length)
|
|
if (content.length >= 30) longTextCount += 1
|
|
const structuredPayload = (
|
|
/^(?:media[\\/]|\[(?:转发的聊天记录|聊天记录|链接|图片|视频|位置|文件|语音|小程序|音乐|消息)\])/u.test(content)
|
|
|| /(?:image|voice|video|file|link|location|emoji|图片|语音|视频|文件|链接|位置|表情|转发|聊天记录)/i.test(messageType)
|
|
)
|
|
if (content && !structuredPayload) {
|
|
authoredTextCount += 1
|
|
authoredTextLength += Math.min(content.length, 500)
|
|
if (content.length >= 30) {
|
|
authoredLongTextCount += 1
|
|
if (sender) authoredLongTextsBySender.set(sender, (authoredLongTextsBySender.get(sender) || 0) + 1)
|
|
}
|
|
}
|
|
if (record.quotedContent || record.quotedSender || /\[(?:引用|回复)\b/.test(content)) quotedMessageCount += 1
|
|
if (index > 0) {
|
|
if (sender && sender !== safeText(segment[index - 1].sender)) alternations += 1
|
|
if (messageType !== (safeText(segment[index - 1].messageType) || 'unknown')) messageTypeChanges += 1
|
|
}
|
|
}
|
|
const senderCounts = Array.from(senders.values()).sort((left, right) => right - left)
|
|
const reciprocalBalance = senderCounts.length >= 2 && senderCounts[0] > 0
|
|
? Math.min(1, senderCounts[1] / senderCounts[0])
|
|
: 0
|
|
const firstSeconds = segment[0].createTime > 10_000_000_000 ? segment[0].createTime / 1000 : segment[0].createTime
|
|
const lastSeconds = segment.at(-1)!.createTime > 10_000_000_000 ? segment.at(-1)!.createTime / 1000 : segment.at(-1)!.createTime
|
|
const activeMinutes = Math.max(0, (lastSeconds - firstSeconds) / 60)
|
|
const authoredTextRatio = authoredTextCount / Math.max(1, segment.length)
|
|
const averageAuthoredTextLength = authoredTextLength / Math.max(1, authoredTextCount)
|
|
const authoredLongTextCounts = Array.from(authoredLongTextsBySender.values())
|
|
.sort((left, right) => right - left)
|
|
const authoredLongTextBalance = authoredLongTextCounts.length >= 2 && authoredLongTextCounts[0] > 0
|
|
? Math.min(1, authoredLongTextCounts[1] / authoredLongTextCounts[0])
|
|
: 0
|
|
const authoredLongTextVolume = Math.min(1, authoredLongTextCount / 12)
|
|
const score = (
|
|
Math.min(1, alternations / 24) * 22
|
|
+ reciprocalBalance * 14
|
|
+ Math.min(1, segment.length / 90) * 10
|
|
+ Math.min(1, activeMinutes / 120) * 12
|
|
+ Math.min(1, longTextCount / 8) * 16
|
|
+ Math.min(1, totalTextLength / 2_400) * 8
|
|
+ Math.min(1, longestTextLength / 240) * 6
|
|
+ Math.min(1, quotedMessageCount / 8) * 7
|
|
+ Math.min(1, messageTypes.size / 4) * 3
|
|
+ Math.min(1, messageTypeChanges / 8) * 2
|
|
// 大量多媒体或转发消息集中出现时,过去会占满所有结构项,掩盖双方往返的
|
|
// 解释性交流。这里提高双方原创长文本轮次和文本连续性的权重;该导航仍然
|
|
// 与内容含义无关,也不会赋予任何主题意义。
|
|
+ Math.min(1, authoredLongTextCount / 20) * 14
|
|
// 只有在实际文本足够多时,往返平衡才具有信息量。
|
|
// 不能只因为双方各一条长消息的比例恰好完全均衡,就让它排在规模大得多的
|
|
// 解释性交流之前。
|
|
+ authoredLongTextBalance * authoredLongTextVolume * 10
|
|
+ Math.max(0, Math.min(1, (authoredTextRatio - 0.65) / 0.35)) * 5
|
|
+ Math.max(0, Math.min(1, (averageAuthoredTextLength - 8) / 10)) * 12
|
|
)
|
|
return {
|
|
records: segment,
|
|
score,
|
|
date: localDate(firstSeconds),
|
|
alternations,
|
|
longTextCount,
|
|
authoredLongTextCount,
|
|
authoredTextRatio,
|
|
authoredLongTextBalance,
|
|
quotedMessageCount,
|
|
messageTypeCount: messageTypes.size,
|
|
}
|
|
}).sort((left, right) => right.score - left.score
|
|
|| right.alternations - left.alternations
|
|
|| right.records.length - left.records.length
|
|
|| left.records[0].createTime - right.records[0].createTime)
|
|
|
|
const limit = Math.max(1, Math.min(6, Math.floor(Number(limitValue) || 2)))
|
|
const selected: AgentDistinctiveConversationSegment[] = []
|
|
const selectedDates = new Set<string>()
|
|
for (const candidate of ranked) {
|
|
if (selectedDates.has(candidate.date)) continue
|
|
selected.push(candidate)
|
|
selectedDates.add(candidate.date)
|
|
if (selected.length >= limit) break
|
|
}
|
|
return selected
|
|
}
|
|
|
|
/**
|
|
* 保留结构性较强的事件,同时为时间边界和中间阶段预留空间。
|
|
* 候选项应当是各自日期中最佳的局部对话簇,因此此函数既不会给消息正文赋予语义,
|
|
* 也不会假定消息最多的日期就是重要日期。
|
|
*/
|
|
export function selectAgentDistributedConversationSegments(
|
|
candidatesValue: AgentDistinctiveConversationSegment[],
|
|
limitValue = 6,
|
|
): AgentDistinctiveConversationSegment[] {
|
|
const bestByDate = new Map<string, AgentDistinctiveConversationSegment>()
|
|
for (const candidate of candidatesValue) {
|
|
if (!candidate?.records?.length || !/^\d{4}-\d{2}-\d{2}$/.test(candidate.date)) continue
|
|
const previous = bestByDate.get(candidate.date)
|
|
if (
|
|
!previous
|
|
|| candidate.score > previous.score
|
|
|| (candidate.score === previous.score && candidate.alternations > previous.alternations)
|
|
) {
|
|
bestByDate.set(candidate.date, candidate)
|
|
}
|
|
}
|
|
const chronological = Array.from(bestByDate.values()).sort((left, right) => left.date.localeCompare(right.date))
|
|
if (chronological.length === 0) return []
|
|
const limit = Math.max(1, Math.min(8, Math.floor(Number(limitValue) || 6), chronological.length))
|
|
if (chronological.length <= limit) return chronological
|
|
|
|
const selected = new Map<string, AgentDistinctiveConversationSegment>()
|
|
const add = (candidate: AgentDistinctiveConversationSegment | undefined) => {
|
|
if (!candidate || selected.size >= limit || selected.has(candidate.date)) return
|
|
selected.set(candidate.date, candidate)
|
|
}
|
|
const ranked = [...chronological].sort((left, right) => right.score - left.score
|
|
|| right.alternations - left.alternations
|
|
|| right.longTextCount - left.longTextCount
|
|
|| left.date.localeCompare(right.date))
|
|
const structuralCount = Math.max(
|
|
1,
|
|
Math.min(limit >= 7 ? 3 : 2, limit - Math.min(2, chronological.length)),
|
|
)
|
|
ranked.slice(0, structuralCount).forEach(add)
|
|
|
|
if (limit >= 3) add(chronological[0])
|
|
if (limit >= 4) add(chronological.at(-1))
|
|
|
|
for (const ratio of [0.15, 0.5, 0.85]) {
|
|
if (selected.size >= limit) break
|
|
add(chronological[Math.round((chronological.length - 1) * ratio)])
|
|
}
|
|
|
|
const featureRankings = [
|
|
[...chronological].sort((left, right) => right.longTextCount - left.longTextCount || right.score - left.score),
|
|
[...chronological].sort((left, right) => right.quotedMessageCount - left.quotedMessageCount || right.score - left.score),
|
|
[...chronological].sort((left, right) => right.messageTypeCount - left.messageTypeCount || right.score - left.score),
|
|
]
|
|
for (const ranking of featureRankings) {
|
|
if (selected.size >= limit) break
|
|
add(ranking[0])
|
|
}
|
|
for (const candidate of ranked) {
|
|
if (selected.size >= limit) break
|
|
add(candidate)
|
|
}
|
|
return Array.from(selected.values()).sort((left, right) => left.date.localeCompare(right.date))
|
|
}
|
|
|
|
/**
|
|
* 在月份结构样本中,为精确词项导航发现的日期预留数量受限的一部分。
|
|
* 词项仍不带语义标签:模型会收到对应的原始对话簇,并自行判断其是否重要。
|
|
*/
|
|
export function selectAgentLexicallyAugmentedConversationSegments(
|
|
candidatesValue: AgentDistinctiveConversationSegment[],
|
|
lexicalAnchorsValue: AgentLexicalAnchor[],
|
|
limitValue = 6,
|
|
): AgentDistinctiveConversationSegment[] {
|
|
const candidates = candidatesValue
|
|
.filter((candidate) => candidate?.records?.length && /^\d{4}-\d{2}-\d{2}$/.test(candidate.date))
|
|
if (candidates.length === 0) return []
|
|
const limit = Math.max(1, Math.min(8, Math.floor(Number(limitValue) || 6), candidates.length))
|
|
const untaggedCandidates = candidates.filter((candidate) => !candidate.lexicalTerms?.length)
|
|
const structuralCandidates = untaggedCandidates.length > 0 ? untaggedCandidates : candidates
|
|
const structural = selectAgentDistributedConversationSegments(structuralCandidates, limit)
|
|
if (lexicalAnchorsValue.length === 0) return structural
|
|
|
|
const bestByDate = new Map<string, AgentDistinctiveConversationSegment>()
|
|
for (const candidate of structuralCandidates) {
|
|
const previous = bestByDate.get(candidate.date)
|
|
if (!previous || candidate.score > previous.score) bestByDate.set(candidate.date, candidate)
|
|
}
|
|
const lexicalPreferred: AgentDistinctiveConversationSegment[] = []
|
|
const candidateKey = (candidate: AgentDistinctiveConversationSegment) => {
|
|
const first = candidate.records[0]
|
|
return `${candidate.date}\u0001${first?.createTime || 0}\u0001${first?.localId || 0}`
|
|
}
|
|
const lexicalKeys = new Set<string>()
|
|
const anchorsByKind = (kind?: AgentLexicalNavigationKind) => lexicalAnchorsValue
|
|
.filter((anchor) => kind
|
|
? anchor.navigationKinds?.includes(kind)
|
|
: !anchor.navigationKinds?.length)
|
|
.sort((left, right) => (right.navigationScore || 0) - (left.navigationScore || 0)
|
|
|| right.messageCount - left.messageCount
|
|
|| right.dates.length - left.dates.length
|
|
|| left.term.localeCompare(right.term, 'zh-CN'))
|
|
const diversifiedAnchors: AgentLexicalAnchor[] = []
|
|
const addAnchor = (anchor: AgentLexicalAnchor | undefined) => {
|
|
if (!anchor || diversifiedAnchors.some((candidate) => candidate.term === anchor.term)) return
|
|
diversifiedAnchors.push(anchor)
|
|
}
|
|
const locationAnchors = anchorsByKind('location')
|
|
const temporalAnchors = anchorsByKind('temporal')
|
|
const namedEntityAnchors = anchorsByKind('named-entity')
|
|
addAnchor(locationAnchors[0])
|
|
addAnchor(temporalAnchors[0])
|
|
addAnchor(namedEntityAnchors[0])
|
|
addAnchor(locationAnchors[1])
|
|
addAnchor(temporalAnchors[1])
|
|
addAnchor(namedEntityAnchors[1])
|
|
addAnchor(anchorsByKind()[0])
|
|
addAnchor(anchorsByKind()[1])
|
|
lexicalAnchorsValue.forEach(addAnchor)
|
|
for (const anchor of diversifiedAnchors) {
|
|
const tagged = candidates
|
|
.filter((candidate) => candidate.lexicalTerms?.includes(anchor.term))
|
|
.sort((left, right) => right.score - left.score || left.date.localeCompare(right.date))[0]
|
|
const preferred = tagged || (anchor.dates || [])
|
|
.map((date) => bestByDate.get(date))
|
|
.filter((candidate): candidate is AgentDistinctiveConversationSegment => Boolean(candidate))
|
|
.sort((left, right) => right.score - left.score || left.date.localeCompare(right.date))[0]
|
|
const key = preferred ? candidateKey(preferred) : ''
|
|
if (!preferred || lexicalKeys.has(key)) continue
|
|
lexicalKeys.add(key)
|
|
lexicalPreferred.push(preferred)
|
|
}
|
|
|
|
const targetLexicalCount = Math.min(4, Math.max(1, Math.ceil(limit * 0.5)), lexicalPreferred.length)
|
|
if (targetLexicalCount === 0) return structural
|
|
const selected = new Map(structural.map((candidate) => [candidateKey(candidate), candidate]))
|
|
const chronological = [...structuralCandidates].sort((left, right) => left.date.localeCompare(right.date))
|
|
const structuralStrength = [...structuralCandidates].sort((left, right) => right.score - left.score
|
|
|| right.alternations - left.alternations
|
|
|| left.date.localeCompare(right.date))
|
|
const protectedKeys = new Set([
|
|
structuralStrength[0],
|
|
limit >= 3 ? chronological[0] : undefined,
|
|
limit >= 4 ? chronological.at(-1) : undefined,
|
|
limit >= 6 && targetLexicalCount < 4 ? structuralStrength[1] : undefined,
|
|
].filter(Boolean).map((candidate) => candidateKey(candidate!)))
|
|
const retainedLexicalKeys = new Set<string>()
|
|
for (const preferred of lexicalPreferred) {
|
|
if (retainedLexicalKeys.size >= targetLexicalCount) break
|
|
const preferredKey = candidateKey(preferred)
|
|
if (selected.has(preferredKey)) {
|
|
retainedLexicalKeys.add(preferredKey)
|
|
continue
|
|
}
|
|
const replacementEntry = Array.from(selected.entries())
|
|
.filter(([key]) => !protectedKeys.has(key) && !retainedLexicalKeys.has(key))
|
|
.sort(([, left], [, right]) => left.score - right.score || right.date.localeCompare(left.date))[0]
|
|
const replacement = replacementEntry?.[1]
|
|
if (!replacement) continue
|
|
selected.delete(replacementEntry[0])
|
|
selected.set(preferredKey, preferred)
|
|
retainedLexicalKeys.add(preferredKey)
|
|
}
|
|
return Array.from(selected.values()).sort((left, right) => left.date.localeCompare(right.date)
|
|
|| left.records[0].createTime - right.records[0].createTime)
|
|
}
|
|
|
|
/**
|
|
* 构建紧凑的发现页,同时避免时间顺序或自动提取的某个词项主导页面。
|
|
* 大部分位置保留最强的往返交流,其余位置来自分布式/词项导航器。
|
|
* 模型仍需阅读原文预览,并自行赋予全部含义。
|
|
*/
|
|
export function selectAgentDiscoveryConversationSegments(
|
|
candidatesValue: AgentDistinctiveConversationSegment[],
|
|
lexicalAnchorsValue: AgentLexicalAnchor[],
|
|
limitValue = 6,
|
|
): AgentDistinctiveConversationSegment[] {
|
|
const candidates = candidatesValue
|
|
.filter((candidate) => candidate?.records?.length && /^\d{4}-\d{2}-\d{2}$/.test(candidate.date))
|
|
if (candidates.length === 0) return []
|
|
const limit = Math.max(1, Math.min(8, Math.floor(Number(limitValue) || 6), candidates.length))
|
|
const candidateKey = (candidate: AgentDistinctiveConversationSegment) => {
|
|
const first = candidate.records[0]
|
|
const last = candidate.records.at(-1)
|
|
return [
|
|
candidate.date,
|
|
first?.createTime || 0,
|
|
first?.localId || 0,
|
|
last?.createTime || 0,
|
|
last?.localId || 0,
|
|
].join('\u0001')
|
|
}
|
|
const strongest = [...candidates].sort((left, right) => (
|
|
right.score - left.score
|
|
|| right.alternations - left.alternations
|
|
|| right.longTextCount - left.longTextCount
|
|
|| right.records.length - left.records.length
|
|
|| left.records[0].createTime - right.records[0].createTime
|
|
))
|
|
const strongestSlots = Math.max(1, Math.min(limit, Math.ceil(limit * 2 / 3)))
|
|
const selected = new Map<string, AgentDistinctiveConversationSegment>()
|
|
const add = (candidate: AgentDistinctiveConversationSegment | undefined) => {
|
|
if (!candidate || selected.size >= limit) return
|
|
const key = candidateKey(candidate)
|
|
if (!selected.has(key)) selected.set(key, candidate)
|
|
}
|
|
strongest.slice(0, strongestSlots).forEach(add)
|
|
selectAgentLexicallyAugmentedConversationSegments(
|
|
candidates,
|
|
lexicalAnchorsValue,
|
|
limit,
|
|
).forEach(add)
|
|
strongest.forEach(add)
|
|
return Array.from(selected.values()).sort((left, right) => (
|
|
left.date.localeCompare(right.date)
|
|
|| left.records[0].createTime - right.records[0].createTime
|
|
))
|
|
}
|
|
|
|
/**
|
|
* 在不解释词义的前提下选择紧凑的词项导航锚点。一种视图侧重月份内频率,
|
|
* 另一种视图保留使用时间集中在该月的词项,使反复出现的具体话题不会被通用的
|
|
* 高频聊天词汇淹没。
|
|
*/
|
|
export function selectAgentLexicalAnchors(
|
|
candidatesValue: AgentLexicalAnchorCandidate[],
|
|
totalMonthCountValue: number,
|
|
limitValue = 16,
|
|
): AgentLexicalAnchor[] {
|
|
const totalMonthCount = Math.max(1, Math.floor(Number(totalMonthCountValue) || 1))
|
|
const limit = Math.max(1, Math.min(24, Math.floor(Number(limitValue) || 16)))
|
|
const candidates = candidatesValue
|
|
.map((candidate) => {
|
|
const term = safeText(candidate.term)
|
|
const messageCount = Math.max(0, Math.floor(Number(candidate.messageCount) || 0))
|
|
const totalMessageCount = Math.max(messageCount, Math.floor(Number(candidate.totalMessageCount) || 0))
|
|
const monthCount = Math.max(1, Math.min(totalMonthCount, Math.floor(Number(candidate.monthCount) || 1)))
|
|
const dates = (candidate.dates || [])
|
|
.map((item) => ({ date: safeText(item.date), count: Math.max(0, Math.floor(Number(item.count) || 0)) }))
|
|
.filter((item) => /^\d{4}-\d{2}-\d{2}$/.test(item.date) && item.count > 0)
|
|
.sort((left, right) => left.date.localeCompare(right.date))
|
|
const dateCount = dates.length
|
|
const idfWeight = Math.max(0, Math.log2((totalMonthCount + 1) / (monthCount + 0.5)))
|
|
const idfScore = Math.log2(messageCount + 1)
|
|
* idfWeight
|
|
* (1 + Math.min(5, dateCount) * 0.08)
|
|
* (1 + Math.min(4, Math.max(0, Array.from(term).length - 2)) * 0.08)
|
|
const concentration = totalMessageCount > 0 ? messageCount / totalMessageCount : 0
|
|
const concentrationScore = totalMessageCount > 0
|
|
? (messageCount / totalMessageCount) * Math.log2(messageCount + 1)
|
|
: 0
|
|
const temporalScore = dateCount * Math.log2(messageCount + 1) * (idfWeight + concentration)
|
|
const navigationKinds = Array.from(new Set(candidate.navigationKinds || []))
|
|
.filter((kind): kind is AgentLexicalNavigationKind => (
|
|
kind === 'location' || kind === 'named-entity' || kind === 'temporal'
|
|
))
|
|
const semanticNavigationScore = Math.log2(messageCount + 1)
|
|
* (1 + Math.min(5, dateCount) * 0.18)
|
|
* (1 + idfWeight + concentration)
|
|
return {
|
|
term,
|
|
messageCount,
|
|
dates,
|
|
idfScore,
|
|
concentration,
|
|
concentrationScore,
|
|
temporalScore,
|
|
semanticNavigationScore,
|
|
navigationKinds,
|
|
}
|
|
})
|
|
.filter((candidate) => candidate.term.length >= 2 && candidate.messageCount >= 2 && candidate.dates.length > 0)
|
|
const selected = new Map<string, (typeof candidates)[number]>()
|
|
const add = (candidate: (typeof candidates)[number] | undefined) => {
|
|
if (!candidate || selected.size >= limit || selected.has(candidate.term)) return
|
|
selected.set(candidate.term, candidate)
|
|
}
|
|
const byIdf = [...candidates].sort((left, right) => right.idfScore - left.idfScore
|
|
|| right.messageCount - left.messageCount
|
|
|| left.term.localeCompare(right.term, 'zh-CN'))
|
|
const byConcentration = [...candidates]
|
|
.filter((candidate) => candidate.messageCount >= 3)
|
|
.sort((left, right) => right.concentrationScore - left.concentrationScore
|
|
|| right.messageCount - left.messageCount
|
|
|| left.term.localeCompare(right.term, 'zh-CN'))
|
|
const byTemporalBreadth = [...candidates]
|
|
.filter((candidate) => candidate.idfScore >= 0.5 || candidate.concentration >= 0.15)
|
|
.sort((left, right) => right.temporalScore - left.temporalScore
|
|
|| right.messageCount - left.messageCount
|
|
|| left.term.localeCompare(right.term, 'zh-CN'))
|
|
const byNamedEntity = candidates
|
|
.filter((candidate) => candidate.navigationKinds.includes('named-entity'))
|
|
.sort((left, right) => right.semanticNavigationScore - left.semanticNavigationScore
|
|
|| right.messageCount - left.messageCount
|
|
|| left.term.localeCompare(right.term, 'zh-CN'))
|
|
const byLocation = candidates
|
|
.filter((candidate) => candidate.navigationKinds.includes('location'))
|
|
.sort((left, right) => right.semanticNavigationScore - left.semanticNavigationScore
|
|
|| right.messageCount - left.messageCount
|
|
|| left.term.localeCompare(right.term, 'zh-CN'))
|
|
const bySemanticTime = candidates
|
|
.filter((candidate) => candidate.navigationKinds.includes('temporal'))
|
|
.sort((left, right) => right.semanticNavigationScore - left.semanticNavigationScore
|
|
|| right.messageCount - left.messageCount
|
|
|| left.term.localeCompare(right.term, 'zh-CN'))
|
|
const semanticKindLimit = Math.max(1, Math.ceil(limit * 0.125))
|
|
byLocation.slice(0, semanticKindLimit).forEach(add)
|
|
bySemanticTime.slice(0, semanticKindLimit).forEach(add)
|
|
byNamedEntity.slice(0, semanticKindLimit).forEach(add)
|
|
byIdf.slice(0, Math.ceil(limit * 0.4)).forEach(add)
|
|
byConcentration.slice(0, Math.ceil(limit * 0.25)).forEach(add)
|
|
for (const candidate of byTemporalBreadth) add(candidate)
|
|
for (const candidate of byIdf) add(candidate)
|
|
|
|
return Array.from(selected.values()).map((candidate) => {
|
|
const byCount = [...candidate.dates].sort((left, right) => right.count - left.count || left.date.localeCompare(right.date))
|
|
const selectedDates = new Set<string>()
|
|
const addDate = (date: string | undefined) => {
|
|
if (date && selectedDates.size < 4) selectedDates.add(date)
|
|
}
|
|
addDate(candidate.dates[0]?.date)
|
|
addDate(candidate.dates.at(-1)?.date)
|
|
for (const item of byCount) addDate(item.date)
|
|
return {
|
|
term: candidate.term,
|
|
messageCount: candidate.messageCount,
|
|
dates: Array.from(selectedDates).sort(),
|
|
navigationKinds: candidate.navigationKinds.length > 0 ? candidate.navigationKinds : undefined,
|
|
navigationScore: Math.max(
|
|
candidate.idfScore,
|
|
candidate.concentrationScore,
|
|
candidate.semanticNavigationScore,
|
|
),
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 评估模型阅读前某个月份能够提供多少具体导航信息。这里不会解释任何词项,
|
|
* 会优先考虑多样的地点、实体和时间表达标签,以及跨日期重复出现的锚点,
|
|
* 同时削弱高消息量月份的天然优势。
|
|
*/
|
|
export function scoreAgentSemanticMonthNavigation(
|
|
value: AgentSemanticMonthNavigation,
|
|
): number {
|
|
const anchors = (value.lexicalAnchors || [])
|
|
.filter((anchor) => safeText(anchor.term) && anchor.messageCount > 0 && anchor.dates.length > 0)
|
|
if (anchors.length === 0) return 0
|
|
|
|
const kinds = new Set<AgentLexicalNavigationKind>()
|
|
let taggedAnchorCount = 0
|
|
let rareTaggedAnchorCount = 0
|
|
let multiDateAnchorCount = 0
|
|
for (const anchor of anchors) {
|
|
const anchorKinds = anchor.navigationKinds || []
|
|
if (anchorKinds.length > 0) taggedAnchorCount += 1
|
|
if (anchorKinds.length > 0 && anchor.messageCount <= 8) rareTaggedAnchorCount += 1
|
|
if (anchor.dates.length >= 2) multiDateAnchorCount += 1
|
|
anchorKinds.forEach((kind) => kinds.add(kind))
|
|
}
|
|
const strongestAnchorScores = anchors
|
|
.map((anchor) => Math.max(0, Number(anchor.navigationScore) || 0))
|
|
.sort((left, right) => right - left)
|
|
.slice(0, 6)
|
|
const anchorSignal = strongestAnchorScores.reduce((sum, score) => sum + Math.log2(score + 1), 0)
|
|
const concreteSignal = (
|
|
kinds.size * 4
|
|
+ taggedAnchorCount * 1.5
|
|
+ rareTaggedAnchorCount * 2
|
|
+ multiDateAnchorCount * 1.25
|
|
+ Math.log2(anchors.length + 1) * 2
|
|
+ anchorSignal
|
|
)
|
|
const volumeDampener = Math.sqrt(Math.max(1, Math.log2(Math.max(0, value.messageCount) + 2)))
|
|
return concreteSignal / volumeDampener
|
|
}
|
|
|
|
/**
|
|
* 从时间线的各个部分保留高信息量导航行。上限按结构而不是 token 计算:
|
|
* 对于较长历史,会从每个时间带展示少量条目,而不是让某个密集阶段占据所有可见位置。
|
|
*/
|
|
export function selectAgentTemporallyDistributedRows<T>(
|
|
values: T[],
|
|
timeKey: (value: T) => string,
|
|
score: (value: T) => number,
|
|
bandCountValue = 4,
|
|
rowsPerBandValue = 2,
|
|
): T[] {
|
|
const chronological = [...values].sort((left, right) => (
|
|
timeKey(left).localeCompare(timeKey(right))
|
|
))
|
|
if (chronological.length <= 1) return chronological
|
|
const bandCount = Math.max(1, Math.min(
|
|
chronological.length,
|
|
Math.floor(Number(bandCountValue) || 1),
|
|
))
|
|
const rowsPerBand = Math.max(1, Math.floor(Number(rowsPerBandValue) || 1))
|
|
if (chronological.length <= bandCount * rowsPerBand) return chronological
|
|
|
|
const selected = new Set<T>()
|
|
for (let bandIndex = 0; bandIndex < bandCount; bandIndex += 1) {
|
|
const startIndex = Math.floor((bandIndex * chronological.length) / bandCount)
|
|
const endIndex = Math.floor(((bandIndex + 1) * chronological.length) / bandCount)
|
|
chronological
|
|
.slice(startIndex, Math.max(startIndex + 1, endIndex))
|
|
.sort((left, right) => (
|
|
Math.max(0, Number(score(right)) || 0) - Math.max(0, Number(score(left)) || 0)
|
|
|| timeKey(left).localeCompare(timeKey(right))
|
|
))
|
|
.slice(0, rowsPerBand)
|
|
.forEach((value) => selected.add(value))
|
|
}
|
|
return chronological.filter((value) => selected.has(value))
|
|
}
|
|
|
|
export function selectAgentStructuralScanMonths<T extends {
|
|
month: string
|
|
count: number
|
|
}>(
|
|
values: T[],
|
|
desiredCountValue: unknown,
|
|
priority: (value: T) => number,
|
|
strongestChanges: Array<{ from?: string; to?: string }> = [],
|
|
distribution: 'structural' | 'chronological' = 'structural',
|
|
): T[] {
|
|
const byMonth = new Map<string, T>()
|
|
for (const value of values) {
|
|
if (/^\d{4}-\d{2}$/.test(safeText(value.month))) byMonth.set(value.month, value)
|
|
}
|
|
const chronological = Array.from(byMonth.values())
|
|
.sort((left, right) => left.month.localeCompare(right.month))
|
|
const desiredCount = Math.max(1, Math.min(
|
|
chronological.length,
|
|
Math.floor(Number(desiredCountValue) || 1),
|
|
))
|
|
if (chronological.length <= desiredCount) return chronological
|
|
|
|
const selected = new Set<string>()
|
|
const add = (value: T | undefined) => {
|
|
if (value && selected.size < desiredCount) selected.add(value.month)
|
|
}
|
|
const addMonth = (month: unknown) => add(byMonth.get(safeText(month)))
|
|
|
|
// 边界月份用于保留整体轨迹,其余位置分配给完整时间线和结构变化。
|
|
// 曾经占主导的活跃连续段不能吞掉小型跨期预览的大部分位置,否则会让后期的
|
|
// 密集阶段看起来像完整历史,并隐藏模型判断下一步阅读位置所需的中间阶段。
|
|
if (desiredCount >= 4) {
|
|
add(chronological[0])
|
|
add(chronological.at(-1))
|
|
}
|
|
const peakCount = chronological.reduce(
|
|
(maximum, month) => Math.max(maximum, Math.max(0, Number(month.count) || 0)),
|
|
0,
|
|
)
|
|
const activeThreshold = Math.max(1, peakCount * 0.25)
|
|
const monthOrdinal = (month: string) => {
|
|
const [year, monthNumber] = month.split('-').map(Number)
|
|
return year * 12 + monthNumber - 1
|
|
}
|
|
const activeRuns: T[][] = []
|
|
let currentRun: T[] = []
|
|
for (const month of chronological) {
|
|
const continuesRun = currentRun.length === 0
|
|
|| monthOrdinal(month.month) === monthOrdinal(currentRun.at(-1)!.month) + 1
|
|
if (Math.max(0, Number(month.count) || 0) >= activeThreshold) {
|
|
if (!continuesRun) {
|
|
if (currentRun.length > 0) activeRuns.push(currentRun)
|
|
currentRun = []
|
|
}
|
|
currentRun.push(month)
|
|
} else if (currentRun.length > 0) {
|
|
activeRuns.push(currentRun)
|
|
currentRun = []
|
|
}
|
|
}
|
|
if (currentRun.length > 0) activeRuns.push(currentRun)
|
|
const dominantRun = activeRuns
|
|
.map((run) => ({
|
|
run,
|
|
score: run.reduce((sum, month) => sum + Math.max(0, Number(month.count) || 0), 0)
|
|
* (1 + Math.log2(run.length + 1)),
|
|
}))
|
|
.sort((left, right) => right.score - left.score
|
|
|| right.run.length - left.run.length
|
|
|| left.run[0].month.localeCompare(right.run[0].month))[0]?.run || []
|
|
const reservedStructuralSlots = Math.max(
|
|
1,
|
|
Math.min(desiredCount - selected.size, Math.ceil(desiredCount * 0.2)),
|
|
)
|
|
const distributedSlots = Math.max(
|
|
1,
|
|
desiredCount - selected.size - reservedStructuralSlots,
|
|
)
|
|
const distributedTarget = Math.min(desiredCount, selected.size + distributedSlots)
|
|
if (distribution === 'chronological') {
|
|
// 这些是时间顺序锚点,不是又一轮排序。使用内部分位点,避免宽时间带边缘附近
|
|
// 的高分月份反复挤掉长历史的中间部分。结构变化和主要活跃连续段仍会在下方
|
|
// 获得数量受限的位置。
|
|
for (let index = 1; index <= distributedSlots; index += 1) {
|
|
add(chronological[
|
|
Math.round((index * (chronological.length - 1)) / (distributedSlots + 1))
|
|
])
|
|
if (selected.size >= distributedTarget) break
|
|
}
|
|
} else {
|
|
// 初次发现来源时,每个时间带中结构信息最丰富的月份更有帮助。
|
|
// 一旦选中来源,聚焦阅读就切换到上方的时间顺序锚点,避免发现阶段的醒目程度
|
|
// 抹去其余历史。
|
|
for (const month of selectAgentTemporallyDistributedRows(
|
|
chronological,
|
|
(value) => value.month,
|
|
priority,
|
|
distributedSlots,
|
|
1,
|
|
)) {
|
|
add(month)
|
|
if (selected.size >= distributedTarget) break
|
|
}
|
|
}
|
|
|
|
const transitionSlots = Math.max(1, Math.ceil(reservedStructuralSlots / 2))
|
|
const transitionTarget = Math.min(desiredCount, selected.size + transitionSlots)
|
|
for (const change of strongestChanges) {
|
|
addMonth(change.from)
|
|
if (selected.size >= transitionTarget) break
|
|
addMonth(change.to)
|
|
if (selected.size >= transitionTarget) break
|
|
}
|
|
|
|
const phaseTarget = Math.min(desiredCount, selected.size + Math.max(0, reservedStructuralSlots - transitionSlots))
|
|
for (const month of selectAgentTemporallyDistributedRows(
|
|
dominantRun,
|
|
(value) => value.month,
|
|
priority,
|
|
Math.max(1, reservedStructuralSlots - transitionSlots),
|
|
1,
|
|
)) {
|
|
add(month)
|
|
if (selected.size >= phaseTarget) break
|
|
}
|
|
for (const month of selectAgentTemporallyDistributedRows(
|
|
chronological,
|
|
(value) => value.month,
|
|
priority,
|
|
desiredCount,
|
|
1,
|
|
)) add(month)
|
|
for (const month of [...chronological].sort((left, right) => (
|
|
Math.max(0, Number(priority(right)) || 0) - Math.max(0, Number(priority(left)) || 0)
|
|
|| left.month.localeCompare(right.month)
|
|
))) add(month)
|
|
for (const month of chronological) add(month)
|
|
return chronological.filter((month) => selected.has(month.month))
|
|
}
|
|
|
|
export function selectAgentDistributedRawPage(
|
|
recordsValue: AgentRawMessageRecord[],
|
|
tokenBudgetValue: unknown,
|
|
minimumTokenBudgetValue = 160,
|
|
): AgentRawPageSelection {
|
|
const records = [...recordsValue]
|
|
.filter((record) => Number(record.createTime) > 0)
|
|
.sort((left, right) => left.createTime - right.createTime || left.sortSeq - right.sortSeq || left.localId - right.localId)
|
|
const minimumTokenBudget = Math.max(160, Math.min(
|
|
AGENT_RAW_PAGE_MIN_TOKENS,
|
|
Math.floor(Number(minimumTokenBudgetValue) || 160),
|
|
))
|
|
const tokenBudget = Math.max(minimumTokenBudget, Math.min(
|
|
AGENT_RAW_PAGE_MAX_TOKENS,
|
|
Math.floor(Number(tokenBudgetValue) || minimumTokenBudget),
|
|
))
|
|
if (records.length <= 1) return selectAgentRawPage(records, tokenBudget, minimumTokenBudget)
|
|
|
|
const lineTokens = records.map((record) => estimateAgentRawTextTokens(formatAgentRawMessage(record)) + 6)
|
|
const windowCount = records.length >= 12 && tokenBudget >= 240 ? 3 : 2
|
|
const ratios = windowCount === 3 ? [0.15, 0.5, 0.85] : [0.25, 0.75]
|
|
const selectedIndexes = new Set<number>()
|
|
let estimatedTokens = 0
|
|
|
|
for (const ratio of ratios) {
|
|
const center = Math.round((records.length - 1) * ratio)
|
|
const order: number[] = [center]
|
|
for (let radius = 1; radius < records.length; radius += 1) {
|
|
if (center - radius >= 0) order.push(center - radius)
|
|
if (center + radius < records.length) order.push(center + radius)
|
|
}
|
|
const windowBudget = Math.max(60, Math.floor(tokenBudget / windowCount))
|
|
let windowTokens = 0
|
|
for (const index of order) {
|
|
if (selectedIndexes.has(index)) continue
|
|
const cost = lineTokens[index]
|
|
if (selectedIndexes.size > 0 && estimatedTokens + cost > tokenBudget) continue
|
|
if (windowTokens > 0 && windowTokens + cost > windowBudget) break
|
|
selectedIndexes.add(index)
|
|
estimatedTokens += cost
|
|
windowTokens += cost
|
|
if (estimatedTokens >= tokenBudget) break
|
|
}
|
|
}
|
|
|
|
const neighbours = Array.from(selectedIndexes)
|
|
.flatMap((index) => [index - 1, index + 1])
|
|
.filter((index) => index >= 0 && index < records.length && !selectedIndexes.has(index))
|
|
for (const index of Array.from(new Set(neighbours))) {
|
|
const cost = lineTokens[index]
|
|
if (estimatedTokens + cost > tokenBudget) continue
|
|
selectedIndexes.add(index)
|
|
estimatedTokens += cost
|
|
}
|
|
|
|
if (selectedIndexes.size === 0) return selectAgentRawPage(records, tokenBudget, minimumTokenBudget)
|
|
const selectedRecords = Array.from(selectedIndexes)
|
|
.sort((left, right) => left - right)
|
|
.map((index) => records[index])
|
|
return {
|
|
records: selectedRecords,
|
|
consumed: selectedRecords.length,
|
|
estimatedTokens,
|
|
}
|
|
}
|
|
|
|
export function selectAgentTimelineSampleDates(
|
|
rowsValue: Array<{ date: string; count: number }>,
|
|
windowCountValue: number,
|
|
mode: AgentTimelineSampleSelectionMode = 'mixed',
|
|
): AgentTimelineSampleDate[] {
|
|
const rows = rowsValue
|
|
.map((row) => ({ date: safeText(row.date), count: Math.max(0, Number(row.count) || 0) }))
|
|
.filter((row) => /^\d{4}-\d{2}-\d{2}$/.test(row.date) && row.count > 0)
|
|
.sort((left, right) => left.date.localeCompare(right.date))
|
|
.filter((row, index, all) => index === 0 || row.date !== all[index - 1].date)
|
|
if (rows.length === 0) return []
|
|
const windowCount = Math.max(1, Math.min(rows.length, Math.floor(Number(windowCountValue) || 1)))
|
|
if (windowCount === 1) return [{ ...rows[0], reason: 'range-start' }]
|
|
|
|
const monthly = new Map<string, typeof rows>()
|
|
for (const row of rows) {
|
|
const month = row.date.slice(0, 7)
|
|
const bucket = monthly.get(month) || []
|
|
bucket.push(row)
|
|
monthly.set(month, bucket)
|
|
}
|
|
const monthRows = Array.from(monthly, ([month, values]) => ({
|
|
month,
|
|
values,
|
|
count: values.reduce((sum, row) => sum + row.count, 0),
|
|
activeDays: values.length,
|
|
peak: [...values].sort((left, right) => right.count - left.count || left.date.localeCompare(right.date))[0],
|
|
}))
|
|
|
|
if (mode === 'uniform') {
|
|
if (monthRows.length >= windowCount) {
|
|
return Array.from({ length: windowCount }, (_, index) => {
|
|
const month = monthRows[Math.round((index * (monthRows.length - 1)) / (windowCount - 1))]
|
|
const row = index === 0
|
|
? month.values[0]
|
|
: index === windowCount - 1
|
|
? month.values.at(-1)!
|
|
: month.values[Math.round((month.values.length - 1) / 2)]
|
|
return {
|
|
...row,
|
|
reason: index === 0 ? 'range-start' as const : index === windowCount - 1 ? 'range-end' as const : 'time-quantile' as const,
|
|
}
|
|
})
|
|
}
|
|
return Array.from({ length: windowCount }, (_, index) => {
|
|
const row = rows[Math.round((index * (rows.length - 1)) / (windowCount - 1))]
|
|
return {
|
|
...row,
|
|
reason: index === 0 ? 'range-start' as const : index === windowCount - 1 ? 'range-end' as const : 'time-quantile' as const,
|
|
}
|
|
}).filter((row, index, all) => all.findIndex((candidate) => candidate.date === row.date) === index)
|
|
}
|
|
|
|
const baseline = Array.from({ length: windowCount }, (_, index) => {
|
|
if (index === 0) return { ...rows[0], reason: 'range-start' as const }
|
|
if (index === windowCount - 1) return { ...rows.at(-1)!, reason: 'range-end' as const }
|
|
const segmentStart = Math.floor((index * rows.length) / windowCount)
|
|
const segmentEnd = Math.max(segmentStart, Math.floor(((index + 1) * rows.length) / windowCount) - 1)
|
|
const segment = rows.slice(segmentStart, segmentEnd + 1)
|
|
if (index % 2 === 1) {
|
|
const center = (segment.length - 1) / 2
|
|
const peak = segment
|
|
.map((row, segmentIndex) => ({ row, distance: Math.abs(segmentIndex - center) }))
|
|
.sort((left, right) => right.row.count - left.row.count || left.distance - right.distance || left.row.date.localeCompare(right.row.date))[0].row
|
|
return { ...peak, reason: 'segment-activity-peak' as const }
|
|
}
|
|
return { ...segment[Math.round((segment.length - 1) / 2)], reason: 'time-quantile' as const }
|
|
}).filter((row, index, all) => all.findIndex((candidate) => candidate.date === row.date) === index)
|
|
|
|
if (monthRows.length < 3 || windowCount < 4) return baseline
|
|
|
|
if (monthRows.length <= windowCount) {
|
|
const selected = new Map<string, AgentTimelineSampleDate>()
|
|
const add = (row: AgentTimelineSampleDate) => {
|
|
if (selected.has(row.date) || selected.size >= windowCount) return false
|
|
selected.set(row.date, row)
|
|
return true
|
|
}
|
|
monthRows.forEach((month, index) => {
|
|
const row = index === 0
|
|
? month.values[0]
|
|
: index === monthRows.length - 1
|
|
? month.values.at(-1)!
|
|
: month.peak
|
|
add({
|
|
...row,
|
|
reason: index === 0 ? 'range-start' : index === monthRows.length - 1 ? 'range-end' : 'time-quantile',
|
|
})
|
|
})
|
|
|
|
const trailingTransition = monthRows.length >= 2
|
|
? { previous: monthRows.at(-2)!, current: monthRows.at(-1)! }
|
|
: null
|
|
const trailingChangeRatio = trailingTransition
|
|
? Math.abs(trailingTransition.current.count - trailingTransition.previous.count)
|
|
/ Math.max(1, trailingTransition.current.count, trailingTransition.previous.count)
|
|
: 0
|
|
if (trailingTransition && trailingChangeRatio >= 0.65 && windowCount >= 6) {
|
|
const trailingSide = trailingTransition.current.count >= trailingTransition.previous.count
|
|
? trailingTransition.current
|
|
: trailingTransition.previous
|
|
const reason = trailingSide === trailingTransition.previous
|
|
? 'trailing-change-before' as const
|
|
: 'trailing-change-after' as const
|
|
for (const row of [...trailingSide.values].sort((left, right) => right.count - left.count || left.date.localeCompare(right.date)).slice(0, 2)) {
|
|
if (selected.has(row.date)) selected.set(row.date, { ...row, reason })
|
|
else add({ ...row, reason })
|
|
}
|
|
}
|
|
|
|
const transitionCandidates = monthRows.slice(1).map((current, index) => {
|
|
const previous = monthRows[index]
|
|
const activeDayBalance = Math.min(previous.activeDays, current.activeDays)
|
|
/ Math.max(1, Math.max(previous.activeDays, current.activeDays))
|
|
return {
|
|
previous,
|
|
current,
|
|
score: Math.abs(current.count - previous.count) * (0.5 + activeDayBalance * 0.5),
|
|
}
|
|
}).sort((left, right) => right.score - left.score
|
|
|| left.previous.month.localeCompare(right.previous.month))
|
|
for (const candidate of transitionCandidates) {
|
|
const before = Array.from(selected.values()).find((row) => row.date.startsWith(candidate.previous.month))
|
|
const after = Array.from(selected.values()).find((row) => row.date.startsWith(candidate.current.month))
|
|
if (
|
|
before
|
|
&& after
|
|
&& !before.reason.startsWith('trailing-change')
|
|
&& !after.reason.startsWith('trailing-change')
|
|
) {
|
|
selected.set(before.date, { ...before, reason: 'activity-change-before' })
|
|
selected.set(after.date, { ...after, reason: 'activity-change-after' })
|
|
break
|
|
}
|
|
}
|
|
for (const candidate of transitionCandidates) {
|
|
if (selected.size >= windowCount) break
|
|
const before = [...candidate.previous.values]
|
|
.sort((left, right) => right.count - left.count || left.date.localeCompare(right.date))
|
|
.find((row) => !selected.has(row.date))
|
|
if (before) add({ ...before, reason: 'activity-change-before' })
|
|
if (selected.size >= windowCount) break
|
|
const after = [...candidate.current.values]
|
|
.sort((left, right) => right.count - left.count || left.date.localeCompare(right.date))
|
|
.find((row) => !selected.has(row.date))
|
|
if (after) add({ ...after, reason: 'activity-change-after' })
|
|
}
|
|
for (const row of baseline) {
|
|
if (selected.size >= windowCount) break
|
|
add(row)
|
|
}
|
|
return Array.from(selected.values()).sort((left, right) => left.date.localeCompare(right.date))
|
|
}
|
|
|
|
const requireDistinctMonths = monthRows.length >= windowCount
|
|
|
|
if (requireDistinctMonths) {
|
|
// 长时间线既需要有代表性的时间顺序,也需要结构转折。把固定窗口数的大约四分之一
|
|
// 留给前后变化最强的节点,可以在不增加阅读预算的情况下展示均匀分位点跳过的时期。
|
|
const chronologicalWindowCount = Math.min(windowCount, Math.max(2, Math.ceil(windowCount * 0.75)))
|
|
const selected = new Map<string, AgentTimelineSampleDate>()
|
|
const selectedMonths = new Set<string>()
|
|
const add = (row: AgentTimelineSampleDate) => {
|
|
const month = row.date.slice(0, 7)
|
|
if (selected.has(row.date) || selectedMonths.has(month) || selected.size >= windowCount) return false
|
|
selected.set(row.date, row)
|
|
selectedMonths.add(month)
|
|
return true
|
|
}
|
|
const chronologicalMonths = Array.from({ length: chronologicalWindowCount }, (_, index) => (
|
|
monthRows[Math.round((index * (monthRows.length - 1)) / (chronologicalWindowCount - 1))]
|
|
))
|
|
for (let index = 0; index < chronologicalMonths.length; index += 1) {
|
|
const month = chronologicalMonths[index]
|
|
const row = index === 0
|
|
? month.values[0]
|
|
: index === chronologicalMonths.length - 1
|
|
? month.values.at(-1)!
|
|
: month.peak
|
|
add({
|
|
...row,
|
|
reason: index === 0 ? 'range-start' : index === chronologicalMonths.length - 1 ? 'range-end' : 'time-quantile',
|
|
})
|
|
}
|
|
|
|
const transitionCandidates = monthRows.slice(1).map((current, index) => {
|
|
const previous = monthRows[index]
|
|
const activeDayBalance = Math.min(previous.activeDays, current.activeDays)
|
|
/ Math.max(1, Math.max(previous.activeDays, current.activeDays))
|
|
return {
|
|
previous,
|
|
current,
|
|
score: Math.abs(current.count - previous.count) * (0.5 + activeDayBalance * 0.5),
|
|
}
|
|
}).sort((left, right) => right.score - left.score
|
|
|| left.previous.month.localeCompare(right.previous.month))
|
|
const activityWindowLimit = windowCount - chronologicalWindowCount
|
|
let activityWindows = 0
|
|
for (const candidate of transitionCandidates) {
|
|
if (activityWindows + 2 > activityWindowLimit || selected.size + 2 > windowCount) break
|
|
if (selectedMonths.has(candidate.previous.month) || selectedMonths.has(candidate.current.month)) continue
|
|
add({ ...candidate.previous.peak, reason: 'activity-change-before' })
|
|
add({ ...candidate.current.peak, reason: 'activity-change-after' })
|
|
activityWindows += 2
|
|
}
|
|
if (activityWindows < activityWindowLimit && selected.size < windowCount) {
|
|
for (const candidate of transitionCandidates) {
|
|
const sides = [
|
|
{ month: candidate.previous, reason: 'activity-change-before' as const },
|
|
{ month: candidate.current, reason: 'activity-change-after' as const },
|
|
].sort((left, right) => right.month.count - left.month.count || left.month.month.localeCompare(right.month.month))
|
|
const side = sides.find(({ month }) => !selectedMonths.has(month.month))
|
|
if (!side) continue
|
|
if (add({ ...side.month.peak, reason: side.reason })) activityWindows += 1
|
|
break
|
|
}
|
|
}
|
|
|
|
const remainingMonths = monthRows.filter(({ month }) => !selectedMonths.has(month))
|
|
while (selected.size < windowCount && remainingMonths.length > 0) {
|
|
const selectedIndexes = monthRows
|
|
.map((month, index) => selectedMonths.has(month.month) ? index : -1)
|
|
.filter((index) => index >= 0)
|
|
const nextIndex = remainingMonths
|
|
.map((month, index) => {
|
|
const monthIndex = monthRows.findIndex((candidate) => candidate.month === month.month)
|
|
const nearestDistance = Math.min(...selectedIndexes.map((selectedIndex) => Math.abs(selectedIndex - monthIndex)))
|
|
return { index, nearestDistance, monthIndex }
|
|
})
|
|
.sort((left, right) => right.nearestDistance - left.nearestDistance || left.monthIndex - right.monthIndex)[0]?.index
|
|
if (nextIndex === undefined) break
|
|
const [month] = remainingMonths.splice(nextIndex, 1)
|
|
add({
|
|
...month.values[Math.round((month.values.length - 1) / 2)],
|
|
reason: 'time-quantile',
|
|
})
|
|
}
|
|
return Array.from(selected.values()).sort((left, right) => left.date.localeCompare(right.date))
|
|
}
|
|
|
|
const selectedMonth = (selected: Map<string, AgentTimelineSampleDate>, row: AgentTimelineSampleDate) => (
|
|
Array.from(selected.values()).some((candidate) => candidate.date.slice(0, 7) === row.date.slice(0, 7))
|
|
)
|
|
const canSelect = (selected: Map<string, AgentTimelineSampleDate>, row: AgentTimelineSampleDate) => (
|
|
!selected.has(row.date) && (!requireDistinctMonths || !selectedMonth(selected, row))
|
|
)
|
|
|
|
const trailingTransition = monthRows.length >= 2
|
|
? { previous: monthRows.at(-2)!, current: monthRows.at(-1)! }
|
|
: null
|
|
const trailingChangeRatio = trailingTransition
|
|
? Math.abs(trailingTransition.current.count - trailingTransition.previous.count)
|
|
/ Math.max(1, trailingTransition.current.count, trailingTransition.previous.count)
|
|
: 0
|
|
const trailingSide = trailingTransition
|
|
? trailingTransition.current.count >= trailingTransition.previous.count
|
|
? trailingTransition.current
|
|
: trailingTransition.previous
|
|
: null
|
|
const trailingSelections: AgentTimelineSampleDate[] = trailingSide && trailingChangeRatio >= 0.65 && windowCount >= 6
|
|
? [...trailingSide.values]
|
|
.sort((left, right) => right.count - left.count || left.date.localeCompare(right.date))
|
|
.slice(0, 2)
|
|
.map((row) => ({
|
|
...row,
|
|
reason: trailingSide === trailingTransition?.previous
|
|
? 'trailing-change-before' as const
|
|
: 'trailing-change-after' as const,
|
|
}))
|
|
: []
|
|
const maximumTransitionPairs = Math.min(
|
|
trailingSelections.length > 0 ? 1 : 3,
|
|
Math.max(1, Math.floor((windowCount - 2 - trailingSelections.length) / 3)),
|
|
)
|
|
const transitionCandidates = monthRows.slice(1).map((current, index) => {
|
|
const previous = monthRows[index]
|
|
const activeDayBalance = Math.min(previous.activeDays, current.activeDays)
|
|
/ Math.max(1, Math.max(previous.activeDays, current.activeDays))
|
|
return {
|
|
previous,
|
|
current,
|
|
score: Math.abs(current.count - previous.count) * (0.5 + activeDayBalance * 0.5),
|
|
}
|
|
}).sort((left, right) => right.score - left.score
|
|
|| left.previous.month.localeCompare(right.previous.month))
|
|
|
|
const transitionSelections: AgentTimelineSampleDate[] = []
|
|
const usedTransitionMonths = new Set<string>(trailingTransition && trailingSelections.length > 0
|
|
? [trailingTransition.previous.month, trailingTransition.current.month]
|
|
: [])
|
|
for (const candidate of transitionCandidates) {
|
|
if (transitionSelections.length >= maximumTransitionPairs * 2) break
|
|
if (usedTransitionMonths.has(candidate.previous.month) || usedTransitionMonths.has(candidate.current.month)) continue
|
|
usedTransitionMonths.add(candidate.previous.month)
|
|
usedTransitionMonths.add(candidate.current.month)
|
|
transitionSelections.push(
|
|
{ ...candidate.previous.peak, reason: 'activity-change-before' },
|
|
{ ...candidate.current.peak, reason: 'activity-change-after' },
|
|
)
|
|
}
|
|
const selected = new Map<string, AgentTimelineSampleDate>()
|
|
selected.set(rows[0].date, { ...rows[0], reason: 'range-start' })
|
|
selected.set(rows.at(-1)!.date, { ...rows.at(-1)!, reason: 'range-end' })
|
|
for (const row of trailingSelections) {
|
|
if (selected.size >= windowCount) break
|
|
if (row.date !== rows[0].date && row.date !== rows.at(-1)!.date && canSelect(selected, row)) selected.set(row.date, row)
|
|
}
|
|
for (const row of transitionSelections) {
|
|
if (selected.size >= windowCount) break
|
|
if (row.date !== rows[0].date && row.date !== rows.at(-1)!.date && canSelect(selected, row)) selected.set(row.date, row)
|
|
}
|
|
const remainingBaselineSlots = Math.max(0, windowCount - selected.size)
|
|
const baselineCandidates = baseline.filter((row) => row.date !== rows.at(-1)!.date && canSelect(selected, row))
|
|
const distributedBaseline = remainingBaselineSlots >= baselineCandidates.length
|
|
? baselineCandidates
|
|
: Array.from({ length: remainingBaselineSlots }, (_, index) => {
|
|
if (remainingBaselineSlots === 1) return baselineCandidates[Math.floor((baselineCandidates.length - 1) / 2)]
|
|
return baselineCandidates[Math.round((index * (baselineCandidates.length - 1)) / (remainingBaselineSlots - 1))]
|
|
}).filter((row, index, values) => values.indexOf(row) === index)
|
|
for (const row of distributedBaseline) {
|
|
if (row && canSelect(selected, row)) selected.set(row.date, row)
|
|
}
|
|
if (requireDistinctMonths && selected.size < windowCount) {
|
|
const remainingMonths = monthRows
|
|
.filter(({ month }) => month !== rows.at(-1)!.date.slice(0, 7))
|
|
.filter(({ month }) => !Array.from(selected.values()).some((row) => row.date.startsWith(month)))
|
|
const needed = Math.max(0, windowCount - selected.size)
|
|
const distributedMonths = needed >= remainingMonths.length
|
|
? remainingMonths
|
|
: Array.from({ length: needed }, (_, index) => {
|
|
if (needed === 1) return remainingMonths[Math.floor((remainingMonths.length - 1) / 2)]
|
|
return remainingMonths[Math.round((index * (remainingMonths.length - 1)) / (needed - 1))]
|
|
}).filter((row, index, values) => values.indexOf(row) === index)
|
|
for (const month of distributedMonths) {
|
|
if (!month) continue
|
|
const row = { ...month.peak, reason: 'time-quantile' as const }
|
|
if (canSelect(selected, row)) selected.set(row.date, row)
|
|
}
|
|
}
|
|
return Array.from(selected.values())
|
|
.sort((left, right) => left.date.localeCompare(right.date))
|
|
.slice(0, windowCount)
|
|
}
|
|
|
|
export function buildAgentTimelineCoverageMap(
|
|
monthlyRowsValue: unknown,
|
|
rawRangesValue: Array<{ startAt?: unknown; endAt?: unknown }>,
|
|
): {
|
|
activeMonthCount: number
|
|
touchedMonthCount: number
|
|
untouchedMonthCount: number
|
|
months: AgentTimelineCoverageMonth[]
|
|
} {
|
|
const ranges = rawRangesValue
|
|
.map((range) => ({
|
|
startMonth: safeText(range.startAt).slice(0, 7),
|
|
endMonth: safeText(range.endAt).slice(0, 7),
|
|
}))
|
|
.filter((range) => /^\d{4}-\d{2}$/.test(range.startMonth) && /^\d{4}-\d{2}$/.test(range.endMonth))
|
|
const months = (Array.isArray(monthlyRowsValue) ? monthlyRowsValue : [])
|
|
.map((value): AgentTimelineCoverageMonth | null => {
|
|
const row = value && typeof value === 'object' && !Array.isArray(value)
|
|
? value as Record<string, unknown>
|
|
: null
|
|
const month = safeText(row?.month).slice(0, 7)
|
|
if (!/^\d{4}-\d{2}$/.test(month)) return null
|
|
const representatives = Array.isArray(row?.representativeDates)
|
|
? row.representativeDates.map((candidate) => {
|
|
const record = candidate && typeof candidate === 'object' && !Array.isArray(candidate)
|
|
? candidate as Record<string, unknown>
|
|
: null
|
|
return safeText(record?.date || candidate).slice(0, 10)
|
|
})
|
|
: []
|
|
const representativeDates = Array.from(new Set([
|
|
safeText(row?.firstActiveDate).slice(0, 10),
|
|
...representatives,
|
|
safeText(row?.lastActiveDate).slice(0, 10),
|
|
].filter((date) => /^\d{4}-\d{2}-\d{2}$/.test(date)))).slice(0, 6)
|
|
const touched = ranges.some((range) => range.startMonth <= month && range.endMonth >= month)
|
|
return {
|
|
month,
|
|
messageCount: Math.max(0, Math.floor(Number(row?.count) || 0)),
|
|
activeDays: Math.max(0, Math.floor(Number(row?.activeDays) || 0)),
|
|
firstActiveDate: safeText(row?.firstActiveDate).slice(0, 10),
|
|
lastActiveDate: safeText(row?.lastActiveDate).slice(0, 10),
|
|
representativeDates,
|
|
rawPageCoverage: touched ? 'touched' : 'untouched',
|
|
}
|
|
})
|
|
.filter((row): row is AgentTimelineCoverageMonth => Boolean(row))
|
|
.sort((left, right) => left.month.localeCompare(right.month))
|
|
const touchedMonthCount = months.filter((row) => row.rawPageCoverage === 'touched').length
|
|
return {
|
|
activeMonthCount: months.length,
|
|
touchedMonthCount,
|
|
untouchedMonthCount: months.length - touchedMonthCount,
|
|
months,
|
|
}
|
|
}
|
|
|
|
export function filterAgentUnreadAnchorDates(
|
|
dates: unknown[],
|
|
pages: Array<{
|
|
displayName?: unknown
|
|
sessionId?: unknown
|
|
startAt?: unknown
|
|
endAt?: unknown
|
|
}>,
|
|
conversation = '',
|
|
): string[] {
|
|
const normalizedConversation = String(conversation || '').trim()
|
|
const normalizedDates = Array.from(new Set(dates
|
|
.map((value) => String(value || '').slice(0, 10))
|
|
.filter((date) => /^\d{4}-\d{2}-\d{2}$/.test(date))))
|
|
return normalizedDates.filter((date) => !pages.some((page) => {
|
|
const pageLabels = [page.displayName, page.sessionId]
|
|
.map((value) => String(value || '').trim())
|
|
.filter(Boolean)
|
|
if (normalizedConversation && pageLabels.length > 0 && !pageLabels.includes(normalizedConversation)) return false
|
|
const rawStartDate = String(page.startAt || '').slice(0, 10)
|
|
const rawEndDate = String(page.endAt || '').slice(0, 10)
|
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(rawStartDate) || !/^\d{4}-\d{2}-\d{2}$/.test(rawEndDate)) return false
|
|
const startDate = rawStartDate <= rawEndDate ? rawStartDate : rawEndDate
|
|
const endDate = rawStartDate <= rawEndDate ? rawEndDate : rawStartDate
|
|
return startDate <= date && endDate >= date
|
|
}))
|
|
}
|
|
|
|
function nextCalendarMonth(month: string): string {
|
|
const [year, monthNumber] = month.split('-').map(Number)
|
|
if (!year || !monthNumber) return ''
|
|
const next = new Date(Date.UTC(year, monthNumber, 1))
|
|
return `${next.getUTCFullYear()}-${String(next.getUTCMonth() + 1).padStart(2, '0')}`
|
|
}
|
|
|
|
export function groupAgentUntouchedTimelineSpans(
|
|
monthsValue: AgentTimelineCoverageMonth[],
|
|
): AgentTimelineCoverageSpan[] {
|
|
const months = monthsValue
|
|
.filter((row) => row.rawPageCoverage === 'untouched' && /^\d{4}-\d{2}$/.test(row.month))
|
|
.sort((left, right) => left.month.localeCompare(right.month))
|
|
const spans: AgentTimelineCoverageMonth[][] = []
|
|
for (const month of months) {
|
|
const current = spans.at(-1)
|
|
if (!current || nextCalendarMonth(current.at(-1)!.month) !== month.month) spans.push([month])
|
|
else current.push(month)
|
|
}
|
|
return spans.map((span) => {
|
|
const first = span[0]
|
|
const last = span.at(-1)!
|
|
const middle = span[Math.floor((span.length - 1) / 2)]
|
|
const middleDates = middle.representativeDates
|
|
const representativeDate = middleDates.find((date) => (
|
|
date !== middle.firstActiveDate && date !== middle.lastActiveDate
|
|
)) || middleDates[0] || ''
|
|
return {
|
|
startMonth: first.month,
|
|
endMonth: last.month,
|
|
monthCount: span.length,
|
|
messageCount: span.reduce((sum, row) => sum + row.messageCount, 0),
|
|
activeDays: span.reduce((sum, row) => sum + row.activeDays, 0),
|
|
firstActiveDate: first.firstActiveDate,
|
|
representativeDate,
|
|
lastActiveDate: last.lastActiveDate,
|
|
}
|
|
})
|
|
}
|
|
|
|
export function allocateAgentTimelineSamplePositions(
|
|
selectionsValue: AgentTimelineSampleDate[],
|
|
totalWindowCountValue: number,
|
|
expectedWindowMessagesValue: number,
|
|
): AgentTimelineSamplePosition[] {
|
|
const selections = selectionsValue
|
|
.map((selection) => ({
|
|
...selection,
|
|
date: safeText(selection.date),
|
|
count: Math.max(0, Number(selection.count) || 0),
|
|
}))
|
|
.filter((selection) => /^\d{4}-\d{2}-\d{2}$/.test(selection.date) && selection.count > 0)
|
|
.sort((left, right) => left.date.localeCompare(right.date))
|
|
if (selections.length === 0) return []
|
|
|
|
const totalWindowCount = Math.max(
|
|
selections.length,
|
|
Math.floor(Number(totalWindowCountValue) || selections.length),
|
|
)
|
|
const expectedWindowMessages = Math.max(1, Math.floor(Number(expectedWindowMessagesValue) || 1))
|
|
const positions = selections.map((selection, index): AgentTimelineSamplePosition => {
|
|
if (index === 0) return { ...selection, position: 'start', offset: 0 }
|
|
if (index === selections.length - 1) return { ...selection, position: 'end', offset: 0 }
|
|
const maximumMiddleOffset = Math.max(0, selection.count - expectedWindowMessages)
|
|
return {
|
|
...selection,
|
|
position: 'middle',
|
|
offset: Math.floor(maximumMiddleOffset / 2),
|
|
}
|
|
})
|
|
|
|
const reasonPriority = (reason: AgentTimelineSampleDate['reason']): number => {
|
|
if (reason === 'trailing-change-before' || reason === 'trailing-change-after') return 0
|
|
if (reason === 'activity-change-before' || reason === 'activity-change-after') return 1
|
|
if (reason === 'segment-activity-peak') return 2
|
|
if (reason === 'time-quantile') return 3
|
|
return 4
|
|
}
|
|
const candidates = [...positions]
|
|
.sort((left, right) => reasonPriority(left.reason) - reasonPriority(right.reason)
|
|
|| right.count - left.count
|
|
|| left.date.localeCompare(right.date))
|
|
const extraPositionOrder = (sample: AgentTimelineSamplePosition): Array<'start' | 'middle' | 'end'> => {
|
|
if (sample.position === 'start') return ['middle', 'end']
|
|
if (sample.position === 'end') return ['middle', 'start']
|
|
return ['start', 'end']
|
|
}
|
|
|
|
for (let round = 0; positions.length < totalWindowCount && round < 2; round += 1) {
|
|
for (const candidate of candidates) {
|
|
if (positions.length >= totalWindowCount) break
|
|
const position = extraPositionOrder(candidate)[round]
|
|
const minimumNonOverlappingMessages = expectedWindowMessages * (round + 2)
|
|
if (candidate.count < minimumNonOverlappingMessages) continue
|
|
if (positions.some((sample) => sample.date === candidate.date && sample.position === position)) continue
|
|
const maximumMiddleOffset = Math.max(0, candidate.count - expectedWindowMessages)
|
|
positions.push({
|
|
...candidate,
|
|
position,
|
|
offset: position === 'middle' ? Math.floor(maximumMiddleOffset / 2) : 0,
|
|
})
|
|
}
|
|
}
|
|
|
|
const positionRank = { start: 0, middle: 1, end: 2 }
|
|
return positions
|
|
.sort((left, right) => left.date.localeCompare(right.date) || positionRank[left.position] - positionRank[right.position])
|
|
.slice(0, totalWindowCount)
|
|
}
|
|
|
|
export function normalizeAgentRawTokenBudget(value: unknown): number {
|
|
const requested = Math.floor(Number(value) || AGENT_RAW_PAGE_DEFAULT_TOKENS)
|
|
return Math.max(AGENT_RAW_PAGE_MIN_TOKENS, Math.min(AGENT_RAW_PAGE_MAX_TOKENS, requested))
|
|
}
|
|
|
|
export function normalizeAgentTimelineWindowTokenBudget(value: unknown): number {
|
|
const requested = Math.floor(Number(value) || AGENT_TIMELINE_WINDOW_DEFAULT_TOKENS)
|
|
return Math.max(AGENT_RAW_PAGE_MIN_TOKENS, Math.min(AGENT_TIMELINE_WINDOW_MAX_TOKENS, requested))
|
|
}
|
|
|
|
export function estimateAgentRawTextTokens(value: string): number {
|
|
if (!value) return 0
|
|
let ascii = 0
|
|
let nonAscii = 0
|
|
for (const character of value) {
|
|
if (character.charCodeAt(0) <= 0x7f) ascii += 1
|
|
else nonAscii += 1
|
|
}
|
|
return Math.ceil(ascii / 4 + nonAscii / 1.45)
|
|
}
|
|
|
|
function splitFormattedRawMessageBlocks(value: string): string[] {
|
|
return safeText(value)
|
|
.split(/(?=^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\|)/m)
|
|
.map((block) => block.trim())
|
|
.filter((block) => /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\|/.test(block))
|
|
}
|
|
|
|
export function countAgentRawMessageBlocks(value: string): number {
|
|
return splitFormattedRawMessageBlocks(value).length
|
|
}
|
|
|
|
export function summarizeAgentRawPageTextCoverage(pages: Array<{
|
|
key: string
|
|
scopeKey?: string
|
|
pageText: string
|
|
}>): {
|
|
rawMessageBlocks: number
|
|
uniqueRawMessages: number
|
|
duplicateRawMessages: number
|
|
duplicateRatio: number
|
|
distinctScopes: number
|
|
} {
|
|
const normalizedPages = pages.map((page) => ({
|
|
key: safeText(page.key),
|
|
scopeKey: safeText(page.scopeKey),
|
|
pageText: safeText(page.pageText),
|
|
}))
|
|
const rawMessageBlocks = normalizedPages.reduce(
|
|
(sum, page) => sum + countAgentRawMessageBlocks(page.pageText),
|
|
0,
|
|
)
|
|
const deduplicated = deduplicateAgentRawPageTexts(normalizedPages)
|
|
const uniqueRawMessages = deduplicated.reduce(
|
|
(sum, page) => sum + countAgentRawMessageBlocks(page.pageText),
|
|
0,
|
|
)
|
|
const duplicateRawMessages = Math.max(0, rawMessageBlocks - uniqueRawMessages)
|
|
return {
|
|
rawMessageBlocks,
|
|
uniqueRawMessages,
|
|
duplicateRawMessages,
|
|
duplicateRatio: rawMessageBlocks > 0 ? duplicateRawMessages / rawMessageBlocks : 0,
|
|
distinctScopes: new Set(normalizedPages.map((page) => page.scopeKey).filter(Boolean)).size,
|
|
}
|
|
}
|
|
|
|
export function deduplicateAgentRawPageTexts(pages: Array<{
|
|
key: string
|
|
scopeKey?: string
|
|
priority?: boolean
|
|
pageText: string
|
|
}>): Array<{ key: string; scopeKey?: string; priority?: boolean; pageText: string; duplicateBlocks: number }> {
|
|
const seenBlocks = new Set<string>()
|
|
return pages.map((page) => {
|
|
const scopeKey = safeText(page.scopeKey)
|
|
const uniqueBlocks: string[] = []
|
|
let duplicateBlocks = 0
|
|
for (const block of splitFormattedRawMessageBlocks(page.pageText)) {
|
|
const blockKey = `${scopeKey}\u0001${block}`
|
|
if (seenBlocks.has(blockKey)) {
|
|
duplicateBlocks += 1
|
|
continue
|
|
}
|
|
seenBlocks.add(blockKey)
|
|
uniqueBlocks.push(block)
|
|
}
|
|
return {
|
|
key: safeText(page.key),
|
|
scopeKey: scopeKey || undefined,
|
|
priority: page.priority === true || undefined,
|
|
pageText: uniqueBlocks.join('\n'),
|
|
duplicateBlocks,
|
|
}
|
|
})
|
|
}
|
|
|
|
export function allocateAgentRawPageTokenBudgets(
|
|
pages: Array<{ key: string; scopeKey?: string; priority?: boolean; pageText: string }>,
|
|
totalTokenBudget: number,
|
|
priorityTermsValue: string[] = [],
|
|
): Map<string, number> {
|
|
const allocations = new Map<string, number>()
|
|
const priorityTerms = Array.from(new Set(priorityTermsValue.map(safeText).filter(Boolean)))
|
|
const selectDistributed = <T>(values: T[], count: number): T[] => {
|
|
if (count <= 0 || values.length === 0) return []
|
|
if (count >= values.length) return [...values]
|
|
if (count === 1) return [values[Math.floor((values.length - 1) / 2)]]
|
|
return Array.from({ length: count }, (_, index) => values[Math.round((index * (values.length - 1)) / (count - 1))])
|
|
.filter((value, index, selected) => selected.indexOf(value) === index)
|
|
}
|
|
let pending = pages
|
|
.map((page, index) => ({
|
|
key: safeText(page.key),
|
|
scopeKey: safeText(page.scopeKey),
|
|
priority: page.priority === true,
|
|
pageText: safeText(page.pageText),
|
|
needed: estimateAgentRawTextTokens(page.pageText),
|
|
index,
|
|
}))
|
|
.filter((page) => page.key)
|
|
for (const page of pending) allocations.set(page.key, 0)
|
|
const isPriority = (page: (typeof pending)[number]) => page.priority
|
|
|| (priorityTerms.length > 0 && priorityTerms.some((term) => page.pageText.includes(term)))
|
|
let remaining = Math.max(0, Math.floor(Number(totalTokenBudget) || 0))
|
|
// 把紧张的合成预算分摊到每一页,可能使各页低于 excerptAgentRawPageText 的有效下限,
|
|
// 实际上丢掉全部原文。这里改为保留按时间分布的页子集,同时留下包含模型所选
|
|
// 导航词项的页面。
|
|
const minimumUsefulExcerptTokens = 500
|
|
const minimumRetainedCost = (page: (typeof pending)[number]) => Math.max(
|
|
1,
|
|
Math.min(page.needed, minimumUsefulExcerptTokens),
|
|
)
|
|
const minimumBudgetForEveryPage = pending.reduce((sum, page) => sum + minimumRetainedCost(page), 0)
|
|
if (pending.length > 1 && remaining < minimumBudgetForEveryPage) {
|
|
const sortedMinimumCosts = pending.map(minimumRetainedCost).sort((left, right) => left - right)
|
|
let maximumPages = 0
|
|
let selectedMinimumCost = 0
|
|
for (const cost of sortedMinimumCosts) {
|
|
if (selectedMinimumCost + cost > remaining) break
|
|
selectedMinimumCost += cost
|
|
maximumPages += 1
|
|
}
|
|
maximumPages = Math.max(1, Math.min(pending.length, maximumPages))
|
|
const pagesByScope = new Map<string, typeof pending>()
|
|
for (const page of pending) {
|
|
const groupKey = page.scopeKey || '__single_scope__'
|
|
const group = pagesByScope.get(groupKey) || []
|
|
group.push(page)
|
|
pagesByScope.set(groupKey, group)
|
|
}
|
|
const scopeGroups = Array.from(pagesByScope.values())
|
|
const selectedGroups = selectDistributed(scopeGroups, Math.min(scopeGroups.length, maximumPages))
|
|
const selected: typeof pending = []
|
|
for (const group of selectedGroups) {
|
|
const preferred = group.filter(isPriority)
|
|
selected.push(selectDistributed(preferred.length > 0 ? preferred : group, 1)[0])
|
|
}
|
|
const selectedKeys = new Set(selected.map((page) => page.key))
|
|
const remainingCandidates = pending.filter((page) => !selectedKeys.has(page.key))
|
|
for (const page of selectDistributed(remainingCandidates, maximumPages - selected.length)) {
|
|
selected.push(page)
|
|
selectedKeys.add(page.key)
|
|
}
|
|
pending = pending.filter((page) => selectedKeys.has(page.key))
|
|
}
|
|
|
|
const sourceGroups = new Map<string, typeof pending>()
|
|
for (const page of pending) {
|
|
const groupKey = page.scopeKey || '__single_scope__'
|
|
const group = sourceGroups.get(groupKey) || []
|
|
group.push(page)
|
|
sourceGroups.set(groupKey, group)
|
|
}
|
|
const sourceBudgets = new Map<string, number>()
|
|
const unsettledSources = Array.from(sourceGroups.entries())
|
|
.map(([scopeKey, sourcePages]) => ({
|
|
scopeKey,
|
|
needed: sourcePages.reduce((sum, page) => sum + page.needed, 0),
|
|
}))
|
|
.sort((left, right) => left.needed - right.needed || left.scopeKey.localeCompare(right.scopeKey))
|
|
let sourceBudgetRemaining = remaining
|
|
for (let index = 0; index < unsettledSources.length; index += 1) {
|
|
const source = unsettledSources[index]
|
|
const fairShare = Math.floor(sourceBudgetRemaining / Math.max(1, unsettledSources.length - index))
|
|
if (source.needed <= fairShare) {
|
|
sourceBudgets.set(source.scopeKey, source.needed)
|
|
sourceBudgetRemaining -= source.needed
|
|
continue
|
|
}
|
|
for (let rest = index; rest < unsettledSources.length; rest += 1) {
|
|
sourceBudgets.set(unsettledSources[rest].scopeKey, fairShare)
|
|
}
|
|
sourceBudgetRemaining = 0
|
|
break
|
|
}
|
|
|
|
const allocateWithinSource = (sourcePages: typeof pending, sourceTokenBudget: number) => {
|
|
let sourceRemaining = Math.max(0, sourceTokenBudget)
|
|
let retainedPages = [...sourcePages]
|
|
const minimumNeeded = (page: (typeof pending)[number]) => Math.max(
|
|
1,
|
|
Math.min(page.needed, minimumUsefulExcerptTokens),
|
|
)
|
|
const totalMinimumNeeded = retainedPages.reduce((sum, page) => sum + minimumNeeded(page), 0)
|
|
if (retainedPages.length > 1 && sourceRemaining < totalMinimumNeeded) {
|
|
const sortedCosts = retainedPages.map(minimumNeeded).sort((left, right) => left - right)
|
|
let affordableCount = 0
|
|
let affordableCost = 0
|
|
for (const cost of sortedCosts) {
|
|
if (affordableCost + cost > sourceRemaining) break
|
|
affordableCost += cost
|
|
affordableCount += 1
|
|
}
|
|
affordableCount = Math.max(1, Math.min(retainedPages.length, affordableCount))
|
|
const priorityPages = retainedPages.filter(isPriority)
|
|
const ordinaryPages = retainedPages.filter((page) => !isPriority(page))
|
|
const priorityCount = priorityPages.length > 0
|
|
? Math.min(priorityPages.length, affordableCount, Math.max(1, Math.ceil(affordableCount * 0.75)))
|
|
: 0
|
|
const ordinaryCount = ordinaryPages.length > 0
|
|
? Math.min(ordinaryPages.length, affordableCount - priorityCount)
|
|
: 0
|
|
const selected: typeof retainedPages = [
|
|
...selectDistributed(priorityPages, priorityCount),
|
|
...selectDistributed(ordinaryPages, ordinaryCount),
|
|
]
|
|
const selectedKeys = new Set(selected.map((page) => page.key))
|
|
const remainingCandidates = [
|
|
...priorityPages.filter((page) => !selectedKeys.has(page.key)),
|
|
...ordinaryPages.filter((page) => !selectedKeys.has(page.key)),
|
|
]
|
|
for (const page of selectDistributed(remainingCandidates, affordableCount - selected.length)) {
|
|
selected.push(page)
|
|
selectedKeys.add(page.key)
|
|
}
|
|
retainedPages = retainedPages.filter((page) => selectedKeys.has(page.key))
|
|
}
|
|
|
|
// 每个保留页面先获得足以容纳连贯摘录的空间,优先级只分配剩余的细节预算,
|
|
// 因此聚焦页面不会让所有时间背景或其他候选来源的上下文消失。
|
|
for (const page of retainedPages) {
|
|
const base = Math.min(sourceRemaining, minimumNeeded(page))
|
|
allocations.set(page.key, base)
|
|
sourceRemaining -= base
|
|
}
|
|
if (sourceRemaining <= 0) return
|
|
|
|
const priorityGroups = retainedPages.some(isPriority)
|
|
? [retainedPages.filter(isPriority), retainedPages.filter((page) => !isPriority(page))]
|
|
: [retainedPages]
|
|
for (let groupIndex = 0; groupIndex < priorityGroups.length; groupIndex += 1) {
|
|
const group = priorityGroups[groupIndex]
|
|
.filter((page) => (allocations.get(page.key) || 0) < page.needed)
|
|
.sort((left, right) => left.needed - right.needed || left.index - right.index)
|
|
if (group.length === 0 || sourceRemaining <= 0) continue
|
|
const groupBudget = priorityGroups.length === 1
|
|
? sourceRemaining
|
|
: groupIndex === 0
|
|
? Math.floor(sourceTokenBudget * 0.7)
|
|
: sourceRemaining
|
|
let groupRemaining = Math.min(sourceRemaining, groupBudget)
|
|
for (let index = 0; index < group.length; index += 1) {
|
|
const page = group[index]
|
|
const current = allocations.get(page.key) || 0
|
|
const remainingNeed = Math.max(0, page.needed - current)
|
|
const fairShare = Math.floor(groupRemaining / Math.max(1, group.length - index))
|
|
if (remainingNeed <= fairShare) {
|
|
allocations.set(page.key, current + remainingNeed)
|
|
groupRemaining -= remainingNeed
|
|
sourceRemaining -= remainingNeed
|
|
continue
|
|
}
|
|
for (let rest = index; rest < group.length; rest += 1) {
|
|
const restPage = group[rest]
|
|
allocations.set(restPage.key, (allocations.get(restPage.key) || 0) + fairShare)
|
|
}
|
|
sourceRemaining -= fairShare * (group.length - index)
|
|
groupRemaining = 0
|
|
break
|
|
}
|
|
}
|
|
}
|
|
for (const [scopeKey, sourcePages] of sourceGroups) {
|
|
allocateWithinSource(sourcePages, sourceBudgets.get(scopeKey) || 0)
|
|
}
|
|
return allocations
|
|
}
|
|
|
|
export function excerptAgentRawPageText(
|
|
pageTextValue: string,
|
|
tokenBudgetValue: number,
|
|
priorityTermsValue: string[] = [],
|
|
): string {
|
|
const pageText = safeText(pageTextValue)
|
|
const tokenBudget = Math.max(0, Math.floor(Number(tokenBudgetValue) || 0))
|
|
if (!pageText || tokenBudget === 0) return ''
|
|
if (estimateAgentRawTextTokens(pageText) <= tokenBudget) return pageText
|
|
const blocks = splitFormattedRawMessageBlocks(pageText)
|
|
if (blocks.length === 0 || tokenBudget < 500) return ''
|
|
const priorityTerms = Array.from(new Set(priorityTermsValue.map(safeText).filter(Boolean)))
|
|
|
|
const buildExcerpt = (workingBudget: number) => {
|
|
const selected = new Map<number, string>()
|
|
const addWindow = (center: number, requestedBudget: number) => {
|
|
const order: number[] = [center]
|
|
for (let radius = 1; radius < blocks.length; radius += 1) {
|
|
if (center - radius >= 0) order.push(center - radius)
|
|
if (center + radius < blocks.length) order.push(center + radius)
|
|
}
|
|
let remainingWindow = Math.max(60, requestedBudget)
|
|
let addedInWindow = 0
|
|
for (const blockIndex of order) {
|
|
if (selected.has(blockIndex)) continue
|
|
const block = blocks[blockIndex]
|
|
const blockTokens = estimateAgentRawTextTokens(block)
|
|
if (blockTokens <= remainingWindow) {
|
|
selected.set(blockIndex, block)
|
|
remainingWindow -= blockTokens
|
|
addedInWindow += 1
|
|
continue
|
|
}
|
|
if (addedInWindow === 0 && remainingWindow >= 60) {
|
|
const keepCharacters = Math.max(80, Math.floor(block.length * (remainingWindow / Math.max(1, blockTokens))))
|
|
selected.set(blockIndex, `${block.slice(0, keepCharacters)}\n[该条长消息后部因当前工具返回预算省略]`)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
const priorityCenters = priorityTerms.flatMap((term) => {
|
|
const matches: number[] = []
|
|
for (let index = 0; index < blocks.length; index += 1) {
|
|
if (blocks[index].includes(term)) matches.push(index)
|
|
}
|
|
return matches.length > 0 ? [matches[Math.floor((matches.length - 1) / 2)]] : []
|
|
})
|
|
const uniquePriorityCenters = Array.from(new Set(priorityCenters))
|
|
const priorityBudget = uniquePriorityCenters.length > 0 ? Math.floor(workingBudget * 0.65) : 0
|
|
const priorityWindowBudget = Math.max(60, Math.floor(priorityBudget / Math.max(1, uniquePriorityCenters.length)))
|
|
for (const center of uniquePriorityCenters) addWindow(center, priorityWindowBudget)
|
|
|
|
const distributedBudget = Math.max(0, workingBudget - priorityBudget)
|
|
const windowCount = Math.min(blocks.length, Math.max(3, Math.min(7, Math.floor(distributedBudget / 400))))
|
|
const windowBudget = Math.max(60, Math.floor((distributedBudget - windowCount * 24) / windowCount))
|
|
for (let windowIndex = 0; windowIndex < windowCount; windowIndex += 1) {
|
|
const center = windowCount === 1
|
|
? 0
|
|
: Math.round((blocks.length - 1) * (windowIndex / (windowCount - 1)))
|
|
addWindow(center, windowBudget)
|
|
}
|
|
const ordered = Array.from(selected.entries()).sort((left, right) => left[0] - right[0])
|
|
const output: string[] = []
|
|
let previousIndex = -2
|
|
for (const [blockIndex, block] of ordered) {
|
|
if (blockIndex > previousIndex + 1) output.push('[因当前工具返回预算省略该页部分普通消息]')
|
|
output.push(block)
|
|
previousIndex = blockIndex
|
|
}
|
|
if (previousIndex < blocks.length - 1) output.push('[因当前工具返回预算省略该页部分普通消息]')
|
|
return output.join('\n')
|
|
}
|
|
|
|
let excerpt = buildExcerpt(tokenBudget)
|
|
const excerptTokens = estimateAgentRawTextTokens(excerpt)
|
|
if (excerptTokens > tokenBudget) {
|
|
excerpt = buildExcerpt(Math.max(500, Math.floor(tokenBudget * (tokenBudget / excerptTokens) * 0.9)))
|
|
}
|
|
return excerpt
|
|
}
|
|
|
|
export function formatAgentRawTime(timestamp: number): string {
|
|
const value = Number(timestamp || 0)
|
|
if (!Number.isFinite(value) || value <= 0) return 'unknown-time'
|
|
const date = new Date(value > 10_000_000_000 ? value : value * 1000)
|
|
if (Number.isNaN(date.getTime())) return 'unknown-time'
|
|
const pad = (part: number) => String(part).padStart(2, '0')
|
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
|
}
|
|
|
|
export function formatAgentRawMessage(record: AgentRawMessageRecord): string {
|
|
const body = safeText(record.content) || `[${safeText(record.messageType) || 'unknown'}]`
|
|
const quote = safeText(record.quotedContent)
|
|
const quoteSuffix = quote
|
|
? `\n > ${safeText(record.quotedSender) || '引用'}: ${quote.replace(/\n/g, '\n > ')}`
|
|
: ''
|
|
const media = [
|
|
record.voiceRef ? `voice=${record.voiceRef}` : '',
|
|
record.imageRef ? `image=${record.imageRef}` : '',
|
|
].filter(Boolean).join(',')
|
|
const kind = safeText(record.messageType) || 'text'
|
|
const kindColumn = kind === 'text' && !media ? '' : `|${kind}${media ? `|${media}` : ''}`
|
|
return `${formatAgentRawTime(record.createTime)}|${safeText(record.sender) || '未知'}${kindColumn}|${body}${quoteSuffix}`
|
|
}
|
|
|
|
export function selectAgentRawPage(
|
|
records: AgentRawMessageRecord[],
|
|
tokenBudgetValue: unknown,
|
|
minimumTokenBudgetValue = AGENT_RAW_PAGE_MIN_TOKENS,
|
|
): AgentRawPageSelection {
|
|
const minimumTokenBudget = Math.max(160, Math.min(
|
|
AGENT_RAW_PAGE_MIN_TOKENS,
|
|
Math.floor(Number(minimumTokenBudgetValue) || AGENT_RAW_PAGE_MIN_TOKENS),
|
|
))
|
|
const tokenBudget = Math.max(minimumTokenBudget, Math.min(
|
|
AGENT_RAW_PAGE_MAX_TOKENS,
|
|
Math.floor(Number(tokenBudgetValue) || AGENT_RAW_PAGE_DEFAULT_TOKENS),
|
|
))
|
|
if (records.length === 0) return { records: [], consumed: 0, estimatedTokens: 0 }
|
|
|
|
const minimumBeforeNaturalBreak = Math.floor(tokenBudget * 0.68)
|
|
const selected: AgentRawMessageRecord[] = []
|
|
let estimatedTokens = 0
|
|
|
|
for (let index = 0; index < records.length; index += 1) {
|
|
const record = records[index]
|
|
const lineTokens = estimateAgentRawTextTokens(formatAgentRawMessage(record)) + 6
|
|
if (selected.length > 0 && estimatedTokens + lineTokens > tokenBudget) break
|
|
selected.push(record)
|
|
estimatedTokens += lineTokens
|
|
|
|
const next = records[index + 1]
|
|
if (!next || estimatedTokens < minimumBeforeNaturalBreak) continue
|
|
const currentSeconds = record.createTime > 10_000_000_000 ? record.createTime / 1000 : record.createTime
|
|
const nextSeconds = next.createTime > 10_000_000_000 ? next.createTime / 1000 : next.createTime
|
|
if (Math.abs(nextSeconds - currentSeconds) >= 30 * 60) break
|
|
}
|
|
|
|
return {
|
|
records: selected,
|
|
consumed: selected.length,
|
|
estimatedTokens,
|
|
}
|
|
}
|
|
|
|
export function selectAgentCenteredRawPage(
|
|
recordsValue: AgentRawMessageRecord[],
|
|
anchorValue: AgentRawMessageRecord,
|
|
tokenBudgetValue: unknown,
|
|
minimumTokenBudgetValue = 160,
|
|
): AgentRawPageSelection {
|
|
const records = [...recordsValue]
|
|
.filter((record) => Number(record.createTime) > 0)
|
|
.sort((left, right) => left.createTime - right.createTime || left.sortSeq - right.sortSeq || left.localId - right.localId)
|
|
const anchorIndex = records.findIndex((record) => (
|
|
record === anchorValue
|
|
|| (
|
|
record.localId === anchorValue?.localId
|
|
&& record.createTime === anchorValue?.createTime
|
|
&& record.sortSeq === anchorValue?.sortSeq
|
|
)
|
|
))
|
|
if (anchorIndex < 0) return selectAgentRawPage(records, tokenBudgetValue, minimumTokenBudgetValue)
|
|
const minimumTokenBudget = Math.max(160, Math.min(
|
|
AGENT_RAW_PAGE_MIN_TOKENS,
|
|
Math.floor(Number(minimumTokenBudgetValue) || 160),
|
|
))
|
|
const tokenBudget = Math.max(minimumTokenBudget, Math.min(
|
|
AGENT_RAW_PAGE_MAX_TOKENS,
|
|
Math.floor(Number(tokenBudgetValue) || minimumTokenBudget),
|
|
))
|
|
const selected = new Set<number>([anchorIndex])
|
|
let estimatedTokens = estimateAgentRawTextTokens(formatAgentRawMessage(records[anchorIndex])) + 6
|
|
for (let distance = 1; distance < records.length; distance += 1) {
|
|
let added = false
|
|
for (const index of [anchorIndex - distance, anchorIndex + distance]) {
|
|
if (index < 0 || index >= records.length || selected.has(index)) continue
|
|
const lineTokens = estimateAgentRawTextTokens(formatAgentRawMessage(records[index])) + 6
|
|
if (estimatedTokens + lineTokens > tokenBudget) continue
|
|
selected.add(index)
|
|
estimatedTokens += lineTokens
|
|
added = true
|
|
}
|
|
if (!added && anchorIndex - distance < 0 && anchorIndex + distance >= records.length) break
|
|
}
|
|
return {
|
|
records: Array.from(selected).sort((left, right) => left - right).map((index) => records[index]),
|
|
consumed: selected.size,
|
|
estimatedTokens,
|
|
}
|
|
}
|
|
|
|
export function encodeAgentRawPageCursor(cursor: AgentRawPageCursor): string {
|
|
const normalized: AgentRawPageCursor = {
|
|
version: 1,
|
|
sessionId: safeText(cursor.sessionId).slice(0, 512),
|
|
startTime: Math.max(0, Math.floor(Number(cursor.startTime) || 0)),
|
|
endTime: Math.max(0, Math.floor(Number(cursor.endTime) || 0)),
|
|
direction: cursor.direction === 'backward' ? 'backward' : 'forward',
|
|
offset: Math.max(0, Math.floor(Number(cursor.offset) || 0)),
|
|
}
|
|
if (!normalized.sessionId) throw new Error('原文页游标缺少会话')
|
|
return Buffer.from(JSON.stringify(normalized), 'utf8').toString('base64url')
|
|
}
|
|
|
|
export function decodeAgentRawPageCursor(value: unknown): AgentRawPageCursor | null {
|
|
try {
|
|
const parsed = JSON.parse(Buffer.from(safeText(value), 'base64url').toString('utf8')) as Partial<AgentRawPageCursor>
|
|
if (parsed.version !== 1 || !safeText(parsed.sessionId)) return null
|
|
if (parsed.direction !== 'forward' && parsed.direction !== 'backward') return null
|
|
const startTime = Math.max(0, Math.floor(Number(parsed.startTime) || 0))
|
|
const endTime = Math.max(0, Math.floor(Number(parsed.endTime) || 0))
|
|
const offset = Math.max(0, Math.floor(Number(parsed.offset) || 0))
|
|
if (startTime > 0 && endTime > 0 && startTime > endTime) return null
|
|
return {
|
|
version: 1,
|
|
sessionId: safeText(parsed.sessionId),
|
|
startTime,
|
|
endTime,
|
|
direction: parsed.direction,
|
|
offset,
|
|
}
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
export function agentRawPageHash(records: AgentRawMessageRecord[]): string {
|
|
return createHash('sha256')
|
|
.update(records.map((record) => [
|
|
record.sessionId,
|
|
record.localId,
|
|
record.messageKey || '',
|
|
record.createTime,
|
|
record.sortSeq,
|
|
record.sender,
|
|
record.messageType,
|
|
record.content,
|
|
record.quotedSender || '',
|
|
record.quotedContent || '',
|
|
].join('\u0001')).join('\u0002')).digest('base64url')
|
|
}
|
|
|
|
export function agentRawPageCacheFingerprint(input: {
|
|
accountFingerprint?: string
|
|
sessionId: string
|
|
startTime: number
|
|
endTime: number
|
|
direction: AgentRawPageDirection
|
|
offset: number
|
|
tokenBudget: number
|
|
tailWatermark?: string
|
|
}): string {
|
|
return createHash('sha256').update(JSON.stringify({
|
|
version: AGENT_RAW_PAGE_FORMAT_VERSION,
|
|
accountFingerprint: safeText(input.accountFingerprint),
|
|
sessionId: safeText(input.sessionId),
|
|
startTime: Math.max(0, Math.floor(Number(input.startTime) || 0)),
|
|
endTime: Math.max(0, Math.floor(Number(input.endTime) || 0)),
|
|
direction: input.direction === 'backward' ? 'backward' : 'forward',
|
|
offset: Math.max(0, Math.floor(Number(input.offset) || 0)),
|
|
tokenBudget: normalizeAgentRawTokenBudget(input.tokenBudget),
|
|
tailWatermark: safeText(input.tailWatermark),
|
|
})).digest('base64url')
|
|
}
|