diff --git a/apps/web/app/wenfan-xiaobao/page.tsx b/apps/web/app/wenfan-xiaobao/page.tsx index c6decbc..0020618 100644 --- a/apps/web/app/wenfan-xiaobao/page.tsx +++ b/apps/web/app/wenfan-xiaobao/page.tsx @@ -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([]); const [activeConversationId, setActiveConversationId] = useState(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) { event.preventDefault(); - askQuestion(input); + void askQuestion(input); } function handleTextareaKeyDown(event: KeyboardEvent) { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); - askQuestion(input); + void askQuestion(input); } } @@ -227,7 +276,7 @@ export default function WenfanXiaobaoPage() {

AI 助手

- 内置帮助 + 只读分析 · 内置帮助
@@ -247,7 +296,7 @@ export default function WenfanXiaobaoPage() {
{message.type === 'intro' &&

{message.content}

} + {message.type === 'analysis' && } + {message.type === 'analysis_error' && ( +

+ {message.content} +

+ )} {message.type === 'article' && } {message.type === 'fallback' && (
diff --git a/apps/web/components/analysis/AnalysisReport.tsx b/apps/web/components/analysis/AnalysisReport.tsx new file mode 100644 index 0000000..c7e6c08 --- /dev/null +++ b/apps/web/components/analysis/AnalysisReport.tsx @@ -0,0 +1,37 @@ +import type { AnalysisReport as AnalysisReportData } from '@ftb/shared'; +import { EvidenceList } from './EvidenceList'; + +export function AnalysisReport({ report }: { report: AnalysisReportData }) { + return ( +
+

分析报告

+

{report.summary}

+ +
+

数据依据

+ +
+ +
+

{report.dataScope.timeDescription}

+

{report.dataScope.permissionDescription}

+

{report.dataScope.metricFormulaDescription}

+
+
+ ); +} + +function ReportSection({ title, items }: { title: string; items: string[] }) { + if (items.length === 0) return null; + + return ( +
+

{title}

+
    + {items.map((item) => ( +
  • • {item}
  • + ))} +
+
+ ); +} diff --git a/apps/web/components/analysis/AnalysisResultBlock.tsx b/apps/web/components/analysis/AnalysisResultBlock.tsx new file mode 100644 index 0000000..477a758 --- /dev/null +++ b/apps/web/components/analysis/AnalysisResultBlock.tsx @@ -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 ( +
+

{response.message}

+ {response.clarificationOptions && ( +
+ {response.clarificationOptions.map((option) => ( + + ))} +
+ )} +
+ ); + } + + return ( +
+ + + + +
+ ); +} diff --git a/apps/web/components/analysis/EvidenceList.tsx b/apps/web/components/analysis/EvidenceList.tsx new file mode 100644 index 0000000..ee90411 --- /dev/null +++ b/apps/web/components/analysis/EvidenceList.tsx @@ -0,0 +1,25 @@ +import type { EvidenceItem } from '@ftb/shared'; + +export function EvidenceList({ items }: { items: EvidenceItem[] }) { + if (items.length === 0) return null; + + return ( +
+ {items.map((item, index) => ( + + ))} +
+ ); +} diff --git a/apps/web/components/analysis/FollowUpActions.tsx b/apps/web/components/analysis/FollowUpActions.tsx new file mode 100644 index 0000000..dc7a52e --- /dev/null +++ b/apps/web/components/analysis/FollowUpActions.tsx @@ -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 ( +
+ {followUps.map((item) => ( + + ))} +
+ ); +} diff --git a/apps/web/components/analysis/InsightCard.tsx b/apps/web/components/analysis/InsightCard.tsx new file mode 100644 index 0000000..225a881 --- /dev/null +++ b/apps/web/components/analysis/InsightCard.tsx @@ -0,0 +1,31 @@ +import type { InsightCard as InsightCardData } from '@ftb/shared'; + +export function InsightCard({ insight }: { insight: InsightCardData }) { + return ( +
+ {insight.primaryValue && ( +
+
+ {insight.primaryValue.value} + {insight.primaryValue.unit ?? ''} +
+
{insight.primaryValue.label}
+
+ )} +

{insight.summary}

+ {(insight.semanticConfidence !== 'high' || insight.dataConfidence !== 'sufficient') && ( +

+ 语义置信:{confidenceLabel(insight.semanticConfidence)} · 数据充分性:{dataLabel(insight.dataConfidence)} +

+ )} +
+ ); +} + +function confidenceLabel(value: InsightCardData['semanticConfidence']) { + return value === 'high' ? '高' : value === 'medium' ? '中' : '低'; +} + +function dataLabel(value: InsightCardData['dataConfidence']) { + return value === 'sufficient' ? '数据充分' : value === 'partial' ? '部分数据' : '数据不足'; +} diff --git a/apps/web/lib/wenfan-xiaobao-ui.test.ts b/apps/web/lib/wenfan-xiaobao-ui.test.ts index d3eaab1..d2826fd 100644 --- a/apps/web/lib/wenfan-xiaobao-ui.test.ts +++ b/apps/web/lib/wenfan-xiaobao-ui.test.ts @@ -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\)\]'/); }); +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, //); +}); + test('wenfan xiaobao page does not ship seeded conversation history', () => { const page = readFileSync(join(process.cwd(), 'app/wenfan-xiaobao/page.tsx'), 'utf8');