feat(ai-analysis): 在AI助手展示业务分析结果
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { FormEvent, KeyboardEvent, MouseEvent, useMemo, useState } from 'react';
|
||||
import type { AnalysisResponse } from '@ftb/shared';
|
||||
import {
|
||||
BotMessageSquare,
|
||||
Image as ImageIcon,
|
||||
@@ -14,6 +15,8 @@ import {
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { AnalysisResultBlock } from '@/components/analysis/AnalysisResultBlock';
|
||||
import { requestAnalysis } from '@/lib/analysis-api';
|
||||
import { WENFAN_HELP_ARTICLES, type HelpArticle } from '@/lib/wenfan-help-articles';
|
||||
import {
|
||||
createWenfanConversationRecord,
|
||||
@@ -28,6 +31,8 @@ import {
|
||||
shouldShowGenericHelpSuggestions,
|
||||
type HelpSearchResult,
|
||||
} from '@/lib/wenfan-help-search';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { useMemberStore } from '@/stores/useMemberStore';
|
||||
|
||||
type ChatMessage =
|
||||
| {
|
||||
@@ -54,6 +59,18 @@ type ChatMessage =
|
||||
type: 'fallback';
|
||||
content: string;
|
||||
suggestions: string[];
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
role: 'assistant';
|
||||
type: 'analysis';
|
||||
response: AnalysisResponse;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
role: 'assistant';
|
||||
type: 'analysis_error';
|
||||
content: string;
|
||||
};
|
||||
|
||||
const INITIAL_MESSAGES: ChatMessage[] = [
|
||||
@@ -62,7 +79,7 @@ const INITIAL_MESSAGES: ChatMessage[] = [
|
||||
role: 'assistant',
|
||||
type: 'intro',
|
||||
content:
|
||||
'第一阶段只从内置帮助中心回答系统怎么用,不调用 AI,也不消耗模型 token。你可以问产品、项目、版本、需求池、开发任务、测试用例、Bug 和日志记录。',
|
||||
'你可以问系统怎么用,也可以问业务数据,例如:哪个部门最忙、哪些版本风险最高、需求完成趋势怎么样。业务分析只读取你已有权限的数据。',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -75,8 +92,14 @@ export default function WenfanXiaobaoPage() {
|
||||
const [input, setInput] = useState('');
|
||||
const [conversations, setConversations] = useState<WenfanConversation[]>([]);
|
||||
const [activeConversationId, setActiveConversationId] = useState<string | null>(null);
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const roles = useMemberStore((state) => state.roles);
|
||||
|
||||
const visibleHistory = useMemo(() => conversations.slice(0, 12), [conversations]);
|
||||
const currentPermissions = useMemo(
|
||||
() => roles.find((role) => role.id === user?.roleId)?.permissions ?? [],
|
||||
[roles, user?.roleId],
|
||||
);
|
||||
const userQuestionCount = useMemo(
|
||||
() => messages.filter((message) => message.role === 'user').length,
|
||||
[messages],
|
||||
@@ -111,52 +134,78 @@ export default function WenfanXiaobaoPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function askQuestion(rawQuestion: string) {
|
||||
async function askQuestion(rawQuestion: string) {
|
||||
const question = rawQuestion.trim();
|
||||
if (!question) return;
|
||||
|
||||
const results = searchHelpArticles(question, WENFAN_HELP_ARTICLES);
|
||||
const nextUserQuestionCount = userQuestionCount + 1;
|
||||
const showFallbackSuggestions = shouldShowGenericHelpSuggestions(nextUserQuestionCount);
|
||||
const userMessage: ChatMessage = {
|
||||
id: `user-${Date.now()}`,
|
||||
role: 'user',
|
||||
type: 'text',
|
||||
content: question,
|
||||
};
|
||||
const optimisticMessages = [...messages, userMessage];
|
||||
|
||||
const assistantMessage: ChatMessage =
|
||||
results.length > 0
|
||||
? {
|
||||
id: `article-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
type: 'article',
|
||||
result: results[0],
|
||||
}
|
||||
: {
|
||||
id: `fallback-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
type: 'fallback',
|
||||
content: getFallbackHelpMessage(showFallbackSuggestions),
|
||||
suggestions: showFallbackSuggestions ? STARTER_QUESTIONS : [],
|
||||
};
|
||||
|
||||
const nextMessages = [...messages, userMessage, assistantMessage];
|
||||
|
||||
setMessages(nextMessages);
|
||||
setConversations((current) => updateWenfanConversationRecord(current, activeConversationId, nextMessages));
|
||||
setMessages(optimisticMessages);
|
||||
setInput('');
|
||||
|
||||
try {
|
||||
const analysis = await requestAnalysis(
|
||||
{ question, context: { surface: 'ai_assistant' } },
|
||||
currentPermissions,
|
||||
);
|
||||
const assistantMessage: ChatMessage = {
|
||||
id: `analysis-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
type: 'analysis',
|
||||
response: analysis,
|
||||
};
|
||||
const nextMessages = [...optimisticMessages, assistantMessage];
|
||||
|
||||
setMessages(nextMessages);
|
||||
setConversations((current) => updateWenfanConversationRecord(current, activeConversationId, nextMessages));
|
||||
return;
|
||||
} catch {
|
||||
const results = searchHelpArticles(question, WENFAN_HELP_ARTICLES);
|
||||
const nextUserQuestionCount = userQuestionCount + 1;
|
||||
const showFallbackSuggestions = shouldShowGenericHelpSuggestions(nextUserQuestionCount);
|
||||
const analysisErrorMessage: ChatMessage = {
|
||||
id: `analysis-error-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
type: 'analysis_error',
|
||||
content: '业务分析暂不可用,我先用内置帮助继续回答。',
|
||||
};
|
||||
const assistantMessage: ChatMessage =
|
||||
results.length > 0
|
||||
? {
|
||||
id: `article-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
type: 'article',
|
||||
result: results[0],
|
||||
}
|
||||
: {
|
||||
id: `fallback-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
type: 'fallback',
|
||||
content: getFallbackHelpMessage(showFallbackSuggestions),
|
||||
suggestions: showFallbackSuggestions ? STARTER_QUESTIONS : [],
|
||||
};
|
||||
const nextMessages = [...optimisticMessages, analysisErrorMessage, assistantMessage];
|
||||
|
||||
setMessages(nextMessages);
|
||||
setConversations((current) => updateWenfanConversationRecord(current, activeConversationId, nextMessages));
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
askQuestion(input);
|
||||
void askQuestion(input);
|
||||
}
|
||||
|
||||
function handleTextareaKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
askQuestion(input);
|
||||
void askQuestion(input);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,7 +276,7 @@ export default function WenfanXiaobaoPage() {
|
||||
<h1 className="truncate text-[15px] font-semibold text-[#171717]">AI 助手</h1>
|
||||
</div>
|
||||
</div>
|
||||
<span className="rounded-full bg-[#f4f4f4] px-3 py-1 text-[12px] text-[#6b6b6b]">内置帮助</span>
|
||||
<span className="rounded-full bg-[#f4f4f4] px-3 py-1 text-[12px] text-[#6b6b6b]">只读分析 · 内置帮助</span>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
@@ -247,7 +296,7 @@ export default function WenfanXiaobaoPage() {
|
||||
<button
|
||||
key={question}
|
||||
type="button"
|
||||
onClick={() => askQuestion(question)}
|
||||
onClick={() => void askQuestion(question)}
|
||||
className="rounded-full border border-[#dedede] px-3 py-2 text-[13px] text-[#3f3f46] transition-colors hover:bg-[#f7f7f8]"
|
||||
>
|
||||
{question}
|
||||
@@ -330,6 +379,12 @@ function MessageRow({
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
{message.type === 'intro' && <p className="text-[14px] leading-7 text-[#202123]">{message.content}</p>}
|
||||
{message.type === 'analysis' && <AnalysisResultBlock response={message.response} onAsk={onAsk} />}
|
||||
{message.type === 'analysis_error' && (
|
||||
<p className="rounded-[22px] border border-[#e2e8f0] bg-white/75 px-4 py-3 text-[13px] leading-6 text-[#64748b] shadow-sm backdrop-blur">
|
||||
{message.content}
|
||||
</p>
|
||||
)}
|
||||
{message.type === 'article' && <HelpAnswer result={message.result} onAsk={onAsk} />}
|
||||
{message.type === 'fallback' && (
|
||||
<div className="space-y-3">
|
||||
|
||||
Reference in New Issue
Block a user