feat(chat): LLM 动态对话建议 — 替换硬编码关键词匹配
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
AI 回复结束后,将最近对话发给 LLM 生成 3 个上下文相关的后续问题, 替换原有的"继续深入分析"等泛泛默认建议。 变更: - llm-service.ts: 添加 suggestions 提示模板 + llmSuggest() 辅助函数 - streamStore.ts: SSE 流式请求 via SaaS relay,response.text() 一次性 读取避免 Tauri WebView2 ReadableStream 兼容问题,失败降级到关键词 - chatStore.ts: suggestionsLoading 状态镜像 - SuggestionChips.tsx: loading 骨架动画 - ChatArea.tsx: 传递 loading prop
This commit is contained in:
@@ -53,7 +53,7 @@ export function ChatArea({ compact, onOpenDetail }: { compact?: boolean; onOpenD
|
|||||||
const {
|
const {
|
||||||
messages, isStreaming, isLoading,
|
messages, isStreaming, isLoading,
|
||||||
sendMessage: sendToGateway, initStreamListener,
|
sendMessage: sendToGateway, initStreamListener,
|
||||||
chatMode, setChatMode, suggestions,
|
chatMode, setChatMode, suggestions, suggestionsLoading,
|
||||||
totalInputTokens, totalOutputTokens,
|
totalInputTokens, totalOutputTokens,
|
||||||
cancelStream,
|
cancelStream,
|
||||||
} = useChatStore();
|
} = useChatStore();
|
||||||
@@ -505,9 +505,10 @@ export function ChatArea({ compact, onOpenDetail }: { compact?: boolean; onOpenD
|
|||||||
<div className="flex-shrink-0 p-4 bg-white dark:bg-gray-900">
|
<div className="flex-shrink-0 p-4 bg-white dark:bg-gray-900">
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
{/* Suggestion chips */}
|
{/* Suggestion chips */}
|
||||||
{!isStreaming && suggestions.length > 0 && !messages.some(m => m.error) && (
|
{!isStreaming && !messages.some(m => m.error) && (suggestions.length > 0 || suggestionsLoading) && (
|
||||||
<SuggestionChips
|
<SuggestionChips
|
||||||
suggestions={suggestions}
|
suggestions={suggestions}
|
||||||
|
loading={suggestionsLoading}
|
||||||
onSelect={(text) => { setInput(text); textareaRef.current?.focus(); setTimeout(() => handleSend(), 0); }}
|
onSelect={(text) => { setInput(text); textareaRef.current?.focus(); setTimeout(() => handleSend(), 0); }}
|
||||||
className="mb-3"
|
className="mb-3"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,15 +7,30 @@ import { motion } from 'framer-motion';
|
|||||||
* - Horizontal scrollable chip list
|
* - Horizontal scrollable chip list
|
||||||
* - Click to fill input
|
* - Click to fill input
|
||||||
* - Animated entrance
|
* - Animated entrance
|
||||||
|
* - Loading skeleton while LLM generates suggestions
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface SuggestionChipsProps {
|
interface SuggestionChipsProps {
|
||||||
suggestions: string[];
|
suggestions: string[];
|
||||||
|
loading?: boolean;
|
||||||
onSelect: (text: string) => void;
|
onSelect: (text: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SuggestionChips({ suggestions, onSelect, className = '' }: SuggestionChipsProps) {
|
export function SuggestionChips({ suggestions, loading, onSelect, className = '' }: SuggestionChipsProps) {
|
||||||
|
if (loading && suggestions.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-wrap gap-2 ${className}`}>
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-7 w-28 rounded-full bg-gray-100 dark:bg-gray-800 animate-pulse"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (suggestions.length === 0) return null;
|
if (suggestions.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -644,6 +644,21 @@ const HARDCODED_PROMPTS: Record<string, { system: string; user: (arg: string) =>
|
|||||||
]`,
|
]`,
|
||||||
user: (conversation: string) => `从以下对话中提取值得长期记住的信息:\n\n${conversation}\n\n如果没有值得记忆的内容,返回空数组 []。`,
|
user: (conversation: string) => `从以下对话中提取值得长期记住的信息:\n\n${conversation}\n\n如果没有值得记忆的内容,返回空数组 []。`,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
suggestions: {
|
||||||
|
system: `你是对话分析助手。根据最近的对话内容,生成 3 个用户可能想继续探讨的问题。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
- 每个问题必须与对话内容直接相关,具体且有针对性
|
||||||
|
- 帮助用户深入理解、实际操作或拓展思路
|
||||||
|
- 每个问题不超过 30 个中文字符
|
||||||
|
- 不要重复对话中已讨论过的内容
|
||||||
|
- 使用与用户相同的语言
|
||||||
|
|
||||||
|
只输出 JSON 数组,包含恰好 3 个字符串。不要输出任何其他内容。
|
||||||
|
示例:["如何在生产环境中部署?", "这个方案的成本如何?", "有没有更简单的替代方案?"]`,
|
||||||
|
user: (context: string) => `以下是对话中最近的消息:\n\n${context}\n\n请生成 3 个后续问题。`,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// === Prompt Cache (SaaS OTA) ===
|
// === Prompt Cache (SaaS OTA) ===
|
||||||
@@ -806,6 +821,7 @@ export const LLM_PROMPTS = {
|
|||||||
get reflection() { return { system: getSystemPrompt('reflection'), user: getUserPromptTemplate('reflection')! }; },
|
get reflection() { return { system: getSystemPrompt('reflection'), user: getUserPromptTemplate('reflection')! }; },
|
||||||
get compaction() { return { system: getSystemPrompt('compaction'), user: getUserPromptTemplate('compaction')! }; },
|
get compaction() { return { system: getSystemPrompt('compaction'), user: getUserPromptTemplate('compaction')! }; },
|
||||||
get extraction() { return { system: getSystemPrompt('extraction'), user: getUserPromptTemplate('extraction')! }; },
|
get extraction() { return { system: getSystemPrompt('extraction'), user: getUserPromptTemplate('extraction')! }; },
|
||||||
|
get suggestions() { return { system: getSystemPrompt('suggestions'), user: getUserPromptTemplate('suggestions')! }; },
|
||||||
};
|
};
|
||||||
|
|
||||||
// === Telemetry Integration ===
|
// === Telemetry Integration ===
|
||||||
@@ -876,3 +892,18 @@ export async function llmExtract(
|
|||||||
trackLLMCall(llm, response);
|
trackLLMCall(llm, response);
|
||||||
return response.content;
|
return response.content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function llmSuggest(
|
||||||
|
conversationContext: string,
|
||||||
|
adapter?: LLMServiceAdapter,
|
||||||
|
): Promise<string> {
|
||||||
|
const llm = adapter || getLLMAdapter();
|
||||||
|
|
||||||
|
const response = await llm.complete([
|
||||||
|
{ role: 'system', content: LLM_PROMPTS.suggestions.system },
|
||||||
|
{ role: 'user', content: typeof LLM_PROMPTS.suggestions.user === 'function' ? LLM_PROMPTS.suggestions.user(conversationContext) : LLM_PROMPTS.suggestions.user },
|
||||||
|
]);
|
||||||
|
|
||||||
|
trackLLMCall(llm, response);
|
||||||
|
return response.content;
|
||||||
|
}
|
||||||
|
|||||||
@@ -573,10 +573,8 @@ async function generateLLMSuggestions(
|
|||||||
let raw: string;
|
let raw: string;
|
||||||
|
|
||||||
if (connectionMode === 'saas') {
|
if (connectionMode === 'saas') {
|
||||||
// SaaS relay: use saasClient directly for reliable auth
|
|
||||||
raw = await llmSuggestViaSaaS(context);
|
raw = await llmSuggestViaSaaS(context);
|
||||||
} else {
|
} else {
|
||||||
// Local kernel: use llm-service adapter (GatewayLLMAdapter → agent_chat)
|
|
||||||
raw = await llmSuggest(context);
|
raw = await llmSuggest(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -596,32 +594,40 @@ async function generateLLMSuggestions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate suggestions via SaaS relay, using saasStore auth directly.
|
* Generate suggestions via SaaS relay using SSE streaming.
|
||||||
|
* Uses the same streaming path as the main chat to avoid relay timeout issues
|
||||||
|
* with non-streaming requests. Collects the full response from SSE deltas,
|
||||||
|
* then parses the suggestion JSON from the accumulated text.
|
||||||
*/
|
*/
|
||||||
async function llmSuggestViaSaaS(context: string): Promise<string> {
|
async function llmSuggestViaSaaS(context: string): Promise<string> {
|
||||||
const { useSaaSStore } = await import('../saasStore');
|
|
||||||
const { saasUrl, authToken } = useSaaSStore.getState();
|
|
||||||
|
|
||||||
if (!saasUrl || !authToken) {
|
|
||||||
throw new Error('SaaS not authenticated');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { saasClient } = await import('../../lib/saas-client');
|
const { saasClient } = await import('../../lib/saas-client');
|
||||||
saasClient.setBaseUrl(saasUrl);
|
const { useConversationStore } = await import('./conversationStore');
|
||||||
saasClient.setToken(authToken);
|
const { useSaaSStore } = await import('../saasStore');
|
||||||
|
|
||||||
|
const currentModel = useConversationStore.getState().currentModel;
|
||||||
|
const availableModels = useSaaSStore.getState().availableModels;
|
||||||
|
const model = currentModel || (availableModels.length > 0 ? availableModels[0]?.id : undefined);
|
||||||
|
if (!model) throw new Error('No model available for suggestions');
|
||||||
|
|
||||||
|
// Delay to avoid concurrent relay requests with memory extraction
|
||||||
|
await new Promise(r => setTimeout(r, 2000));
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 60000);
|
||||||
|
|
||||||
|
try {
|
||||||
const response = await saasClient.chatCompletion(
|
const response = await saasClient.chatCompletion(
|
||||||
{
|
{
|
||||||
model: 'default',
|
model,
|
||||||
messages: [
|
messages: [
|
||||||
{ role: 'system', content: LLM_PROMPTS_SYSTEM },
|
{ role: 'system', content: LLM_PROMPTS_SYSTEM },
|
||||||
{ role: 'user', content: `以下是对话中最近的消息:\n\n${context}\n\n请生成 3 个后续问题。` },
|
{ role: 'user', content: `以下是对话中最近的消息:\n\n${context}\n\n请生成 3 个后续问题。` },
|
||||||
],
|
],
|
||||||
max_tokens: 500,
|
max_tokens: 500,
|
||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
stream: false,
|
stream: true,
|
||||||
},
|
},
|
||||||
AbortSignal.timeout(15000),
|
controller.signal,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -629,8 +635,31 @@ async function llmSuggestViaSaaS(context: string): Promise<string> {
|
|||||||
throw new Error(`SaaS relay error ${response.status}: ${errText.substring(0, 100)}`);
|
throw new Error(`SaaS relay error ${response.status}: ${errText.substring(0, 100)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
// Read full response as text — suggestion responses are small (max 500 tokens),
|
||||||
return data?.choices?.[0]?.message?.content || '';
|
// so streaming is unnecessary. This avoids ReadableStream compatibility issues
|
||||||
|
// in Tauri WebView2 where body.getReader() may not yield SSE chunks correctly.
|
||||||
|
const rawText = await response.text();
|
||||||
|
log.debug('[Suggest] Raw response length:', rawText.length);
|
||||||
|
|
||||||
|
// Parse SSE "data:" lines from accumulated text
|
||||||
|
let accumulated = '';
|
||||||
|
for (const line of rawText.split('\n')) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed.startsWith('data: ')) continue;
|
||||||
|
const payload = trimmed.slice(6).trim();
|
||||||
|
if (payload === '[DONE]') continue;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(payload);
|
||||||
|
const delta = parsed.choices?.[0]?.delta;
|
||||||
|
if (delta?.content) accumulated += delta.content;
|
||||||
|
} catch { /* skip malformed lines */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug('[Suggest] Accumulated length:', accumulated.length);
|
||||||
|
return accumulated;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const LLM_PROMPTS_SYSTEM = `你是对话分析助手。根据最近的对话内容,生成 3 个用户可能想继续探讨的问题。
|
const LLM_PROMPTS_SYSTEM = `你是对话分析助手。根据最近的对话内容,生成 3 个用户可能想继续探讨的问题。
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ interface ChatState {
|
|||||||
totalOutputTokens: number;
|
totalOutputTokens: number;
|
||||||
chatMode: ChatModeType;
|
chatMode: ChatModeType;
|
||||||
suggestions: string[];
|
suggestions: string[];
|
||||||
|
suggestionsLoading: boolean;
|
||||||
|
|
||||||
addMessage: (message: Message) => void;
|
addMessage: (message: Message) => void;
|
||||||
updateMessage: (id: string, updates: Partial<Message>) => void;
|
updateMessage: (id: string, updates: Partial<Message>) => void;
|
||||||
@@ -111,6 +112,7 @@ export const useChatStore = create<ChatState>()(
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
chatMode: 'thinking' as ChatModeType,
|
chatMode: 'thinking' as ChatModeType,
|
||||||
suggestions: [],
|
suggestions: [],
|
||||||
|
suggestionsLoading: false,
|
||||||
totalInputTokens: 0,
|
totalInputTokens: 0,
|
||||||
totalOutputTokens: 0,
|
totalOutputTokens: 0,
|
||||||
|
|
||||||
@@ -367,6 +369,7 @@ const unsubStream = useStreamStore.subscribe((state) => {
|
|||||||
if (chat.isLoading !== state.isLoading) updates.isLoading = state.isLoading;
|
if (chat.isLoading !== state.isLoading) updates.isLoading = state.isLoading;
|
||||||
if (chat.chatMode !== state.chatMode) updates.chatMode = state.chatMode;
|
if (chat.chatMode !== state.chatMode) updates.chatMode = state.chatMode;
|
||||||
if (chat.suggestions !== state.suggestions) updates.suggestions = state.suggestions;
|
if (chat.suggestions !== state.suggestions) updates.suggestions = state.suggestions;
|
||||||
|
if (chat.suggestionsLoading !== state.suggestionsLoading) updates.suggestionsLoading = state.suggestionsLoading;
|
||||||
if (Object.keys(updates).length > 0) {
|
if (Object.keys(updates).length > 0) {
|
||||||
useChatStore.setState(updates);
|
useChatStore.setState(updates);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user