初始化
This commit is contained in:
1 parent
34474bd5fc
commit
b638b541d0
3008 files changed
+837699
No files matched your search
@@ -0,0 +1,474 @@
|
||||
import type { QASingleRagTrace, QAThinkingTrace } from './QACommon';
|
||||
import type { ComparePane, KnowledgeBaseChunk, Message, QAMode, SourceItem } from './GeneralQA.types';
|
||||
|
||||
/**
|
||||
* 解析单轮 RAG 接口的 `<think>...</think>` 流式文本。
|
||||
*
|
||||
* 后端会把“思考过程”和“最终回答”混在同一个 answer 字段里返回,
|
||||
* UI 需要分别展示在 `SingleRagAnswer` 的思考区和答案区。这个函数只做纯文本拆分,
|
||||
* 不依赖 React 状态,因此既可用于模拟数据,也可作为对比模式里的兜底空 trace。
|
||||
*/
|
||||
export const parseSingleRagStream = (chunks: { answer: string }[]): QASingleRagTrace => {
|
||||
let thinkingMarkdown = '';
|
||||
let answerMarkdown = '';
|
||||
let inThink = false;
|
||||
let thinkingComplete = false;
|
||||
|
||||
const appendToken = (token: string) => {
|
||||
let remaining = token;
|
||||
while (remaining) {
|
||||
if (inThink) {
|
||||
const endIdx = remaining.indexOf('</think>');
|
||||
if (endIdx === -1) {
|
||||
thinkingMarkdown += remaining;
|
||||
break;
|
||||
}
|
||||
thinkingMarkdown += remaining.slice(0, endIdx);
|
||||
inThink = false;
|
||||
thinkingComplete = true;
|
||||
remaining = remaining.slice(endIdx + '</think>'.length);
|
||||
} else {
|
||||
const startIdx = remaining.indexOf('<think>');
|
||||
if (startIdx === -1) {
|
||||
answerMarkdown += remaining;
|
||||
break;
|
||||
}
|
||||
answerMarkdown += remaining.slice(0, startIdx);
|
||||
inThink = true;
|
||||
remaining = remaining.slice(startIdx + '<think>'.length);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
chunks.forEach((chunk) => appendToken(chunk.answer));
|
||||
|
||||
return {
|
||||
title: '单轮 RAG 思考过程',
|
||||
thinkingMarkdown,
|
||||
answerMarkdown,
|
||||
thinkingComplete,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 构造单轮 RAG 的演示 trace。
|
||||
*
|
||||
* 首屏默认需要一条完整的示例问答,帮助用户理解“思考过程 + 答案 + 引用”如何展示。
|
||||
* 这里保留原组件中的演示内容,避免拆分后默认展示行为发生变化。
|
||||
*/
|
||||
export const createSingleRagTrace = (): QASingleRagTrace => parseSingleRagStream([
|
||||
{ answer: '<think>' },
|
||||
{ answer: '需要回答用户关于 **RAG 工作原理** 的问题。\n\n' },
|
||||
{ answer: '先根据知识库召回内容组织核心流程:\n\n' },
|
||||
{ answer: '- 检索阶段:查找相关知识片段\n' },
|
||||
{ answer: '- 增强阶段:将证据注入提示词\n' },
|
||||
{ answer: '- 生成阶段:模型基于上下文生成答案\n\n' },
|
||||
{ answer: '回答需要保持结构清晰,并说明它为什么能降低幻觉。' },
|
||||
{ answer: '</think>' },
|
||||
{ answer: 'RAG(Retrieval-Augmented Generation,检索增强生成)是一种把 **知识库检索** 和 **大模型生成** 结合起来的问答方式。\n\n' },
|
||||
{ answer: '### 工作流程\n\n' },
|
||||
{ answer: '1. **问题理解**:系统接收用户问题并生成检索查询。\n' },
|
||||
{ answer: '2. **知识检索**:从知识库中召回相关文档片段。\n' },
|
||||
{ answer: '3. **上下文增强**:把检索到的内容和用户问题一起放入 Prompt。\n' },
|
||||
{ answer: '4. **模型生成**:大模型基于增强后的上下文生成回答。\n\n' },
|
||||
{ answer: '这种方式可以让回答更贴近已有资料,同时提升可追溯性。' },
|
||||
]);
|
||||
|
||||
/**
|
||||
* 构造多轮/深度检索的演示思考链。
|
||||
*
|
||||
* 真实多轮请求会通过 SSE 持续补齐 rounds;深度检索当前仍走本地模拟,
|
||||
* 因此这个函数保留不同模式下的轮次差异,用于未接入真实接口时的展示。
|
||||
*/
|
||||
export const createThinkingTrace = (mode: QAMode): QAThinkingTrace => {
|
||||
// 初始化轮次数组,第一轮总是基础检索
|
||||
const rounds = [
|
||||
{
|
||||
id: 'round-1', // 第一轮唯一标识
|
||||
title: '理解问题并拆解检索方向', // 本轮主题:理解问题并确定检索方向
|
||||
thoughtMarkdown: '用户询问 **RAG 技术工作原理**。需要先明确核心链路:\n\n- 问题理解与向量化\n- 知识库召回\n- 上下文增强\n- 大模型生成\n\n第一轮优先检索基础定义和主流程。', // 模型思考过程,展示分析步骤
|
||||
query: 'RAG 检索增强生成 工作原理 基础流程', // 发送给检索系统的查询词
|
||||
queryShow: true,
|
||||
retrievals: [ // 第一轮检索返回的资料列表
|
||||
{
|
||||
id: 'r1-1',
|
||||
title: 'RAG技术白皮书',
|
||||
content: 'RAG 将外部知识检索结果作为上下文注入生成模型,能够降低幻觉并提升答案的时效性和可追溯性。',
|
||||
source: '知识库 / RAG技术白皮书.pdf',
|
||||
},
|
||||
{
|
||||
id: 'r1-2',
|
||||
title: 'LLM应用实践:检索增强生成',
|
||||
content: '典型 RAG 流程包括查询改写、向量检索、片段重排、Prompt 组装和答案生成。',
|
||||
source: '知识库 / LLM应用实践.docx',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// 多轮模式和深度检索模式需要第二轮检索,补充更多细节
|
||||
if (mode !== 'single-round') {
|
||||
// 添加第二轮检索:补充多轮检索与重排机制的差异
|
||||
rounds.push({
|
||||
id: 'round-2', // 第二轮的唯一标识
|
||||
title: '补充多轮检索与重排机制', // 本轮检索的主题
|
||||
thoughtMarkdown: '基础链路已经明确,继续检索 **多轮 RAG** 和 **深度检索** 的差异:\n\n1. 是否需要 Query 改写\n2. 是否需要对子问题分别召回\n3. 是否需要结果重排与证据融合',
|
||||
query: '多轮 RAG Query 改写 子问题检索 结果重排', // 本轮发送给检索系统的查询词
|
||||
// 本轮检索返回的资料列表
|
||||
queryShow: true,
|
||||
retrievals: [
|
||||
{
|
||||
id: 'r2-1',
|
||||
title: '高级RAG技术指南',
|
||||
content: '复杂问题通常需要分解为多个子问题,并对每个子问题分别检索,再通过重排模型筛选高相关证据。',
|
||||
source: '资料库 / 高级RAG技术指南.md',
|
||||
},
|
||||
{
|
||||
id: 'r2-2',
|
||||
title: 'RAG召回与重排最佳实践',
|
||||
content: '重排阶段可使用 Cross Encoder 或 LLM 评分,按相关性、覆盖度和可信度筛选最终上下文。',
|
||||
source: '行业资料 / RAG最佳实践.html',
|
||||
},
|
||||
{
|
||||
id: 'r2-3',
|
||||
title: '检索增强系统评测报告',
|
||||
content: '多阶段检索可提升复杂问题的召回率,但会增加延迟和计算成本,需要在效果与响应速度之间平衡。',
|
||||
source: '评测库 / 检索增强系统评测报告.xlsx',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// 深度检索模式需要第三轮,综合所有证据并形成最终回答结构
|
||||
if (mode === 'deep-search') {
|
||||
rounds.push({
|
||||
id: 'round-3', // 第三轮的唯一标识
|
||||
title: '综合证据并形成回答结构', // 本轮的主题:整合前面检索到的所有证据,组织成完整的回答
|
||||
thoughtMarkdown: '当前证据已覆盖定义、流程和优化机制。最后需要组织成清晰结构:\n\n- 一句话定义\n- 分阶段流程\n- 技术价值\n- 适用场景与注意事项',
|
||||
query: 'RAG 技术价值 幻觉 时效性 可追溯性 应用场景', // 本轮查询:关于 RAG 价值和应用场景的信息
|
||||
queryShow: true,
|
||||
retrievals: [
|
||||
{
|
||||
id: 'r3-1',
|
||||
title: '企业知识问答系统建设方案',
|
||||
content: 'RAG 在企业知识问答中常用于连接内部文档、制度库和业务数据库,使回答具备来源依据。',
|
||||
source: '方案库 / 企业知识问答系统建设方案.pptx',
|
||||
},
|
||||
{
|
||||
id: 'r3-2',
|
||||
title: '生成式AI落地风险控制',
|
||||
content: 'RAG 不能完全消除幻觉,仍需要上下文质量控制、引用展示、权限过滤和答案审核机制。',
|
||||
source: '风控库 / 生成式AI落地风险控制.pdf',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
title: mode === 'single-round' ? '单轮检索思考过程' : mode === 'multi-round' ? '多轮检索思考过程' : '深度检索思考过程',
|
||||
rounds,
|
||||
finalAnswerMarkdown: 'RAG(Retrieval-Augmented Generation,检索增强生成)是一种把 **外部知识检索** 和 **大模型生成** 结合起来的技术架构。\n\n### 核心流程\n\n1. **理解问题**:系统解析用户问题,并可能进行 Query 改写或问题拆解。\n2. **检索证据**:从知识库、文档库或业务数据源中召回相关片段。\n3. **筛选重排**:对召回片段按相关性和可信度排序,保留高质量上下文。\n4. **增强生成**:将问题和检索证据一起发送给大模型,生成回答。\n5. **引用溯源**:展示答案依据,便于用户核验。\n\n### 主要价值\n\n- 降低模型幻觉\n- 引入最新或私有知识\n- 提升回答可解释性和可追溯性\n- 适合企业知识问答、政策解读、资料分析等场景',
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 构造流式多轮检索的初始空 trace。
|
||||
*
|
||||
* 请求刚建立时还没有真正的工具调用结果,先渲染一个“正在准备检索”的轮次,
|
||||
* 可以让用户立即看到助手进入工作状态,后续 SSE 事件会继续更新这个 trace。
|
||||
*/
|
||||
export const createMultiRoundStreamTrace = (): QAThinkingTrace => ({
|
||||
title: '多轮检索思考过程',
|
||||
rounds: [{
|
||||
id: `round-${Date.now()}`,
|
||||
title: '正在准备检索',
|
||||
thoughtMarkdown: '',
|
||||
query: '',
|
||||
queryShow: true,
|
||||
queryLoading: true,
|
||||
retrievals: [],
|
||||
}],
|
||||
thinkingComplete: false,
|
||||
finalAnswerMarkdown: '',
|
||||
});
|
||||
|
||||
/**
|
||||
* 为对比模式创建左右面板的初始状态。
|
||||
*
|
||||
* 对比模式会并发运行两个不同的问答模式;每个面板需要独立维护 loading、trace 和 sources。
|
||||
* 该工厂函数把不同模式的初始 trace 统一封装,避免页面组件重复判断。
|
||||
*/
|
||||
export const createComparePane = (mode: QAMode): ComparePane => ({
|
||||
mode,
|
||||
loading: true,
|
||||
singleRagTrace: (mode === 'single-round' || mode === 'single-chat')
|
||||
? {
|
||||
title: '单轮 RAG 思考过程',
|
||||
thinkingMarkdown: '',
|
||||
answerMarkdown: '',
|
||||
thinkingComplete: false,
|
||||
}
|
||||
: undefined,
|
||||
thinkingTrace: (mode === 'single-round' || mode === 'single-chat') ? undefined : createMultiRoundStreamTrace(),
|
||||
sources: [],
|
||||
});
|
||||
|
||||
/**
|
||||
* 首屏演示消息。
|
||||
*
|
||||
* 页面首次进入时,如果没有用户消息,会展示两轮预置问答,说明引用、思考过程和追问的展示形态。
|
||||
* 拆分时完整保留原始 mock 数据,确保首屏体验不变。
|
||||
*/
|
||||
export const createMockSingleRagMessages = (): Message[] => ([
|
||||
{
|
||||
id: 'mock-user-1',
|
||||
role: 'user',
|
||||
content: 'RAG 在企业知识问答里具体怎么落地?',
|
||||
timestamp: new Date(),
|
||||
},
|
||||
{
|
||||
id: 'mock-assistant-1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: new Date(),
|
||||
singleRagTrace: {
|
||||
title: '单轮 RAG 思考过程',
|
||||
thinkingMarkdown: '先明确用户要的是“可落地方案”,所以需要回答:\n\n1. 数据准备与切片策略\n2. 检索与重排链路\n3. 答案引用与可追溯性\n\n优先给出一套可直接上线的最小闭环。',
|
||||
answerMarkdown: '可以按“**数据层 → 检索层 → 生成层 → 评估层**”四步落地:\n\n1. **数据层**:把制度、FAQ、SOP 文档切片并向量化,按业务域建索引。[[1]]\n2. **检索层**:先向量召回,再用重排模型做精排,提升命中质量。[[2]]\n3. **生成层**:将 TopK 证据注入 Prompt,要求回答必须带引用编号。[[3]]\n4. **评估层**:持续监控命中率、引用准确率和用户满意度。\n\n如果你要,我可以下一步给你一份“可直接给研发执行”的字段级配置清单。',
|
||||
thinkingComplete: true,
|
||||
},
|
||||
sources: [
|
||||
{
|
||||
title: '企业知识问答平台建设指南',
|
||||
type: '知识库',
|
||||
snippet: '建议按业务域拆分索引,并保持文档版本可追溯。',
|
||||
source: '内部架构组',
|
||||
time: '2026-03-18',
|
||||
url: 'https://example.com/rag-enterprise-guide',
|
||||
content: '企业落地 RAG 时,建议先从高频问答域切入,采用“文档切片 + 向量索引 + 重排”三层方案,确保上线初期可控。',
|
||||
},
|
||||
{
|
||||
title: 'RAG 重排实践报告',
|
||||
type: '技术报告',
|
||||
snippet: '仅向量召回会引入噪声,重排可显著提升答案相关性。',
|
||||
source: 'NLP 团队',
|
||||
time: '2026-01-09',
|
||||
url: 'https://example.com/rag-rerank-report',
|
||||
content: '在 20 万条文档规模下,向量 Top50 + CrossEncoder 重排到 Top6,可使人工评测正确率提升约 12%。',
|
||||
},
|
||||
{
|
||||
title: '可追溯生成规范',
|
||||
type: '规范',
|
||||
snippet: '回答输出应绑定证据编号,便于用户核验。',
|
||||
source: 'AI 治理委员会',
|
||||
time: '2025-12-22',
|
||||
url: 'https://example.com/traceable-generation-spec',
|
||||
content: '推荐在答案中输出 [[n]] 形式的引用标识,并支持点击查看证据明细、来源与时间信息。',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mock-user-2',
|
||||
role: 'user',
|
||||
content: '那单轮和多轮检索的差异是什么?',
|
||||
timestamp: new Date(),
|
||||
},
|
||||
{
|
||||
id: 'mock-assistant-2',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: new Date(),
|
||||
singleRagTrace: {
|
||||
title: '单轮 RAG 思考过程',
|
||||
thinkingMarkdown: '用户在做方案选型,回答要突出“复杂问题覆盖率”和“时延成本”两个维度。',
|
||||
answerMarkdown: '**单轮检索**更快,适合事实性问题;**多轮检索**覆盖更全,适合复杂推理问题。通常实践里会先单轮,召回不足再升级多轮。[[1]][[2]]',
|
||||
thinkingComplete: true,
|
||||
},
|
||||
sources: [
|
||||
{
|
||||
title: 'RAG 模式选型对比',
|
||||
type: '白皮书',
|
||||
snippet: '单轮偏性能,多轮偏完整性。',
|
||||
source: '智能搜索实验室',
|
||||
time: '2026-02-11',
|
||||
url: 'https://example.com/rag-mode-comparison',
|
||||
content: '复杂查询在多轮检索中召回覆盖更高,但会增加 20%-60% 延迟,需按业务 SLA 选择。',
|
||||
},
|
||||
{
|
||||
title: '检索系统时延评测',
|
||||
type: '评测',
|
||||
snippet: '多轮策略会增加查询与重排调用次数。',
|
||||
source: '平台性能组',
|
||||
time: '2026-02-27',
|
||||
url: 'https://example.com/rag-latency-benchmark',
|
||||
content: '在同等资源下,多轮检索平均时延高于单轮检索,但对于复杂问题的回答完整度更高。',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
/**
|
||||
* 将多轮检索接口返回的 references 项映射为通用引用来源。
|
||||
*/
|
||||
export const mapReferenceToSource = (item: {
|
||||
title?: string;
|
||||
content?: string;
|
||||
snippet?: string;
|
||||
url?: string;
|
||||
knowledge_base_id?: string;
|
||||
knowledge_id?: string;
|
||||
document_id?: string;
|
||||
chunk_id?: string;
|
||||
wiki_slug?: string;
|
||||
slug?: string;
|
||||
}): SourceItem => ({
|
||||
title: item.title || '未命名来源',
|
||||
type: '参考资料',
|
||||
snippet: item.snippet || item.content || '',
|
||||
content: item.content || item.snippet || '',
|
||||
url: item.url,
|
||||
knowledge_base_id: item.knowledge_base_id,
|
||||
knowledge_id: item.knowledge_id,
|
||||
document_id: item.document_id,
|
||||
chunk_id: item.chunk_id,
|
||||
wiki_slug: item.wiki_slug,
|
||||
slug: item.slug,
|
||||
});
|
||||
|
||||
/**
|
||||
* 将知识库单轮接口返回的 docs 映射为页面引用来源。
|
||||
*/
|
||||
export const mapDocsToSources = (docs: KnowledgeBaseChunk['docs']): SourceItem[] => {
|
||||
const normalizedDocs = Array.isArray(docs) ? docs : docs ? [docs] : [];
|
||||
|
||||
return normalizedDocs.map((item, idx) => {
|
||||
console.log(item,'检索资料')
|
||||
if (typeof item === 'string') {
|
||||
return {
|
||||
title: '',
|
||||
type: '知识库',
|
||||
snippet: item,
|
||||
source: '',
|
||||
time: '',
|
||||
content: item,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: item.title || item.source || `参考资料 ${idx + 1}`,
|
||||
type: '知识库',
|
||||
snippet: item.snippet || item.content || item.url || '',
|
||||
source: item.source || item.title || '',
|
||||
time: '',
|
||||
url: item.url,
|
||||
knowledge_base_id: (item as any).knowledge_base_id,
|
||||
knowledge_id: (item as any).knowledge_id,
|
||||
document_id: (item as any).document_id,
|
||||
chunk_id: (item as any).chunk_id,
|
||||
wiki_slug: (item as any).wiki_slug,
|
||||
slug: (item as any).slug,
|
||||
content: item.content || item.snippet || '',
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const chatDataJSON = (chatJSON: string) => {
|
||||
let chatJSONText = chatJSON.replace(/}{"/g, '},{"');
|
||||
chatJSONText = '[' + chatJSONText + ']';
|
||||
// console.log('解析chat',chatJSONText);
|
||||
let chatData = [];
|
||||
let chatTitle: any = '';
|
||||
const docsList: any[] = [];
|
||||
try {
|
||||
chatData = JSON.parse(chatJSONText);
|
||||
chatData.forEach((item: any) => {
|
||||
if (item['answer']) {
|
||||
chatTitle += item['answer'];
|
||||
} else if (item['docs']) {
|
||||
docsList.push(item);
|
||||
}
|
||||
});
|
||||
return {
|
||||
chatTitle,
|
||||
docsList,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
chatTitle,
|
||||
docsList,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 从流式文本中提取完整 JSON 对象。
|
||||
*
|
||||
* SSE 响应可能把一个 JSON 切成多段返回;该函数通过花括号深度和字符串状态,
|
||||
* 找到已经完整闭合的对象,并把未闭合尾巴放回 `rest` 等待下一批数据拼接。
|
||||
*/
|
||||
export const extractJsonObjects = (raw: string): { objects: string[]; rest: string } => {
|
||||
const objects: string[] = [];
|
||||
let depth = 0;
|
||||
let stringQuote: '"' | "'" | null = null;
|
||||
let escaped = false;
|
||||
let start = -1;
|
||||
|
||||
for (let i = 0; i < raw.length; i += 1) {
|
||||
const ch = raw[i];
|
||||
|
||||
if (stringQuote) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (ch === '\\') {
|
||||
escaped = true;
|
||||
} else if (ch === stringQuote) {
|
||||
stringQuote = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '"' || ch === "'") {
|
||||
stringQuote = ch;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '{') {
|
||||
if (depth === 0) start = i;
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0 && start !== -1) {
|
||||
objects.push(raw.slice(start, i + 1));
|
||||
start = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (depth > 0 && start !== -1) {
|
||||
return { objects, rest: raw.slice(start) };
|
||||
}
|
||||
|
||||
return { objects, rest: '' };
|
||||
};
|
||||
|
||||
// 处理平台数据
|
||||
export const platformTypeChange = (platform: string) => {
|
||||
let platformType: string = '';
|
||||
if (platform.includes('fb')) {
|
||||
platformType = 'facebook';
|
||||
} else if (platform.includes('x')) {
|
||||
platformType = 'x';
|
||||
} else if (platform.includes('in')) {
|
||||
platformType = 'instagram';
|
||||
} else if (platform.includes('re')) {
|
||||
platformType = 'reddit';
|
||||
} else if (platform.includes('th')) {
|
||||
platformType = 'threads';
|
||||
} else {
|
||||
platformType = 'facebook';
|
||||
}
|
||||
return platformType;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,115 @@
|
||||
import type { QASingleRagTrace, QAThinkingTrace } from './QACommon';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
export type QAMode = 'single-chat' | 'single-round' | 'multi-round' | 'deep-search' | 'hx-search' | 'news-report';
|
||||
export type ViewMode = 'single' | 'compare';
|
||||
export type KnowledgeScope = 'all' | 'specific-kb' | 'specific-domain';
|
||||
export type AnswerStyle = 'concise' | 'detailed';
|
||||
export type CompareLayout = 'horizontal' | 'vertical';
|
||||
|
||||
export interface SourceItem {
|
||||
title: string;
|
||||
type: string;
|
||||
snippet: string;
|
||||
source?: string;
|
||||
time?: string;
|
||||
url?: string;
|
||||
recUuid?: string;
|
||||
rec_uuid?: string;
|
||||
knowledge_base_id?: string;
|
||||
knowledgeBaseId?: string;
|
||||
mapping_id?: string;
|
||||
mappingId?: string;
|
||||
knowledge_id?: string;
|
||||
knowledgeId?: string;
|
||||
document_id?: string;
|
||||
documentId?: string;
|
||||
chunk_id?: string;
|
||||
chunkId?: string;
|
||||
wiki_slug?: string;
|
||||
wikiSlug?: string;
|
||||
slug?: string;
|
||||
keywords?: string;
|
||||
taskDirection?: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
singleRagTrace?: QASingleRagTrace;
|
||||
thinkingTrace?: QAThinkingTrace;
|
||||
sources?: SourceItem[];
|
||||
}
|
||||
|
||||
export interface ToolCallData {
|
||||
tool_name: string;
|
||||
tool_input: {
|
||||
text?: string;
|
||||
query?: string;
|
||||
q?: string;
|
||||
keyword?: string;
|
||||
platform?: string;
|
||||
user_fig?: string;
|
||||
result?: string;
|
||||
s?: string;
|
||||
e?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface KnowledgeBaseDoc {
|
||||
title?: string;
|
||||
content?: string;
|
||||
source?: string;
|
||||
url?: string;
|
||||
snippet?: string;
|
||||
}
|
||||
|
||||
export interface KnowledgeBaseChunk {
|
||||
answer?: string;
|
||||
docs?: KnowledgeBaseDoc[] | KnowledgeBaseDoc | string;
|
||||
}
|
||||
|
||||
export interface ComparePane {
|
||||
mode: QAMode;
|
||||
loading: boolean;
|
||||
singleRagTrace?: QASingleRagTrace;
|
||||
thinkingTrace?: QAThinkingTrace;
|
||||
sources: SourceItem[];
|
||||
}
|
||||
|
||||
export interface CompareResult {
|
||||
id: string | number;
|
||||
compareUserInput: string;
|
||||
left: ComparePane;
|
||||
right: ComparePane;
|
||||
differences?: {
|
||||
consensus: string;
|
||||
differences: string;
|
||||
leftAdvantage: string;
|
||||
rightAdvantage: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SearchProcessState {
|
||||
currentMode: string;
|
||||
rounds: number;
|
||||
hits: number;
|
||||
queryRewrite: string;
|
||||
}
|
||||
|
||||
export interface stateItem {
|
||||
compareIndex: number;
|
||||
compareId: string;
|
||||
messageList: Message[],
|
||||
compareResult: CompareResult[];
|
||||
}
|
||||
|
||||
export interface qaModeItem {
|
||||
value: QAMode;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
desc: string;
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import React from 'react';
|
||||
import { BookOpen, Bot, Brain, ChevronLeft, ChevronRight, Sparkles, User2Icon } from 'lucide-react';
|
||||
import type { AnswerStyle, CompareLayout, KnowledgeScope, QAMode, qaModeItem, ViewMode } from './GeneralQA.types';
|
||||
|
||||
/**
|
||||
* 左侧配置栏 Props。
|
||||
*
|
||||
* 组件内不保存业务状态,所有选中值和 setter 都来自父组件。
|
||||
* 这样父组件可以统一处理“生成中切换需要确认停止”的逻辑,配置栏只负责触发用户意图。
|
||||
*/
|
||||
type Props = {
|
||||
isDark: boolean; // 当前是否为暗黑主题,用于样式适配
|
||||
viewMode: ViewMode; // 当前视图模式:'single' 单一模式 或 'compare' 对比模式
|
||||
qaMode: QAMode; // 单一模式下的问答模式:'single-round' 单轮RAG / 'multi-round' 多轮RAG / 'deep-search' 深度检索
|
||||
compareLeftMode: QAMode; // 对比模式下左侧的问答模式
|
||||
compareRightMode: QAMode; // 对比模式下右侧的问答模式
|
||||
compareLayout: CompareLayout; // 对比模式的布局方式:'horizontal' 水平 或 'vertical' 垂直
|
||||
knowledgeScope: KnowledgeScope; // 知识范围设置:'all' 全部 / 'specific-kb' 指定知识库 / 'specific-domain' 指定领域
|
||||
answerStyle: AnswerStyle; // 回答风格偏好:'concise' 简洁 或 'detailed' 详细
|
||||
showSources: boolean; // 是否在回答中展示引用来源
|
||||
showProcess: boolean; // 是否展示模型的思考过程
|
||||
autoSummarizeDiff: boolean; // 对比模式下是否自动总结左右差异
|
||||
recommendBetter: boolean; // 对比模式下是否推荐更好的回答方案
|
||||
isCollapsed: boolean; // 左侧配置面板是否折叠
|
||||
qaModeOptions?: qaModeItem[];
|
||||
setQaMode: (mode: QAMode) => void; // 设置单一模式的问答模式
|
||||
setCompareLeftMode: (mode: QAMode) => void; // 设置对比模式左侧的模式
|
||||
setCompareRightMode: (mode: QAMode) => void; // 设置对比模式右侧的模式
|
||||
setCompareLayout: (layout: CompareLayout) => void; // 设置对比布局方式
|
||||
setKnowledgeScope: (scope: KnowledgeScope) => void; // 设置知识范围
|
||||
setAnswerStyle: (style: AnswerStyle) => void; // 设置回答风格
|
||||
setShowSources: (show: boolean) => void; // 设置是否展示引用来源
|
||||
setShowProcess: (show: boolean) => void; // 设置是否展示思考过程
|
||||
setAutoSummarizeDiff: (enabled: boolean) => void; // 设置是否自动总结差异
|
||||
setRecommendBetter: (enabled: boolean) => void; // 设置是否推荐更好的方案
|
||||
setIsCollapsed: (collapsed: boolean) => void; // 设置配置面板折叠状态
|
||||
};
|
||||
|
||||
/**
|
||||
* 单一模式下可选的问答模式。
|
||||
*
|
||||
* 每个选项同时提供展示文案、图标和简短说明,便于配置栏用统一结构渲染卡片。
|
||||
*/
|
||||
const qaModeList: qaModeItem[] = [
|
||||
{ value: 'single-chat' as const, label: '快速', icon: Bot, desc: '快速直接' },
|
||||
{ value: 'single-round' as const, label: '单轮RAG', icon: Sparkles, desc: '快速直接' },
|
||||
{ value: 'multi-round' as const, label: '多轮RAG', icon: Brain, desc: '深入理解' },
|
||||
{ value: 'deep-search' as const, label: '任务检索', icon: BookOpen, desc: '检索相关任务' },
|
||||
{ value: 'hx-search' as const, label: '画像检索', icon: User2Icon, desc: '检索相关账号' },
|
||||
{ value: 'news-report' as const, label: '新闻报告生成', icon: User2Icon, desc: '检索相关新闻' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 知识范围选项。
|
||||
*
|
||||
* 当前只影响 UI 配置状态,后续接入真实知识库筛选时可以直接把该值映射到请求参数。
|
||||
*/
|
||||
const knowledgeScopeOptions = [
|
||||
{ value: 'all' as const, label: '全部知识源' },
|
||||
{ value: 'specific-kb' as const, label: '指定知识库' },
|
||||
{ value: 'specific-domain' as const, label: '指定资源域' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 左侧问答配置栏。
|
||||
*
|
||||
* 该组件集中承载页面的“模式选择、知识范围、回答偏好、对比设置”等表单控件。
|
||||
* 父组件仍是唯一状态源,配置栏通过 setter 把用户选择同步回父组件;这样不会改变原有请求逻辑,
|
||||
* 同时把 300 多行配置 JSX 从 `GeneralQA.tsx` 中移出,便于后续维护表单项和样式。
|
||||
*/
|
||||
export default function GeneralQAConfigSidebar({
|
||||
isDark,
|
||||
viewMode,
|
||||
qaMode,
|
||||
compareLeftMode,
|
||||
compareRightMode,
|
||||
compareLayout,
|
||||
knowledgeScope,
|
||||
answerStyle,
|
||||
showSources,
|
||||
showProcess,
|
||||
autoSummarizeDiff,
|
||||
recommendBetter,
|
||||
isCollapsed,
|
||||
qaModeOptions = qaModeList,
|
||||
setQaMode,
|
||||
setCompareLeftMode,
|
||||
setCompareRightMode,
|
||||
setCompareLayout,
|
||||
setKnowledgeScope,
|
||||
setAnswerStyle,
|
||||
setShowSources,
|
||||
setShowProcess,
|
||||
setAutoSummarizeDiff,
|
||||
setRecommendBetter,
|
||||
setIsCollapsed,
|
||||
}: Props): React.JSX.Element {
|
||||
/**
|
||||
* 自定义勾选图标。
|
||||
*
|
||||
* 原生 checkbox 被隐藏后,所有勾选项都通过这个 SVG 保持一致的视觉风格。
|
||||
*/
|
||||
const renderCheckIcon = () => (
|
||||
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`${isCollapsed ? 'w-14 p-2' : 'w-72 p-4'} border-r flex-shrink-0 overflow-y-auto transition-all duration-300 ${
|
||||
isDark ? 'border-slate-700/50 bg-slate-800/30' : 'border-red-100 bg-gray-50/50'
|
||||
}`}>
|
||||
<div className={`flex items-center ${isCollapsed ? 'justify-center' : 'justify-between'} mb-4`}>
|
||||
{!isCollapsed && (
|
||||
<span className={`text-xs font-medium uppercase tracking-wider ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
|
||||
问答配置
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
className={`p-2 rounded-lg transition-all ${
|
||||
isDark
|
||||
? 'text-slate-400 hover:text-white hover:bg-slate-700/50'
|
||||
: 'text-gray-500 hover:text-red-600 hover:bg-red-50'
|
||||
}`}
|
||||
title={isCollapsed ? '展开侧边栏' : '收起侧边栏'}
|
||||
>
|
||||
{isCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
{viewMode === 'single' ? (
|
||||
<div className="mb-6">
|
||||
<h3 className={`text-sm font-medium mb-3 ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>问答模式</h3>
|
||||
<div className="space-y-2">
|
||||
{qaModeOptions.map((mode) => {
|
||||
const Icon = mode.icon;
|
||||
return (
|
||||
<button
|
||||
key={mode.value}
|
||||
onClick={() => setQaMode(mode.value)}
|
||||
className={`w-full flex items-center gap-3 p-3 rounded-lg transition-all text-left ${
|
||||
qaMode === mode.value
|
||||
? isDark
|
||||
? 'bg-blue-600/20 border border-blue-500/50 text-blue-400'
|
||||
: 'bg-red-50 border border-red-200 text-red-600'
|
||||
: isDark
|
||||
? 'bg-slate-700/30 border border-transparent text-slate-400 hover:bg-slate-700/50'
|
||||
: 'bg-white border border-gray-200 text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium">{mode.label}</div>
|
||||
<div className={`text-xs ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{mode.desc}</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<h3 className={`text-sm font-medium mb-3 ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>对比模式设置</h3>
|
||||
<div className="mb-4">
|
||||
<label className={`text-xs mb-2 block ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>模式A</label>
|
||||
<select value={compareLeftMode} onChange={(e) => setCompareLeftMode(e.target.value as QAMode)} className={`w-full px-3 py-2 rounded-lg text-sm ${isDark ? 'bg-slate-700/50 border border-slate-600 text-white' : 'bg-white border border-gray-200 text-gray-700'}`}>
|
||||
{
|
||||
qaModeOptions.filter(item => item.value !== compareRightMode).map(item => (
|
||||
<option value={item.value} key={item.value}>{item.label}</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div className={`text-center my-2 ${isDark ? 'text-slate-500' : 'text-gray-400'}`}><span className="text-lg">VS</span></div>
|
||||
<div className="mb-4">
|
||||
<label className={`text-xs mb-2 block ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>模式B</label>
|
||||
<select value={compareRightMode} onChange={(e) => setCompareRightMode(e.target.value as QAMode)} className={`w-full px-3 py-2 rounded-lg text-sm ${isDark ? 'bg-slate-700/50 border border-slate-600 text-white' : 'bg-white border border-gray-200 text-gray-700'}`}>
|
||||
{
|
||||
qaModeOptions.filter(item => item.value !== compareLeftMode).map(item => (
|
||||
<option value={item.value} key={item.value}>{item.label}</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<h3 className={`text-sm font-medium mb-3 ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>对比布局</h3>
|
||||
<div className="flex gap-2">
|
||||
{(['horizontal', 'vertical'] as const).map((layout) => (
|
||||
<button key={layout} onClick={() => setCompareLayout(layout)} className={`flex-1 px-3 py-2 rounded-lg text-sm transition-all ${compareLayout === layout ? isDark ? 'bg-blue-600 text-white' : 'bg-red-500 text-white' : isDark ? 'bg-slate-700/50 text-slate-300' : 'bg-gray-100 text-gray-600'}`}>
|
||||
{layout === 'horizontal' ? '左右对比' : '上下对比'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* <div className="mb-6">
|
||||
<h3 className={`text-sm font-medium mb-3 ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>知识范围</h3>
|
||||
<div className="space-y-2">
|
||||
{knowledgeScopeOptions.map((option) => (
|
||||
<label key={option.value} className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer transition-all ${knowledgeScope === option.value ? isDark ? 'bg-blue-600/20 text-blue-400' : 'bg-red-50 text-red-600' : isDark ? 'text-slate-400 hover:bg-slate-700/30' : 'text-gray-600 hover:bg-gray-50'}`}>
|
||||
<input type="radio" name="knowledge-scope" checked={knowledgeScope === option.value} onChange={() => setKnowledgeScope(option.value)} className="sr-only" />
|
||||
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${knowledgeScope === option.value ? isDark ? 'border-blue-500 bg-blue-500' : 'border-red-500 bg-red-500' : isDark ? 'border-slate-500' : 'border-gray-300'}`}>
|
||||
{knowledgeScope === option.value && <div className="w-1.5 h-1.5 rounded-full bg-white" />}
|
||||
</div>
|
||||
<span className="text-sm">{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
{/* <div className="mb-6">
|
||||
<h3 className={`text-sm font-medium mb-3 ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>回答偏好</h3>
|
||||
<div className="space-y-3">
|
||||
<label className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer transition-all ${isDark ? 'text-slate-400 hover:bg-slate-700/30' : 'text-gray-600 hover:bg-gray-50'}`}>
|
||||
<input type="checkbox" checked={answerStyle === 'detailed'} onChange={() => setAnswerStyle(answerStyle === 'detailed' ? 'concise' : 'detailed')} className="sr-only" />
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center ${answerStyle === 'detailed' ? isDark ? 'bg-blue-500 border-blue-500' : 'bg-red-500 border-red-500' : isDark ? 'border-slate-500' : 'border-gray-300'}`}>{answerStyle === 'detailed' && renderCheckIcon()}</div>
|
||||
<span className="text-sm">详细回答</span>
|
||||
</label>
|
||||
<label className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer transition-all ${isDark ? 'text-slate-400 hover:bg-slate-700/30' : 'text-gray-600 hover:bg-gray-50'}`}>
|
||||
<input type="checkbox" checked={showSources} onChange={() => setShowSources(!showSources)} className="sr-only" />
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center ${showSources ? isDark ? 'bg-blue-500 border-blue-500' : 'bg-red-500 border-red-500' : isDark ? 'border-slate-500' : 'border-gray-300'}`}>{showSources && renderCheckIcon()}</div>
|
||||
<span className="text-sm">展示引用来源</span>
|
||||
</label>
|
||||
<label className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer transition-all ${isDark ? 'text-slate-400 hover:bg-slate-700/30' : 'text-gray-600 hover:bg-gray-50'}`}>
|
||||
<input type="checkbox" checked={showProcess} onChange={() => setShowProcess(!showProcess)} className="sr-only" />
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center ${showProcess ? isDark ? 'bg-blue-500 border-blue-500' : 'bg-red-500 border-red-500' : isDark ? 'border-slate-500' : 'border-gray-300'}`}>{showProcess && renderCheckIcon()}</div>
|
||||
<span className="text-sm">展示检索过程</span>
|
||||
</label>
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
{/* {viewMode === 'compare' && (
|
||||
<div className="mb-6">
|
||||
<h3 className={`text-sm font-medium mb-3 ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>对比设置</h3>
|
||||
<div className="space-y-3">
|
||||
<label className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer transition-all ${isDark ? 'text-slate-400 hover:bg-slate-700/30' : 'text-gray-600 hover:bg-gray-50'}`}>
|
||||
<input type="checkbox" checked={autoSummarizeDiff} onChange={() => setAutoSummarizeDiff(!autoSummarizeDiff)} className="sr-only" />
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center ${autoSummarizeDiff ? isDark ? 'bg-blue-500 border-blue-500' : 'bg-red-500 border-red-500' : isDark ? 'border-slate-500' : 'border-gray-300'}`}>{autoSummarizeDiff && renderCheckIcon()}</div>
|
||||
<span className="text-sm">自动总结差异</span>
|
||||
</label>
|
||||
<label className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer transition-all ${isDark ? 'text-slate-400 hover:bg-slate-700/30' : 'text-gray-600 hover:bg-gray-50'}`}>
|
||||
<input type="checkbox" checked={recommendBetter} onChange={() => setRecommendBetter(!recommendBetter)} className="sr-only" />
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center ${recommendBetter ? isDark ? 'bg-blue-500 border-blue-500' : 'bg-red-500 border-red-500' : isDark ? 'border-slate-500' : 'border-gray-300'}`}>{recommendBetter && renderCheckIcon()}</div>
|
||||
<span className="text-sm">推荐更优答案</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)} */}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import React from 'react';
|
||||
import { History } from 'lucide-react';
|
||||
import type { ViewMode } from './GeneralQA.types';
|
||||
|
||||
/**
|
||||
* 顶部栏 Props。
|
||||
*
|
||||
* `setViewMode` 由父组件传入,父组件会在生成中切换时做确认拦截;
|
||||
* Header 本身不直接感知 loading,保持纯展示和事件转发。
|
||||
*/
|
||||
type Props = {
|
||||
isDark: boolean; // 当前是否为暗黑主题,用于样式适配
|
||||
viewMode: ViewMode; // 当前视图模式:'single' 单一模式 或 'compare' 对比模式
|
||||
chatHeadTitle?: string;
|
||||
chatDesign?: string;
|
||||
compareShow?: boolean;
|
||||
setViewMode: (mode: ViewMode) => void; // 设置视图模式回调函数
|
||||
onNavigate?: (page: string) => void; // 页面导航回调函数
|
||||
};
|
||||
|
||||
/**
|
||||
* 通用问答页面顶部栏。
|
||||
*
|
||||
* 该组件只负责页面标题、模式切换和历史记录入口,所有业务状态仍由父组件维护。
|
||||
* 这样拆分后,顶部导航的样式和交互不会和中间问答流处理逻辑混在一起,后续如果要新增
|
||||
* "保存模板""导出记录"等入口,也可以只改这个文件。
|
||||
*/
|
||||
export default function GeneralQAHeader({ isDark, viewMode, chatHeadTitle = '通用问答', chatDesign = '支持单轮RAG、多轮RAG、深度检索及结果对比', compareShow = true, setViewMode, onNavigate }: Props): React.JSX.Element {
|
||||
return (
|
||||
<div className={`flex items-center justify-between px-6 py-4 border-b ${isDark ? 'border-slate-700/50' : 'border-red-100'}`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
{chatHeadTitle}
|
||||
</h1>
|
||||
<span className={`text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
{chatDesign}
|
||||
</span>
|
||||
</div>
|
||||
{
|
||||
compareShow && <div className="flex items-center gap-2">
|
||||
<div className={`flex rounded-lg p-1 ${isDark ? 'bg-slate-700/50' : 'bg-red-50'}`}>
|
||||
<button
|
||||
onClick={() => setViewMode('single')}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${viewMode === 'single'
|
||||
? isDark
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-red-500 text-white'
|
||||
: isDark
|
||||
? 'text-slate-400 hover:text-white'
|
||||
: 'text-gray-600 hover:text-red-600'
|
||||
}`}
|
||||
>
|
||||
单一模式
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('compare')}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${viewMode === 'compare'
|
||||
? isDark
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-red-500 text-white'
|
||||
: isDark
|
||||
? 'text-slate-400 hover:text-white'
|
||||
: 'text-gray-600 hover:text-red-600'
|
||||
}`}
|
||||
>
|
||||
对比模式
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{onNavigate && (
|
||||
<button
|
||||
onClick={() => onNavigate('qa-records')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
isDark
|
||||
? 'bg-slate-700/50 text-slate-300 hover:bg-slate-600/50'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<History className="w-4 h-4" />
|
||||
历史记录
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,957 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Dialog, Textarea } from 'tdesign-react';
|
||||
import {
|
||||
Bookmark,
|
||||
BrainCircuit,
|
||||
Bot,
|
||||
ChevronDown,
|
||||
FileSearch,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
Search,
|
||||
Send,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Square,
|
||||
Copy,
|
||||
Trash2,
|
||||
Edit3,
|
||||
Upload,
|
||||
UserRound,
|
||||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { openKnowledgeSource, resolveWeKnoraSourceTarget, type WeKnoraSourceTarget } from '@/core/llmwiki/source-links';
|
||||
import MarkdownRenderer from '@/strategy-components/MarkdownRenderer';
|
||||
import { SourceItem } from './GeneralQA.types';
|
||||
import '../qa/qa-animations.css';
|
||||
import { readRuntimeEnv } from '@/env';
|
||||
import { WeKnoraSourceDrawer } from '@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer';
|
||||
|
||||
/**
|
||||
* 单条检索结果。
|
||||
*
|
||||
* 多轮检索过程中每一轮可能返回多条资料,这里只保留 UI 展示所需的标题、摘要和来源。
|
||||
*/
|
||||
export interface QARetrievalItem {
|
||||
id?: string;
|
||||
title: string;
|
||||
content: string;
|
||||
keywords?: string;
|
||||
taskDirection?: string;
|
||||
type?: string;
|
||||
time?: string;
|
||||
source?: string;
|
||||
recUuid?: string;
|
||||
platform?: string;
|
||||
fans?: number;
|
||||
comment_count?: number;
|
||||
message_id?: string;
|
||||
hot_num?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 多轮检索中的单轮思考节点。
|
||||
*
|
||||
* `queryLoading` 控制该轮检索结果是否仍显示 loading;retrievals 到达后再渲染资料列表。
|
||||
*/
|
||||
export interface QAThinkingRound {
|
||||
id: string; // 唯一标识符,用于列表渲染时的 key
|
||||
title: string; // 该轮检索的标题,如"第1轮检索"或"背景资料收集"
|
||||
thoughtMarkdown: string; // 模型思考过程的 Markdown 内容,展示思考步骤和推理逻辑
|
||||
queryTitle?: string; // 检索内容
|
||||
query: string; // 该轮发送给检索系统的查询语句
|
||||
queryLoading?: boolean; // 该轮检索是否仍在进行中,true 时显示加载动画
|
||||
textidContent?: string; // 新闻详细内容
|
||||
retrievals: QARetrievalItem[]; // 该轮返回的检索结果列表
|
||||
queryShow: boolean; // 检索列表为空时是否显示
|
||||
}
|
||||
|
||||
/**
|
||||
* 多轮/深度检索的完整思考链。
|
||||
*
|
||||
* rounds 展示检索过程,finalAnswerMarkdown 展示最终回答;两者分开便于流式过程中逐步更新。
|
||||
*/
|
||||
export interface QAThinkingTrace {
|
||||
title?: string; // 整体思考链的标题
|
||||
rounds: QAThinkingRound[]; // 多轮检索过程中的所有轮次,每轮包含思考和检索结果
|
||||
finalAnswerMarkdown: string; // 基于多轮检索结果生成的最终回答内容
|
||||
thinkingComplete?: boolean; // 思考过程是否已完成,true 时思考区域可折叠
|
||||
}
|
||||
|
||||
/**
|
||||
* 单轮 RAG 的回答结构。
|
||||
*
|
||||
* 后端可能把 `<think>...</think>` 和答案混在同一个 answer 流中,解析后分别放到 thinkingMarkdown 与 answerMarkdown。
|
||||
*/
|
||||
export interface QASingleRagTrace {
|
||||
title?: string; // 思考过程的标题,如"单轮 RAG 思考过程"
|
||||
thinkingMarkdown: string; // 模型在生成回答前的思考过程内容,使用 Markdown 格式
|
||||
answerMarkdown: string; // 最终生成的回答内容,使用 Markdown 格式
|
||||
thinkingComplete?: boolean; // 思考过程是否已完成,true 时思考区域可折叠
|
||||
}
|
||||
|
||||
export interface QACommonMessage {
|
||||
id: string; // 消息唯一标识符
|
||||
role: 'user' | 'assistant'; // 消息发送者角色:user 为用户消息,assistant 为 AI 回复
|
||||
content: string; // 消息的纯文本内容(当使用 thinkingTrace 或 singleRagTrace 时为空)
|
||||
timestamp?: Date; // 消息发送时间
|
||||
thinkingTrace?: QAThinkingTrace; // 多轮/深度检索模式的思考链,包含多轮检索过程和最终回答
|
||||
sources?: { // 消息引用的参考资料列表
|
||||
title: string; // 参考资料标题
|
||||
type: string; // 资料类型,如"文档"、"网页"等
|
||||
snippet: string; // 资料摘要内容
|
||||
source?: string; // 来源名称或 URL
|
||||
time?: string; // 发布时间
|
||||
url?: string; // 资料链接
|
||||
content?: string; // 完整内容(可能用于详情弹窗)
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface QACommonMessage {
|
||||
singleRagTrace?: QASingleRagTrace; // 单轮 RAG 模式的思考过程和回答结构
|
||||
}
|
||||
|
||||
interface QAAvatarProps {
|
||||
role: 'user' | 'assistant';
|
||||
isActive?: boolean;
|
||||
label?: string;
|
||||
variant?: 'default' | 'blue' | 'purple';
|
||||
}
|
||||
|
||||
const getAssistantAvatarClass = (variant: QAAvatarProps['variant'], isDark: boolean) => {
|
||||
switch (variant) {
|
||||
case 'blue':
|
||||
return isDark
|
||||
? 'bg-gradient-to-br from-blue-400 via-blue-600 to-cyan-700 text-white'
|
||||
: 'bg-gradient-to-br from-blue-300 via-blue-500 to-cyan-500 text-white';
|
||||
case 'purple':
|
||||
return isDark
|
||||
? 'bg-gradient-to-br from-purple-400 via-violet-600 to-fuchsia-700 text-white'
|
||||
: 'bg-gradient-to-br from-purple-300 via-violet-500 to-fuchsia-500 text-white';
|
||||
default:
|
||||
return isDark
|
||||
? 'bg-gradient-to-br from-cyan-500 via-blue-600 to-violet-600 text-white'
|
||||
: 'bg-gradient-to-br from-red-400 via-rose-500 to-orange-400 text-white';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 统一头像组件。
|
||||
*
|
||||
* 单一模式使用默认助手色;对比模式可通过 variant 指定蓝/紫色系,让头像与左右面板标签保持一致。
|
||||
* `isActive` 会触发呼吸和星光动画,用来提示当前助手正在生成。
|
||||
*/
|
||||
export const QAAvatar: React.FC<QAAvatarProps> = ({ role, isActive = false, label, variant = 'default' }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const Icon = role === 'assistant' ? Bot : UserRound;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative w-9 h-9 rounded-2xl flex items-center justify-center flex-shrink-0 overflow-hidden shadow-sm ${role === 'assistant'
|
||||
? getAssistantAvatarClass(variant, isDark)
|
||||
: isDark
|
||||
? 'bg-gradient-to-br from-slate-500 via-slate-700 to-slate-900 text-white'
|
||||
: 'bg-gradient-to-br from-sky-400 via-indigo-500 to-violet-500 text-white'
|
||||
} ${isActive ? 'qa-avatar-active' : ''}`}
|
||||
title={label}
|
||||
>
|
||||
<div className="absolute inset-0 bg-white/20 opacity-0 qa-avatar-shine" />
|
||||
<Icon className={`w-5 h-5 relative z-10 ${isActive ? 'qa-avatar-icon-active' : ''}`} />
|
||||
{role === 'assistant' && isActive && (
|
||||
<Sparkles className="absolute -right-0.5 -top-0.5 w-3.5 h-3.5 text-yellow-200 qa-avatar-spark" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface TypingIndicatorProps {
|
||||
label?: string;
|
||||
assistantLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 助手预连接/生成中的占位提示。
|
||||
*
|
||||
* 用于请求已经发出但还没有生成可展示消息时,给用户明确的等待反馈。
|
||||
*/
|
||||
export const TypingIndicator: React.FC<TypingIndicatorProps> = ({ label = '正在思考并生成回复', assistantLabel = 'AI 助手' }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<QAAvatar role="assistant" isActive label={assistantLabel} />
|
||||
<div className={`max-w-[70%] rounded-2xl px-4 py-3 shadow-sm border ${isDark ? 'bg-slate-700/60 border-slate-600/50 text-slate-200' : 'bg-white border-red-100 text-gray-700'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader2 className={`w-4 h-4 animate-spin ${isDark ? 'text-blue-300' : 'text-red-500'}`} />
|
||||
<span
|
||||
className={`text-sm font-medium qa-generating-marquee ${isDark ? 'qa-generating-marquee-dark' : 'qa-generating-marquee-light'}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{/* <div className="flex gap-1">
|
||||
{[0, 1, 2].map((idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className={`w-1.5 h-1.5 rounded-full qa-typing-dot ${isDark ? 'bg-blue-300' : 'bg-red-400'}`}
|
||||
style={{
|
||||
animationDelay: `${idx * 160}ms`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ChatMessageProps<T extends QACommonMessage> {
|
||||
message: T;
|
||||
showSources?: boolean;
|
||||
assistantLabel?: string;
|
||||
assistantActive?: boolean;
|
||||
onCitationClick?: (message: T, citationIndex: number) => void;
|
||||
onCopyAssistant?: (message: T) => void;
|
||||
onDeleteAssistant?: (message: T) => void;
|
||||
onEditAssistant?: (message: T) => void;
|
||||
onResendAssistant?: (message: T) => void;
|
||||
renderAssistantExtra?: (message: T) => React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天消息渲染器。
|
||||
*
|
||||
* 根据消息类型自动选择普通文本、单轮 RAG 回答或多轮检索思考链。
|
||||
* sources 区域独立折叠展示,避免引用资料过长时挤占主回答阅读空间。
|
||||
*/
|
||||
export function ChatMessage<T extends QACommonMessage>({
|
||||
message,
|
||||
showSources = true,
|
||||
assistantLabel = 'AI 助手',
|
||||
assistantActive = false,
|
||||
onCitationClick,
|
||||
onCopyAssistant,
|
||||
onDeleteAssistant,
|
||||
onEditAssistant,
|
||||
onResendAssistant,
|
||||
renderAssistantExtra,
|
||||
}: ChatMessageProps<T>) {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const isUser = message.role === 'user';
|
||||
const hasTrace = !isUser && !!(message.singleRagTrace || message.thinkingTrace);
|
||||
const [visible, setVisible] = useState(false); // 打开详情对话框
|
||||
const [listData, setListData] = useState<SourceItem>({
|
||||
title: '',
|
||||
type: '',
|
||||
time: '',
|
||||
snippet: '',
|
||||
source: '',
|
||||
content: '',
|
||||
});
|
||||
const [weknoraDrawer, setWeknoraDrawer] = useState<{
|
||||
target: WeKnoraSourceTarget;
|
||||
title: string;
|
||||
} | null>(null);
|
||||
const showDialog = (item: SourceItem) => {
|
||||
console.log('item', item)
|
||||
const weknoraTarget = resolveWeKnoraSourceTarget(item);
|
||||
if (weknoraTarget) {
|
||||
setWeknoraDrawer({ target: weknoraTarget, title: item.title || '参考文献原文' });
|
||||
} else if (item.url) {
|
||||
openKnowledgeSource(item);
|
||||
} else {
|
||||
setListData(item);
|
||||
setVisible(true);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className={`flex gap-3 ${isUser ? 'justify-end' : ''}`}>
|
||||
{!isUser && <QAAvatar role="assistant" isActive={assistantActive} label={assistantLabel} />}
|
||||
<div className={`${hasTrace ? 'max-w-[85%] flex-1' : 'max-w-[72%]'} rounded-2xl px-4 py-3 shadow-sm ${isUser
|
||||
? isDark
|
||||
? 'bg-blue-600/70 text-white'
|
||||
: 'bg-gradient-to-r from-red-500 to-rose-500 text-white'
|
||||
: message.singleRagTrace || message.thinkingTrace
|
||||
? isDark
|
||||
? 'bg-slate-800/40 text-slate-100 border border-slate-600/40'
|
||||
: 'bg-white text-gray-800 border border-transparent shadow-[0px_3px_12px_rgba(0, 0, 0, 0.12)] px-0 py-0'
|
||||
: isDark
|
||||
? 'bg-slate-700/60 text-slate-100 border border-slate-600/40'
|
||||
: 'bg-white text-gray-800 border border-gray-100'
|
||||
}`}>
|
||||
{message.singleRagTrace && !isUser ? (
|
||||
<SingleRagAnswer
|
||||
trace={message.singleRagTrace}
|
||||
onCitationClick={(citationIndex) => onCitationClick?.(message, citationIndex)}
|
||||
/>
|
||||
) : message.thinkingTrace && !isUser ? (
|
||||
<RetrievalThinkingTrace
|
||||
trace={message.thinkingTrace}
|
||||
onCitationClick={(citationIndex) => onCitationClick?.(message, citationIndex)}
|
||||
/>
|
||||
) : (
|
||||
<p className="whitespace-pre-wrap leading-relaxed">{message.content}</p>
|
||||
)}
|
||||
{!isUser && (onCopyAssistant || onDeleteAssistant || onEditAssistant || onResendAssistant) && (
|
||||
<div className={`mt-2 flex items-center justify-end gap-1.5 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
{onCopyAssistant && (
|
||||
<button type="button" onClick={() => onCopyAssistant(message)} className={`p-1.5 rounded-md transition-colors ${isDark ? 'hover:bg-slate-700 hover:text-slate-100' : 'hover:bg-gray-100 hover:text-gray-700'}`} title="复制回答">
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{onDeleteAssistant && (
|
||||
<button type="button" onClick={() => onDeleteAssistant(message)} className={`p-1.5 rounded-md transition-colors ${isDark ? 'hover:bg-slate-700 hover:text-rose-300' : 'hover:bg-red-50 hover:text-red-600'}`} title="删除问答">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{onEditAssistant && (
|
||||
<button type="button" onClick={() => onEditAssistant(message)} className={`p-1.5 rounded-md transition-colors ${isDark ? 'hover:bg-slate-700 hover:text-blue-300' : 'hover:bg-blue-50 hover:text-blue-600'}`} title="编辑回答和思考过程">
|
||||
<Edit3 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{onResendAssistant && (
|
||||
<button type="button" onClick={() => onResendAssistant(message)} className={`p-1.5 rounded-md transition-colors ${isDark ? 'hover:bg-slate-700 hover:text-emerald-300' : 'hover:bg-emerald-50 hover:text-emerald-600'}`} title="重新发送">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isUser && <QAAvatar role="user" label="用户" />}
|
||||
</div>
|
||||
|
||||
|
||||
{!isUser && message.sources && message.sources.length > 0 && showSources && (
|
||||
<CollapsibleSection
|
||||
defaultOpen={false}
|
||||
className={`ml-12 ${hasTrace ? 'max-w-[85%]' : 'max-w-[72%]'} ${isDark ? 'bg-slate-800/50 border-slate-700/50' : 'bg-gray-50 border-gray-200'}`}
|
||||
title={(
|
||||
<div className="flex items-center gap-2">
|
||||
<BrainCircuit className={`w-3.5 h-3.5 ${isDark ? 'text-blue-300' : 'text-red-500'}`} />
|
||||
<span className={`text-xs font-medium ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>引用来源</span>
|
||||
<span className={`text-xs ${isDark ? 'text-slate-500' : 'text-gray-500'}`}>({message.sources.length})</span>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-2 pt-[10px]">
|
||||
{message.sources.map((source, idx) => (
|
||||
<div key={idx} className={`text-xs p-2 rounded-[4px] border shadow-sm transition-all duration-200 ease-out cursor-pointer hover:-translate-y-0.5 hover:scale-[1.01] ${isDark ? 'bg-slate-700/50 border-slate-600/50 shadow-black/10 hover:bg-slate-700/80 hover:border-blue-400/40 hover:shadow-lg hover:shadow-blue-950/20' : 'bg-white border-gray-100 shadow-gray-200/60 hover:border-red-200 hover:shadow-md hover:shadow-red-100/70'}`} onClick={() => showDialog(source)}>
|
||||
<div className="flex items-center gap-2 mb-1 min-w-0">
|
||||
<BrainCircuit className={`w-3 h-3 shrink-0 ${isDark ? 'text-blue-400' : 'text-red-400'}`} />
|
||||
<span className={`font-medium truncate min-w-0 flex-1 ${isDark ? 'text-slate-300' : 'text-gray-700'}`} title={source.title}>{source.title}</span>
|
||||
<span className={`shrink-0 px-1.5 py-0.5 rounded text-xs ${isDark ? 'bg-slate-600 text-slate-300' : 'bg-gray-200 text-gray-500'}`}>
|
||||
{source.type}
|
||||
</span>
|
||||
</div>
|
||||
<p className={`line-clamp-3 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{source.snippet}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
<Dialog
|
||||
visible={visible}
|
||||
onClose={() => setVisible(false)}
|
||||
header="提示"
|
||||
footer={null}
|
||||
>
|
||||
<div>
|
||||
<div className="pr-9">
|
||||
<h3 className="text-base font-semibold">{listData.title || '引用内容'}</h3>
|
||||
<div className={`mt-2 text-xs space-y-1 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
<div>来源:{listData.source || '未知来源'}</div>
|
||||
<div>时间:{listData.time || '未知'}</div>
|
||||
</div>
|
||||
</div>
|
||||
{(listData.url || resolveWeKnoraSourceTarget(listData)) && (
|
||||
<div className="mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const weknoraTarget = resolveWeKnoraSourceTarget(listData);
|
||||
if (weknoraTarget) {
|
||||
setWeknoraDrawer({ target: weknoraTarget, title: listData.title || '参考文献原文' });
|
||||
setVisible(false);
|
||||
} else {
|
||||
void openKnowledgeSource(listData);
|
||||
}
|
||||
}}
|
||||
className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border text-sm transition-all ${isDark
|
||||
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
|
||||
: 'border-gray-300 text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
title="打开原始链接"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
查看原文链接
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className={`mt-4 rounded-xl p-3 text-sm leading-relaxed whitespace-pre-wrap ${isDark ? 'bg-slate-800/70 text-slate-200' : 'bg-gray-50 text-gray-700'
|
||||
}`}>
|
||||
{listData.content || '暂无引用内容'}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
<WeKnoraSourceDrawer
|
||||
open={!!weknoraDrawer}
|
||||
target={weknoraDrawer?.target ?? null}
|
||||
title={weknoraDrawer?.title}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) setWeknoraDrawer(null);
|
||||
}}
|
||||
/>
|
||||
{!isUser && renderAssistantExtra?.(message)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CollapsibleSectionProps {
|
||||
title: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
className?: string;
|
||||
headerClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用折叠面板。
|
||||
*
|
||||
* 思考过程、检索结果、引用来源都复用该组件,保证展开/收起动画和深浅色主题一致。
|
||||
*/
|
||||
const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
|
||||
title,
|
||||
children,
|
||||
defaultOpen = true,
|
||||
className = '',
|
||||
headerClassName = '',
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
// 当 defaultOpen 改变时,更新 open 状态
|
||||
useEffect(() => {
|
||||
setOpen(defaultOpen);
|
||||
}, [defaultOpen]);
|
||||
return (
|
||||
<div className={`rounded-2xl border overflow-hidden ${isDark ? 'border-slate-700/60 bg-slate-900/30' : 'border-red-100 bg-white'} ${className}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className={`w-full flex items-center justify-between gap-3 px-4 py-3 text-left transition-colors ${isDark ? 'hover:bg-slate-800/70' : 'hover:bg-red-50/60'
|
||||
} ${headerClassName}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">{title}</div>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 flex-shrink-0 transition-transform duration-300 ease-out ${open ? 'rotate-0' : '-rotate-90'
|
||||
} ${isDark ? 'text-slate-400' : 'text-gray-500'}`}
|
||||
/>
|
||||
</button>
|
||||
<div className={`grid transition-[grid-template-rows,opacity] duration-300 ease-out ${open ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'}`}>
|
||||
<div className="overflow-hidden">
|
||||
<div className={`px-4 pb-4 transition-transform duration-300 ease-out ${open ? 'translate-y-0' : '-translate-y-2'} ${isDark ? 'text-slate-200' : 'text-gray-700'}`}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SingleRagAnswerProps {
|
||||
trace: QASingleRagTrace;
|
||||
onCitationClick?: (citationIndex: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单轮 RAG 回答展示。
|
||||
*
|
||||
* 思考内容和最终回答分成两个区域:思考区可折叠,回答区始终展示。
|
||||
* 这样既保留模型推理过程,也不会影响用户优先阅读最终答案。
|
||||
*/
|
||||
export const SingleRagAnswer: React.FC<SingleRagAnswerProps> = ({ trace, onCitationClick }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
return (
|
||||
<div className="w-full min-w-0 max-w-5xl space-y-4 overflow-hidden">
|
||||
<CollapsibleSection
|
||||
defaultOpen={!trace.thinkingComplete}
|
||||
className={isDark ? 'bg-slate-800/60 shadow-lg shadow-black/10' : 'bg-white shadow-sm'}
|
||||
title={
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-9 h-9 rounded-2xl flex items-center justify-center ${isDark ? 'bg-violet-500/15 text-violet-300' : 'bg-orange-50 text-orange-500'
|
||||
}`}>
|
||||
<Sparkles className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className={`text-sm font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>{trace.title || '思考过程'}</div>
|
||||
{trace.thinkingComplete ? (
|
||||
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
思考已完成,可展开查看
|
||||
</div>
|
||||
) : (
|
||||
<div className={`text-xs qa-generating-marquee ${isDark ? 'qa-generating-marquee-dark text-slate-300' : 'qa-generating-marquee-light text-gray-600'}`}>
|
||||
正在接收 <think> 流式思考内容
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{trace.thinkingMarkdown ? (
|
||||
<MarkdownRenderer
|
||||
content={trace.thinkingMarkdown}
|
||||
className={`text-sm ${isDark ? 'text-slate-300' : 'text-gray-700'}`}
|
||||
onCitationClick={onCitationClick}
|
||||
/>
|
||||
) : (
|
||||
<div className={`text-sm flex items-center gap-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
等待模型输出思考内容...
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
|
||||
<div className={`rounded-2xl border p-4 ${isDark ? 'bg-slate-700/60 border-slate-600/40' : 'bg-white border-gray-100'}`}>
|
||||
{trace.answerMarkdown ? (
|
||||
<MarkdownRenderer
|
||||
content={trace.answerMarkdown}
|
||||
className={`text-sm ${isDark ? 'text-slate-200' : 'text-gray-700'}`}
|
||||
onCitationClick={onCitationClick}
|
||||
/>
|
||||
) : (
|
||||
<div className={`text-sm flex items-center gap-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{trace.thinkingComplete ? '正在流式生成回答...' : '思考结束后开始生成回答...'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface RetrievalListProps {
|
||||
items: QARetrievalItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 检索结果列表。
|
||||
*
|
||||
* 每条资料默认折叠,首条默认展开,便于用户快速看到最相关资料,同时保留完整资料明细。
|
||||
*/
|
||||
const RetrievalList: React.FC<RetrievalListProps> = ({ items }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const [visible, setVisible] = useState(false); // 打开详情对话框
|
||||
const [listData, setListData] = useState<QARetrievalItem>({
|
||||
title: '',
|
||||
content: '',
|
||||
});
|
||||
const knowledgeUrl = readRuntimeEnv('VITE_AIPI_KNOWLEDGE_URL');
|
||||
const b1SearchUrl = readRuntimeEnv('VITE_AIPI_B1COH_URL');
|
||||
const b2SearchUrl = readRuntimeEnv('VITE_AIPI_B2ACTOR_URL');
|
||||
const showDialog = (item: QARetrievalItem) => {
|
||||
console.log('item', item)
|
||||
if (item.type === 'more') {
|
||||
if (item.recUuid) {
|
||||
window.open(`${knowledgeUrl}/#/knowledge/mid-detail?recUuid=${item.recUuid}`);
|
||||
} else {
|
||||
setListData(item);
|
||||
setVisible(true);
|
||||
}
|
||||
} else if (item.type === 'task') {
|
||||
window.open(`${b1SearchUrl}/#/task/item-aicoh/step-info-search?id=${item.id}`)
|
||||
} else if (item.type === 'hx') {
|
||||
window.open(`${b2SearchUrl}/fromB/toDetail?user_id=${item.id}&platform=${item.platform}`);
|
||||
} else {
|
||||
setListData(item);
|
||||
setVisible(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{items.map((item, index) => (
|
||||
<div key={index}>
|
||||
{
|
||||
item.type !== 'user_behavior' ? <CollapsibleSection
|
||||
key={index}
|
||||
defaultOpen={index === 0}
|
||||
className={isDark ? 'bg-slate-800/40' : 'bg-gray-50/70'}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-6 h-6 rounded-lg flex items-center justify-center text-xs font-semibold ${isDark ? 'bg-blue-500/20 text-blue-300' : 'bg-red-100 text-red-600'
|
||||
}`}>{index + 1}</span>
|
||||
<div className="min-w-0">
|
||||
<div className={`text-sm font-medium truncate ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>{item.title}</div>
|
||||
{
|
||||
item.type === 'more' && <div className={`text-xs truncate ${isDark ? 'text-slate-500' : 'text-gray-500'}`}>时间:{item.time || '-'}</div>
|
||||
}
|
||||
{
|
||||
item.type === 'task' && <div className={`text-xs truncate ${isDark ? 'text-slate-500' : 'text-gray-500'}`}>关键词:{item.keywords || '-'}</div>
|
||||
}
|
||||
{
|
||||
item.type === 'hx' && <div className={`text-xs truncate ${isDark ? 'text-slate-500' : 'text-gray-500'}`}><span>平台:{item.platform || '-'}</span><span className="ml-[10px]">粉丝数:{item.fans || '-'}</span></div>
|
||||
}
|
||||
{
|
||||
(item.type === 'taiwan_news' || item.type === 'world_news' || item.type === 'messaage_comment' || item.type === 'america_news') && <div className={`text-xs truncate ${isDark ? 'text-slate-500' : 'text-gray-500'}`}><span>平台:{item.platform || '-'}</span>{item.comment_count && <span className="ml-[10px]">评论数:{item.comment_count || '-'}</span>}</div>
|
||||
}
|
||||
{
|
||||
item.type === 'get_time_data' && <div className={`text-xs truncate ${isDark ? 'text-slate-500' : 'text-gray-500'}`}><span>时间:{item.time || '-'}</span><span className="ml-[10px]">热度:{item.hot_num || '-'}</span></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<p className={`text-sm leading-relaxed cursor-progress line-clamp-3 hover:underline ${isDark ? 'text-slate-300' : 'text-gray-600'}`} onClick={() => showDialog(item)}>{item.content}</p>
|
||||
</CollapsibleSection>
|
||||
: <div className="rounded-[5px] border border-[#f5f5f5] p-[10px]" key={index + 10}>
|
||||
<span className="font-bold mr-[10px]">{index + 1}.</span>
|
||||
<span>{item.title}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
))}
|
||||
<Dialog
|
||||
visible={visible}
|
||||
onClose={() => setVisible(false)}
|
||||
header="提示"
|
||||
footer={null}
|
||||
>
|
||||
<div>
|
||||
<div className="pr-9">
|
||||
<h3 className="text-base font-semibold">{listData.title}</h3>
|
||||
<div className={`mt-2 text-xs space-y-1 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
{listData.time && <span>时间:{listData.time || '未知'}</span>}
|
||||
{listData.platform && <span className="ml-[10px]">平台:{listData.platform || '未知'}</span>}
|
||||
{listData.comment_count && <span className="ml-[10px]">评论数:{listData.comment_count || '未知'}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`mt-4 rounded-xl p-3 text-sm leading-relaxed whitespace-pre-wrap ${isDark ? 'bg-slate-800/70 text-slate-200' : 'bg-gray-50 text-gray-700'
|
||||
}`}>
|
||||
{listData.content || '暂无引用内容'}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SearchQueryBadgeProps {
|
||||
query: string;
|
||||
loading?: boolean;
|
||||
queryTitle?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检索词徽标。
|
||||
*
|
||||
* 多轮检索每一轮都会产生一个 query;loading 状态下展示旋转图标,表示该 query 正在召回资料。
|
||||
*/
|
||||
const SearchQueryBadge: React.FC<SearchQueryBadgeProps> = ({ query, loading = false, queryTitle = '检索词' }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
return (
|
||||
<div className={`inline-flex max-w-full items-center gap-2 rounded-full px-3 py-1.5 border ${isDark ? 'bg-blue-500/10 border-blue-400/20 text-blue-200' : 'bg-red-50 border-red-100 text-red-600'
|
||||
}`}>
|
||||
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}
|
||||
<span className="text-xs flex-shrink-0">{queryTitle}</span>
|
||||
<span className={`text-xs font-medium truncate ${loading && 'qa-generating-marquee'} ${loading && (isDark ? 'qa-generating-marquee-dark' : 'qa-generating-marquee-light')}`}>
|
||||
{query}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface RetrievalThinkingTraceProps {
|
||||
trace: QAThinkingTrace;
|
||||
onCitationClick?: (citationIndex: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 多轮/深度检索思考链展示。
|
||||
*
|
||||
* 组件按轮次渲染思考内容、检索词和检索结果,底部展示最终回答。
|
||||
* 它只消费已经整理好的 trace 数据,不关心数据来自真实 SSE 还是本地模拟。
|
||||
*/
|
||||
export const RetrievalThinkingTrace: React.FC<RetrievalThinkingTraceProps> = ({ trace, onCitationClick }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
return (
|
||||
<div className="w-full min-w-0 max-w-5xl space-y-4 overflow-hidden">
|
||||
<CollapsibleSection
|
||||
defaultOpen={!trace.thinkingComplete}
|
||||
className={isDark ? 'bg-slate-800/60 shadow-lg shadow-black/10' : 'bg-white shadow-sm'}
|
||||
title={
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-9 h-9 rounded-2xl flex items-center justify-center ${isDark ? 'bg-cyan-500/15 text-cyan-300' : 'bg-red-50 text-red-500'
|
||||
}`}>
|
||||
<BrainCircuit className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className={`text-sm font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
{trace.title || '思考过程'}
|
||||
</div>
|
||||
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
共 {trace.rounds.length} 轮推理与检索,可展开查看细节
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3 pt-1">
|
||||
{trace.rounds.map((round, index) => (
|
||||
<div key={index} className={`relative pl-5 ${index < trace.rounds.length - 1 ? 'pb-2' : ''}`}>
|
||||
{index < trace.rounds.length - 1 && (
|
||||
<div className={`absolute left-[7px] top-8 bottom-0 w-px ${isDark ? 'bg-slate-700' : 'bg-red-100'}`} />
|
||||
)}
|
||||
<div className={`absolute left-0 top-4 w-3.5 h-3.5 rounded-full border-2 ${isDark ? 'bg-slate-900 border-cyan-400' : 'bg-white border-red-400'
|
||||
}`} />
|
||||
<CollapsibleSection
|
||||
defaultOpen
|
||||
className={isDark ? 'bg-slate-900/40' : 'bg-red-50/30'}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${isDark ? 'bg-cyan-500/15 text-cyan-300' : 'bg-red-100 text-red-600'
|
||||
}`}>第 {index + 1} 轮</span>
|
||||
<span className={`text-sm font-medium ${round.queryLoading && 'qa-generating-marquee'} ${round.queryLoading && (isDark ? 'text-slate-100 qa-generating-marquee-dark' : 'text-gray-900 qa-generating-marquee-light')}`}>{round.title}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<CollapsibleSection
|
||||
defaultOpen
|
||||
className={isDark ? 'bg-slate-800/50' : 'bg-white/80'}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className={`w-4 h-4 ${isDark ? 'text-violet-300' : 'text-orange-500'}`} />
|
||||
<span className={`text-sm font-medium ${isDark ? 'text-slate-100' : 'text-gray-800'}`}>思考内容</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<MarkdownRenderer
|
||||
content={round.thoughtMarkdown}
|
||||
className={`text-sm ${isDark ? 'text-slate-300' : 'text-gray-700'}`}
|
||||
onCitationClick={onCitationClick}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
{
|
||||
round.queryShow &&
|
||||
<div className="flex items-center">
|
||||
<SearchQueryBadge query={round.query} queryTitle={round.queryTitle} loading={round.queryLoading} />
|
||||
</div>
|
||||
}
|
||||
{
|
||||
round.queryShow &&
|
||||
<CollapsibleSection
|
||||
defaultOpen={!round.queryLoading}
|
||||
className={isDark ? 'bg-slate-800/50' : 'bg-white/80'}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<FileSearch className={`w-4 h-4 ${isDark ? 'text-blue-300' : 'text-red-500'}`} />
|
||||
<span className={`text-sm font-medium ${isDark ? 'text-slate-100' : 'text-gray-800'}`}>检索内容</span>
|
||||
<span className={`text-xs ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{round.retrievals.length} 条</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{round.queryLoading ? (
|
||||
<div className={`rounded-xl px-3 py-4 text-sm flex items-center gap-2 ${isDark ? 'bg-slate-900/60 text-slate-400' : 'bg-gray-50 text-gray-500'}`}>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
正在召回相关内容...
|
||||
</div>
|
||||
) : (
|
||||
<RetrievalList items={round.retrievals} />
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
}
|
||||
{
|
||||
round.textidContent &&
|
||||
<div className="mt-[10px]">
|
||||
{round.textidContent}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<div className={`rounded-2xl border p-4 ${isDark ? 'bg-slate-700/60 border-slate-600/40' : 'bg-white border-gray-100'}`}>
|
||||
<div className={`text-sm font-semibold mb-3 ${isDark ? 'text-white' : 'text-gray-900'}`}>最终回答</div>
|
||||
{
|
||||
!trace.finalAnswerMarkdown && <div className={`rounded-xl px-3 py-4 text-sm flex items-center gap-2 ${isDark ? 'bg-slate-900/60 text-slate-400' : 'bg-gray-50 text-gray-500'}`}>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
正在生成思考过程与检索内容...
|
||||
</div>
|
||||
}
|
||||
<MarkdownRenderer
|
||||
content={trace.finalAnswerMarkdown}
|
||||
className={`text-sm ${isDark ? 'text-slate-200' : 'text-gray-700'}`}
|
||||
onCitationClick={onCitationClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface EmptyChatStateProps {
|
||||
icon?: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 空状态展示。
|
||||
*
|
||||
* 当当前模式没有任何消息时使用,提示用户可以开始输入问题。
|
||||
*/
|
||||
export const EmptyChatState: React.FC<EmptyChatStateProps> = ({ icon, title, description }) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
return (
|
||||
<div className={`h-full flex flex-col items-center justify-center ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
|
||||
<div className={`w-20 h-20 rounded-3xl mb-5 flex items-center justify-center ${isDark ? 'bg-slate-700/40' : 'bg-gradient-to-br from-red-50 to-orange-50'
|
||||
}`}>
|
||||
{icon ?? <Bot className="w-11 h-11 opacity-60" />}
|
||||
</div>
|
||||
<p className="text-lg font-medium">{title}</p>
|
||||
<p className="text-sm mt-2">{description}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface QAComposerProps {
|
||||
value: string;
|
||||
placeholder: string;
|
||||
isLoading: boolean;
|
||||
canSubmit?: boolean;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
onStop: () => void;
|
||||
onKeyDown?: (val: string) => void;
|
||||
onUpload?: () => void;
|
||||
onReset?: () => void;
|
||||
onSave?: () => void;
|
||||
onCollect?: () => void;
|
||||
onOpenSettings?: () => void;
|
||||
showUpload?: boolean;
|
||||
showReset?: boolean;
|
||||
showSave?: boolean;
|
||||
showCollect?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 问答输入区。
|
||||
*
|
||||
* 负责输入、发送/停止、附件、重置、保存、收藏和模型参数设置入口。
|
||||
* 具体业务动作通过 props 回调交给父组件处理,保证输入区本身不依赖某个问答模式。
|
||||
*/
|
||||
export const QAComposer: React.FC<QAComposerProps> = ({
|
||||
value,
|
||||
placeholder,
|
||||
isLoading,
|
||||
canSubmit,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onStop,
|
||||
onKeyDown,
|
||||
onUpload,
|
||||
onReset,
|
||||
onSave,
|
||||
onCollect,
|
||||
onOpenSettings,
|
||||
showUpload = true,
|
||||
showReset = false,
|
||||
showSave = true,
|
||||
showCollect = true,
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const submitEnabled = canSubmit ?? Boolean(value.trim());
|
||||
const iconButtonClass = `p-2 rounded-lg transition-colors ${isDark ? 'hover:bg-slate-600 text-slate-400' : 'hover:bg-gray-200 text-gray-500'}`;
|
||||
const handleKeyDown = (val: string, context: { e: React.KeyboardEvent }) => {
|
||||
if (context?.e?.key === 'Enter') {
|
||||
context.e.preventDefault();
|
||||
onKeyDown?.(val);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className={`border-t p-4 ${isDark ? 'border-slate-700/50' : 'border-red-100'}`}>
|
||||
<div className={`flex items-end gap-3 rounded-2xl p-3 shadow-inner ${isDark ? 'bg-slate-700/50' : 'bg-gray-100'}`}>
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(nextValue) => onChange(String(nextValue))}
|
||||
onKeydown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
autosize={{ minRows: 1, maxRows: 15 }}
|
||||
disabled={isLoading}
|
||||
className={`flex-1 !bg-transparent !border-0 !shadow-none qa-composer-textarea [&]:!border-0 [&]:!shadow-none [&]:!outline-none [&]:focus:!border-0 [&]:focus:!shadow-none [&]:focus:!outline-none [&_.t-textarea]:!border-0 [&_.t-textarea]:!shadow-none [&_.t-textarea]:!outline-none [&_.t-textarea:hover]:!border-0 [&_.t-textarea:hover]:!shadow-none [&_.t-textarea--focused]:!border-0 [&_.t-textarea--focused]:!shadow-none [&_.t-is-focused]:!border-0 [&_.t-is-focused]:!shadow-none [&_.t-textarea__inner]:!bg-transparent [&_.t-textarea__inner]:!border-0 [&_.t-textarea__inner]:!shadow-none [&_.t-textarea__inner]:!outline-none [&_.t-textarea__inner]:!resize-none [&_.t-textarea__inner]:!min-h-[40px] [&_.t-textarea__inner]:!p-0 [&_.t-textarea__inner:hover]:!border-0 [&_.t-textarea__inner:hover]:!shadow-none [&_.t-textarea__inner:focus]:!border-0 [&_.t-textarea__inner:focus]:!shadow-none [&_.t-textarea__inner:focus]:!outline-none [&_.t-textarea__inner]:disabled:!cursor-not-allowed ${isDark
|
||||
? '[&_.t-textarea__inner]:!text-white [&_.t-textarea__inner]:placeholder:!text-slate-500'
|
||||
: '[&_.t-textarea__inner]:!text-gray-800 [&_.t-textarea__inner]:placeholder:!text-gray-400'
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* {showUpload && (
|
||||
<button onClick={onUpload} className={iconButtonClass} title="上传附件" type="button">
|
||||
<Upload className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
{showReset && (
|
||||
<button onClick={onReset} className={iconButtonClass} title="重新提问" type="button">
|
||||
<RotateCcw className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
{showSave && (
|
||||
<button onClick={onSave} className={iconButtonClass} title="保存记录" type="button">
|
||||
<Bookmark className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
{showCollect && (
|
||||
<button onClick={onCollect} className={iconButtonClass} title="加入收藏" type="button">
|
||||
<Bookmark className="w-5 h-5" />
|
||||
</button>
|
||||
)} */}
|
||||
{onOpenSettings && (
|
||||
<button onClick={onOpenSettings} className={iconButtonClass} title="模型参数设置" type="button">
|
||||
<Settings className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={isLoading ? onStop : onSubmit}
|
||||
disabled={!isLoading && !submitEnabled}
|
||||
className={`px-4 py-2 rounded-xl font-medium transition-all flex items-center gap-2 shadow-sm ${isLoading
|
||||
? isDark
|
||||
? 'bg-rose-600 text-white qa-stop-breathing'
|
||||
: 'bg-red-600 text-white qa-stop-breathing'
|
||||
: submitEnabled
|
||||
? isDark
|
||||
? 'bg-blue-600 hover:bg-blue-700 text-white'
|
||||
: 'bg-red-500 hover:bg-red-600 text-white'
|
||||
: isDark
|
||||
? 'bg-slate-600 text-slate-400 cursor-not-allowed'
|
||||
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||
}`}
|
||||
type="button"
|
||||
>
|
||||
{isLoading ? <Square className="w-4 h-4 fill-current" /> : <Send className="w-4 h-4" />}
|
||||
{isLoading ? '停止' : '发送'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user