feat(suggest): 新增 fetchSuggestionContext 聚合函数 + 类型定义
- 4 路并行拉取智能上下文:用户画像、痛点、经验、技能匹配 - 500ms 超时保护 + 静默降级(失败不阻断建议生成) - Tauri 不可用时直接返回空上下文
This commit is contained in:
131
desktop/src/lib/suggestion-context.ts
Normal file
131
desktop/src/lib/suggestion-context.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* Suggestion context enrichment — fetches intelligence data for personalized suggestions.
|
||||||
|
* All fetches are optional; failures silently degrade to empty context.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { createLogger } from './logger';
|
||||||
|
|
||||||
|
const log = createLogger('SuggestionContext');
|
||||||
|
|
||||||
|
const CONTEXT_FETCH_TIMEOUT = 500;
|
||||||
|
|
||||||
|
/** Pain point from butler intelligence layer. */
|
||||||
|
interface PainPoint {
|
||||||
|
summary: string;
|
||||||
|
category: string;
|
||||||
|
confidence: number;
|
||||||
|
status: string;
|
||||||
|
occurrence_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Brief experience from the experience store. */
|
||||||
|
interface ExperienceBrief {
|
||||||
|
pain_pattern: string;
|
||||||
|
solution_summary: string;
|
||||||
|
reuse_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pipeline/skill match candidate. */
|
||||||
|
interface PipelineCandidateInfo {
|
||||||
|
id: string;
|
||||||
|
display_name: string;
|
||||||
|
description: string;
|
||||||
|
category: string | null;
|
||||||
|
match_reason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Route intent response (only NoMatch variant has suggestions). */
|
||||||
|
interface RouteResultResponse {
|
||||||
|
type: 'Matched' | 'Ambiguous' | 'NoMatch' | 'NeedMoreInfo';
|
||||||
|
suggestions?: PipelineCandidateInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aggregated suggestion context from all intelligence sources. */
|
||||||
|
export interface SuggestionContext {
|
||||||
|
userProfile: string;
|
||||||
|
painPoints: string;
|
||||||
|
experiences: string;
|
||||||
|
skillMatch: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTauriAvailable(): boolean {
|
||||||
|
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T | null> {
|
||||||
|
return Promise.race([
|
||||||
|
promise,
|
||||||
|
new Promise<null>(resolve => setTimeout(() => resolve(null), ms)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUserProfile(agentId: string): Promise<string> {
|
||||||
|
const profile = await invoke<string>('identity_get_file', {
|
||||||
|
agentId,
|
||||||
|
file: 'userprofile',
|
||||||
|
});
|
||||||
|
if (!profile || profile.trim().length === 0) return '';
|
||||||
|
const text = profile.trim();
|
||||||
|
return text.length > 200 ? text.slice(0, 200) : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPainPoints(agentId: string): Promise<string> {
|
||||||
|
const points = await invoke<PainPoint[]>('butler_list_pain_points', { agentId });
|
||||||
|
if (!Array.isArray(points) || points.length === 0) return '';
|
||||||
|
|
||||||
|
const active = points
|
||||||
|
.filter(p => p.confidence >= 0.5 && p.status !== 'Solved' && p.status !== 'Dismissed')
|
||||||
|
.sort((a, b) => b.confidence - a.confidence)
|
||||||
|
.slice(0, 3);
|
||||||
|
|
||||||
|
if (active.length === 0) return '';
|
||||||
|
return active
|
||||||
|
.map((p, i) => `${i + 1}. [${p.category}] ${p.summary}(出现${p.occurrence_count}次)`)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchExperiences(agentId: string, query: string): Promise<string> {
|
||||||
|
const experiences = await invoke<ExperienceBrief[]>('experience_find_relevant', {
|
||||||
|
agentId,
|
||||||
|
query,
|
||||||
|
});
|
||||||
|
if (!Array.isArray(experiences) || experiences.length === 0) return '';
|
||||||
|
|
||||||
|
return experiences.slice(0, 2)
|
||||||
|
.map(e => `上次解决"${e.pain_pattern}"的方法:${e.solution_summary}(已复用${e.reuse_count}次)`)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSkillMatch(userInput: string): Promise<string> {
|
||||||
|
const result = await invoke<RouteResultResponse>('route_intent', { userInput });
|
||||||
|
const suggestions = result?.suggestions;
|
||||||
|
if (!Array.isArray(suggestions) || suggestions.length === 0) return '';
|
||||||
|
|
||||||
|
const best = suggestions[0];
|
||||||
|
return `你可能需要:${best.display_name} — ${best.description}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_CONTEXT: SuggestionContext = { userProfile: '', painPoints: '', experiences: '', skillMatch: '' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all intelligence context in parallel for suggestion enrichment.
|
||||||
|
* Returns empty strings for any source that fails — never throws.
|
||||||
|
*/
|
||||||
|
export async function fetchSuggestionContext(
|
||||||
|
agentId: string,
|
||||||
|
lastUserMessage: string,
|
||||||
|
): Promise<SuggestionContext> {
|
||||||
|
if (!isTauriAvailable()) {
|
||||||
|
return EMPTY_CONTEXT;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [userProfile, painPoints, experiences, skillMatch] = await Promise.all([
|
||||||
|
withTimeout(fetchUserProfile(agentId).catch(e => { log.warn('User profile fetch failed:', e); return ''; }), CONTEXT_FETCH_TIMEOUT),
|
||||||
|
withTimeout(fetchPainPoints(agentId).catch(e => { log.warn('Pain points fetch failed:', e); return ''; }), CONTEXT_FETCH_TIMEOUT),
|
||||||
|
withTimeout(fetchExperiences(agentId, lastUserMessage).catch(e => { log.warn('Experiences fetch failed:', e); return ''; }), CONTEXT_FETCH_TIMEOUT),
|
||||||
|
withTimeout(fetchSkillMatch(lastUserMessage).catch(e => { log.warn('Skill match fetch failed:', e); return ''; }), CONTEXT_FETCH_TIMEOUT),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { userProfile: userProfile ?? '', painPoints: painPoints ?? '', experiences: experiences ?? '', skillMatch: skillMatch ?? '' };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user