feat(ai-analysis): 在AI助手展示业务分析结果
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { FormEvent, KeyboardEvent, MouseEvent, useMemo, useState } from 'react';
|
import { FormEvent, KeyboardEvent, MouseEvent, useMemo, useState } from 'react';
|
||||||
|
import type { AnalysisResponse } from '@ftb/shared';
|
||||||
import {
|
import {
|
||||||
BotMessageSquare,
|
BotMessageSquare,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
@@ -14,6 +15,8 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} 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 { WENFAN_HELP_ARTICLES, type HelpArticle } from '@/lib/wenfan-help-articles';
|
||||||
import {
|
import {
|
||||||
createWenfanConversationRecord,
|
createWenfanConversationRecord,
|
||||||
@@ -28,6 +31,8 @@ import {
|
|||||||
shouldShowGenericHelpSuggestions,
|
shouldShowGenericHelpSuggestions,
|
||||||
type HelpSearchResult,
|
type HelpSearchResult,
|
||||||
} from '@/lib/wenfan-help-search';
|
} from '@/lib/wenfan-help-search';
|
||||||
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
|
import { useMemberStore } from '@/stores/useMemberStore';
|
||||||
|
|
||||||
type ChatMessage =
|
type ChatMessage =
|
||||||
| {
|
| {
|
||||||
@@ -54,6 +59,18 @@ type ChatMessage =
|
|||||||
type: 'fallback';
|
type: 'fallback';
|
||||||
content: string;
|
content: string;
|
||||||
suggestions: string[];
|
suggestions: string[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
role: 'assistant';
|
||||||
|
type: 'analysis';
|
||||||
|
response: AnalysisResponse;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
role: 'assistant';
|
||||||
|
type: 'analysis_error';
|
||||||
|
content: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const INITIAL_MESSAGES: ChatMessage[] = [
|
const INITIAL_MESSAGES: ChatMessage[] = [
|
||||||
@@ -62,7 +79,7 @@ const INITIAL_MESSAGES: ChatMessage[] = [
|
|||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
type: 'intro',
|
type: 'intro',
|
||||||
content:
|
content:
|
||||||
'第一阶段只从内置帮助中心回答系统怎么用,不调用 AI,也不消耗模型 token。你可以问产品、项目、版本、需求池、开发任务、测试用例、Bug 和日志记录。',
|
'你可以问系统怎么用,也可以问业务数据,例如:哪个部门最忙、哪些版本风险最高、需求完成趋势怎么样。业务分析只读取你已有权限的数据。',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -75,8 +92,14 @@ export default function WenfanXiaobaoPage() {
|
|||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [conversations, setConversations] = useState<WenfanConversation[]>([]);
|
const [conversations, setConversations] = useState<WenfanConversation[]>([]);
|
||||||
const [activeConversationId, setActiveConversationId] = useState<string | null>(null);
|
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 visibleHistory = useMemo(() => conversations.slice(0, 12), [conversations]);
|
||||||
|
const currentPermissions = useMemo(
|
||||||
|
() => roles.find((role) => role.id === user?.roleId)?.permissions ?? [],
|
||||||
|
[roles, user?.roleId],
|
||||||
|
);
|
||||||
const userQuestionCount = useMemo(
|
const userQuestionCount = useMemo(
|
||||||
() => messages.filter((message) => message.role === 'user').length,
|
() => messages.filter((message) => message.role === 'user').length,
|
||||||
[messages],
|
[messages],
|
||||||
@@ -111,20 +134,47 @@ export default function WenfanXiaobaoPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function askQuestion(rawQuestion: string) {
|
async function askQuestion(rawQuestion: string) {
|
||||||
const question = rawQuestion.trim();
|
const question = rawQuestion.trim();
|
||||||
if (!question) return;
|
if (!question) return;
|
||||||
|
|
||||||
const results = searchHelpArticles(question, WENFAN_HELP_ARTICLES);
|
|
||||||
const nextUserQuestionCount = userQuestionCount + 1;
|
|
||||||
const showFallbackSuggestions = shouldShowGenericHelpSuggestions(nextUserQuestionCount);
|
|
||||||
const userMessage: ChatMessage = {
|
const userMessage: ChatMessage = {
|
||||||
id: `user-${Date.now()}`,
|
id: `user-${Date.now()}`,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
content: question,
|
content: question,
|
||||||
};
|
};
|
||||||
|
const optimisticMessages = [...messages, userMessage];
|
||||||
|
|
||||||
|
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 =
|
const assistantMessage: ChatMessage =
|
||||||
results.length > 0
|
results.length > 0
|
||||||
? {
|
? {
|
||||||
@@ -140,23 +190,22 @@ export default function WenfanXiaobaoPage() {
|
|||||||
content: getFallbackHelpMessage(showFallbackSuggestions),
|
content: getFallbackHelpMessage(showFallbackSuggestions),
|
||||||
suggestions: showFallbackSuggestions ? STARTER_QUESTIONS : [],
|
suggestions: showFallbackSuggestions ? STARTER_QUESTIONS : [],
|
||||||
};
|
};
|
||||||
|
const nextMessages = [...optimisticMessages, analysisErrorMessage, assistantMessage];
|
||||||
const nextMessages = [...messages, userMessage, assistantMessage];
|
|
||||||
|
|
||||||
setMessages(nextMessages);
|
setMessages(nextMessages);
|
||||||
setConversations((current) => updateWenfanConversationRecord(current, activeConversationId, nextMessages));
|
setConversations((current) => updateWenfanConversationRecord(current, activeConversationId, nextMessages));
|
||||||
setInput('');
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
askQuestion(input);
|
void askQuestion(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTextareaKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
function handleTextareaKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
||||||
if (event.key === 'Enter' && !event.shiftKey) {
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
event.preventDefault();
|
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>
|
<h1 className="truncate text-[15px] font-semibold text-[#171717]">AI 助手</h1>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</header>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
@@ -247,7 +296,7 @@ export default function WenfanXiaobaoPage() {
|
|||||||
<button
|
<button
|
||||||
key={question}
|
key={question}
|
||||||
type="button"
|
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]"
|
className="rounded-full border border-[#dedede] px-3 py-2 text-[13px] text-[#3f3f46] transition-colors hover:bg-[#f7f7f8]"
|
||||||
>
|
>
|
||||||
{question}
|
{question}
|
||||||
@@ -330,6 +379,12 @@ function MessageRow({
|
|||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1 pt-0.5">
|
<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 === '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 === 'article' && <HelpAnswer result={message.result} onAsk={onAsk} />}
|
||||||
{message.type === 'fallback' && (
|
{message.type === 'fallback' && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
37
apps/web/components/analysis/AnalysisReport.tsx
Normal file
37
apps/web/components/analysis/AnalysisReport.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { AnalysisReport as AnalysisReportData } from '@ftb/shared';
|
||||||
|
import { EvidenceList } from './EvidenceList';
|
||||||
|
|
||||||
|
export function AnalysisReport({ report }: { report: AnalysisReportData }) {
|
||||||
|
return (
|
||||||
|
<section className="rounded-[28px] border border-white/60 bg-white/75 p-5 shadow-[0_18px_60px_rgba(15,23,42,0.08)] backdrop-blur-xl">
|
||||||
|
<h3 className="text-[14px] font-semibold text-[#111827]">分析报告</h3>
|
||||||
|
<p className="mt-3 text-[14px] leading-7 text-[#334155]">{report.summary}</p>
|
||||||
|
<ReportSection title="关键发现" items={report.keyFindings} />
|
||||||
|
<div className="mt-4">
|
||||||
|
<p className="mb-2 text-[12px] font-medium text-[#64748b]">数据依据</p>
|
||||||
|
<EvidenceList items={report.evidence} />
|
||||||
|
</div>
|
||||||
|
<ReportSection title="建议动作" items={report.suggestions} />
|
||||||
|
<div className="mt-4 rounded-2xl bg-[#f8fafc] p-3 text-[12px] leading-6 text-[#64748b]">
|
||||||
|
<p>{report.dataScope.timeDescription}</p>
|
||||||
|
<p>{report.dataScope.permissionDescription}</p>
|
||||||
|
<p>{report.dataScope.metricFormulaDescription}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportSection({ title, items }: { title: string; items: string[] }) {
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-4">
|
||||||
|
<p className="mb-2 text-[12px] font-medium text-[#64748b]">{title}</p>
|
||||||
|
<ul className="space-y-1.5 text-[13px] leading-6 text-[#334155]">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item}>• {item}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
apps/web/components/analysis/AnalysisResultBlock.tsx
Normal file
44
apps/web/components/analysis/AnalysisResultBlock.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import type { AnalysisResponse } from '@ftb/shared';
|
||||||
|
import { AnalysisChart } from './AnalysisChart';
|
||||||
|
import { AnalysisReport } from './AnalysisReport';
|
||||||
|
import { FollowUpActions } from './FollowUpActions';
|
||||||
|
import { InsightCard } from './InsightCard';
|
||||||
|
|
||||||
|
export function AnalysisResultBlock({
|
||||||
|
response,
|
||||||
|
onAsk,
|
||||||
|
}: {
|
||||||
|
response: AnalysisResponse;
|
||||||
|
onAsk: (prompt: string) => void;
|
||||||
|
}) {
|
||||||
|
if (!response.ok) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-[28px] border border-[#e2e8f0] bg-white/80 p-5 text-[14px] leading-7 text-[#334155] shadow-sm backdrop-blur-xl">
|
||||||
|
<p className="font-medium text-[#0f172a]">{response.message}</p>
|
||||||
|
{response.clarificationOptions && (
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
{response.clarificationOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.prompt}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onAsk(option.prompt)}
|
||||||
|
className="rounded-full border border-[#dbe3ef] bg-white/70 px-3 py-2 text-[13px] text-[#334155] hover:bg-white"
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<InsightCard insight={response.insight} />
|
||||||
|
<AnalysisChart spec={response.chart} />
|
||||||
|
<AnalysisReport report={response.report} />
|
||||||
|
<FollowUpActions followUps={response.followUps} onAsk={onAsk} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
apps/web/components/analysis/EvidenceList.tsx
Normal file
25
apps/web/components/analysis/EvidenceList.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { EvidenceItem } from '@ftb/shared';
|
||||||
|
|
||||||
|
export function EvidenceList({ items }: { items: EvidenceItem[] }) {
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<button
|
||||||
|
key={`${item.label}-${index}`}
|
||||||
|
type="button"
|
||||||
|
className="rounded-full border border-[#e2e8f0] bg-white/70 px-3 py-1.5 text-[12px] text-[#334155] shadow-sm backdrop-blur disabled:cursor-default"
|
||||||
|
disabled={!item.drilldown}
|
||||||
|
title={item.sourceLabel ?? item.sourceDomain}
|
||||||
|
>
|
||||||
|
<span className="text-[#64748b]">{item.label}</span>
|
||||||
|
<span className="ml-1 font-semibold text-[#0f172a]">
|
||||||
|
{item.value}
|
||||||
|
{item.unit ?? ''}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
apps/web/components/analysis/FollowUpActions.tsx
Normal file
23
apps/web/components/analysis/FollowUpActions.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { FollowUp } from '@ftb/shared';
|
||||||
|
|
||||||
|
export function FollowUpActions({ followUps, onAsk }: { followUps: FollowUp[]; onAsk: (prompt: string) => void }) {
|
||||||
|
if (followUps.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{followUps.map((item) => (
|
||||||
|
<button
|
||||||
|
key={`${item.type}-${item.label}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (item.type === 'question') onAsk(item.prompt);
|
||||||
|
}}
|
||||||
|
className="rounded-full border border-[#dbe3ef] bg-white/70 px-3 py-2 text-[13px] text-[#334155] shadow-sm transition-colors hover:bg-white disabled:cursor-default disabled:opacity-60"
|
||||||
|
disabled={item.type !== 'question'}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
apps/web/components/analysis/InsightCard.tsx
Normal file
31
apps/web/components/analysis/InsightCard.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type { InsightCard as InsightCardData } from '@ftb/shared';
|
||||||
|
|
||||||
|
export function InsightCard({ insight }: { insight: InsightCardData }) {
|
||||||
|
return (
|
||||||
|
<section className="rounded-[28px] border border-white/60 bg-white/80 p-5 shadow-[0_18px_60px_rgba(15,23,42,0.08)] backdrop-blur-xl">
|
||||||
|
{insight.primaryValue && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="text-[44px] font-semibold leading-none tracking-normal text-[#0f172a]">
|
||||||
|
{insight.primaryValue.value}
|
||||||
|
{insight.primaryValue.unit ?? ''}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-[13px] text-[#64748b]">{insight.primaryValue.label}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-[15px] leading-7 text-[#111827]">{insight.summary}</p>
|
||||||
|
{(insight.semanticConfidence !== 'high' || insight.dataConfidence !== 'sufficient') && (
|
||||||
|
<p className="mt-3 text-[12px] text-[#64748b]">
|
||||||
|
语义置信:{confidenceLabel(insight.semanticConfidence)} · 数据充分性:{dataLabel(insight.dataConfidence)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function confidenceLabel(value: InsightCardData['semanticConfidence']) {
|
||||||
|
return value === 'high' ? '高' : value === 'medium' ? '中' : '低';
|
||||||
|
}
|
||||||
|
|
||||||
|
function dataLabel(value: InsightCardData['dataConfidence']) {
|
||||||
|
return value === 'sufficient' ? '数据充分' : value === 'partial' ? '部分数据' : '数据不足';
|
||||||
|
}
|
||||||
@@ -54,6 +54,23 @@ test('wenfan xiaobao page provides records, chat, and voice input surfaces', ()
|
|||||||
assert.match(page, /: 'bg-white hover:bg-\[var\(--bg-subtle\)\]'/);
|
assert.match(page, /: 'bg-white hover:bg-\[var\(--bg-subtle\)\]'/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('wenfan xiaobao page requests business analysis before preserving help fallback', () => {
|
||||||
|
const page = readFileSync(join(process.cwd(), 'app/wenfan-xiaobao/page.tsx'), 'utf8');
|
||||||
|
|
||||||
|
assert.match(page, /requestAnalysis/);
|
||||||
|
assert.match(page, /AnalysisResultBlock/);
|
||||||
|
assert.match(page, /type: 'analysis'/);
|
||||||
|
assert.match(page, /type: 'analysis_error'/);
|
||||||
|
assert.match(page, /currentPermissions/);
|
||||||
|
assert.match(page, /surface: 'ai_assistant'/);
|
||||||
|
assert.match(page, /业务分析只读取你已有权限的数据/);
|
||||||
|
assert.match(page, /业务分析暂不可用/);
|
||||||
|
assert.match(page, /searchHelpArticles\(question, WENFAN_HELP_ARTICLES\)/);
|
||||||
|
assert.match(page, /getFallbackHelpMessage\(showFallbackSuggestions\)/);
|
||||||
|
assert.match(page, /void askQuestion\(input\)/);
|
||||||
|
assert.match(page, /<AnalysisResultBlock response=\{message\.response\} onAsk=\{onAsk\} \/>/);
|
||||||
|
});
|
||||||
|
|
||||||
test('wenfan xiaobao page does not ship seeded conversation history', () => {
|
test('wenfan xiaobao page does not ship seeded conversation history', () => {
|
||||||
const page = readFileSync(join(process.cwd(), 'app/wenfan-xiaobao/page.tsx'), 'utf8');
|
const page = readFileSync(join(process.cwd(), 'app/wenfan-xiaobao/page.tsx'), 'utf8');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user