Commit 13ea6f71 by Xianquan Committed by GitHub

refactor(chat): split ChatBox into hooks, utils and UI modules (#6966)

* refactor: extract ChatBox hooks and components

Split ChatBox/index.tsx into hooks, utilities, and UI components while
keeping the public API unchanged. Includes main #6977 stop chat overrides,
mark-read hooks, and quote list type updates.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: make requestVariables time test timezone-agnostic

Use formatTime2YMDHMS for expected values so CI in UTC matches local runs.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor: organize ChatBox utility modules

* fix: keep active chat refs in sync before resume callbacks

* test: cover ChatBox utility modules

* perf: ui

* test: stabilize workflow benchmark scaling check

* fix: reason render

* fix: ts

* submodule

* doc

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: archer <545436317@qq.com>
parent 1f600c2d
...@@ -11,6 +11,10 @@ description: 'FastGPT V4.15.0-beta3 更新说明' ...@@ -11,6 +11,10 @@ description: 'FastGPT V4.15.0-beta3 更新说明'
## 🐛 修复 ## 🐛 修复
1. TTS 语音播放适配最新 OpenAI SDK,避免报错。
## 🛠️ 代码优化 ## 🛠️ 代码优化
1. 调整 token 计算依赖,提高性能。 1. 调整 token 计算依赖,提高性能。
2. 重写了对话框相关代码,进行模块化细分。
3. 优化单测性能,全量从 10 分支将至 5 分钟。
...@@ -125,8 +125,8 @@ ...@@ -125,8 +125,8 @@
"content/guide/getting-started/quick-start.mdx": "2026-05-07T15:06:40+08:00", "content/guide/getting-started/quick-start.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/index.en.mdx": "2026-05-07T15:06:40+08:00", "content/guide/index.en.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/index.mdx": "2026-05-07T15:06:40+08:00", "content/guide/index.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/version/cloud/faq.en.mdx": "2026-05-07T15:06:40+08:00", "content/guide/version/cloud/faq.en.mdx": "2026-05-25T18:16:39+08:00",
"content/guide/version/cloud/faq.mdx": "2026-05-07T15:06:40+08:00", "content/guide/version/cloud/faq.mdx": "2026-05-25T18:16:39+08:00",
"content/guide/version/cloud/intro.en.mdx": "2026-05-07T15:06:40+08:00", "content/guide/version/cloud/intro.en.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/version/cloud/intro.mdx": "2026-05-07T15:06:40+08:00", "content/guide/version/cloud/intro.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/version/cloud/privacy.en.mdx": "2026-05-07T15:06:40+08:00", "content/guide/version/cloud/privacy.en.mdx": "2026-05-07T15:06:40+08:00",
...@@ -281,7 +281,7 @@ ...@@ -281,7 +281,7 @@
"content/self-host/upgrading/4-15/41502.en.mdx": "2026-05-25T11:21:30+08:00", "content/self-host/upgrading/4-15/41502.en.mdx": "2026-05-25T11:21:30+08:00",
"content/self-host/upgrading/4-15/41502.mdx": "2026-05-25T11:21:30+08:00", "content/self-host/upgrading/4-15/41502.mdx": "2026-05-25T11:21:30+08:00",
"content/self-host/upgrading/4-15/41503.en.mdx": "2026-05-25T11:21:30+08:00", "content/self-host/upgrading/4-15/41503.en.mdx": "2026-05-25T11:21:30+08:00",
"content/self-host/upgrading/4-15/41503.mdx": "2026-05-25T11:21:30+08:00", "content/self-host/upgrading/4-15/41503.mdx": "2026-05-26T10:59:48+08:00",
"content/self-host/upgrading/outdated/40.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/40.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/40.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/40.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/41.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/41.en.mdx": "2026-04-26T21:08:47+08:00",
......
...@@ -6,6 +6,22 @@ import { TypeTable } from 'fumadocs-ui/components/type-table'; ...@@ -6,6 +6,22 @@ import { TypeTable } from 'fumadocs-ui/components/type-table';
import { LocalizedLink } from '@/components/docs/LocalizedLink'; import { LocalizedLink } from '@/components/docs/LocalizedLink';
import type { ComponentProps } from 'react'; import type { ComponentProps } from 'react';
/**
* 兼容 Fumadocs/MDX 对本地图片的静态资源对象输出。
* 原生 img 只能接收字符串 src;如果直接透传对象,浏览器会请求 [object Object]。
*/
function getMdxImageSrc(src: unknown): string | undefined {
if (typeof src === 'string') return src;
if (src && typeof src === 'object') {
const image = 'default' in src ? src.default : src;
if (image && typeof image === 'object' && 'src' in image && typeof image.src === 'string') {
return image.src;
}
}
}
function MdxImage(props: ComponentProps<'img'>) { function MdxImage(props: ComponentProps<'img'>) {
const hasWidth = props.width !== undefined && props.width !== null && props.width !== ''; const hasWidth = props.width !== undefined && props.width !== null && props.width !== '';
const hasHeight = props.height !== undefined && props.height !== null && props.height !== ''; const hasHeight = props.height !== undefined && props.height !== null && props.height !== '';
...@@ -14,7 +30,14 @@ function MdxImage(props: ComponentProps<'img'>) { ...@@ -14,7 +30,14 @@ function MdxImage(props: ComponentProps<'img'>) {
return <ImageZoom {...(props as any)} />; return <ImageZoom {...(props as any)} />;
} }
return <img {...props} alt={props.alt ?? ''} loading={props.loading ?? 'lazy'} />; return (
<img
{...props}
src={getMdxImageSrc(props.src)}
alt={props.alt ?? ''}
loading={props.loading ?? 'lazy'}
/>
);
} }
// use this function to get MDX components, you will need it for rendering MDX // use this function to get MDX components, you will need it for rendering MDX
......
...@@ -888,7 +888,6 @@ export class WorkflowQueue { ...@@ -888,7 +888,6 @@ export class WorkflowQueue {
}); });
} }
const startTime = Date.now(); const startTime = Date.now();
// get node running params // get node running params
const params = getNodeRunParams(node); const params = getNodeRunParams(node);
...@@ -1648,16 +1647,40 @@ export const runWorkflow = async (data: RunWorkflowProps): Promise<DispatchFlowR ...@@ -1648,16 +1647,40 @@ export const runWorkflow = async (data: RunWorkflowProps): Promise<DispatchFlowR
); );
}; };
/* Merge consecutive text messages into one */ /**
const mergeAssistantResponseAnswerText = (response: AIChatItemValueItemType[]) => { * 合并连续的纯文本 assistant value,减少普通 answer 片段在页面上分裂展示。
*
* 带 reasoning、工具、交互、计划等结构化信息的 value 必须保留独立边界;否则连续
* AI 节点的第二段 reasoning 会在合并 text 时被吞掉,刷新历史后页面看不到对应思考。
*/
export const mergeAssistantResponseAnswerText = (response: AIChatItemValueItemType[]) => {
const result: AIChatItemValueItemType[] = []; const result: AIChatItemValueItemType[] = [];
const isPlainTextValue = (item: AIChatItemValueItemType) =>
!!item.text &&
!item.id &&
!item.planId &&
!item.reasoning &&
!item.tools &&
!item.skills &&
!item.interactive &&
!item.plan &&
!item.planStatus &&
!item.agentPlanUpdate &&
!item.agentAsk &&
!item.agentStopGate &&
!item.contextCheckpoint &&
!item.tool &&
!item.hideReason &&
!item.hideInUI;
// 合并连续的text // 合并连续的text
for (let i = 0; i < response.length; i++) { for (let i = 0; i < response.length; i++) {
const item = response[i]; const item = response[i];
if (item.text) { if (isPlainTextValue(item)) {
const text = item.text?.content || ''; const text = item.text?.content || '';
const lastItem = result[result.length - 1]; const lastItem = result[result.length - 1];
if (lastItem && lastItem.text?.content) { if (lastItem && isPlainTextValue(lastItem) && lastItem.text?.content) {
lastItem.text.content += text; lastItem.text.content += text;
continue; continue;
} }
......
...@@ -47,7 +47,6 @@ export type DispatchFlowResponse = { ...@@ -47,7 +47,6 @@ export type DispatchFlowResponse = {
const WorkflowResponseItemSchema = z.object({ const WorkflowResponseItemSchema = z.object({
id: z.string().optional(), id: z.string().optional(),
stepId: z.string().optional(),
event: z.custom<SseResponseEventEnum>().optional(), event: z.custom<SseResponseEventEnum>().optional(),
data: z.union([z.string(), z.looseObject({})]) data: z.union([z.string(), z.looseObject({})])
}); });
......
...@@ -16,7 +16,6 @@ import { ...@@ -16,7 +16,6 @@ import {
DispatchNodeResponseKeyEnum, DispatchNodeResponseKeyEnum,
SseResponseEventEnum SseResponseEventEnum
} from '@fastgpt/global/core/workflow/runtime/constants'; } from '@fastgpt/global/core/workflow/runtime/constants';
import { getNanoid } from '@fastgpt/global/common/string/tools';
import { type SearchDataResponseItemType } from '@fastgpt/global/core/dataset/type'; import { type SearchDataResponseItemType } from '@fastgpt/global/core/dataset/type';
import { getMCPToolRuntimeNode } from '@fastgpt/global/core/app/tool/mcpTool/utils'; import { getMCPToolRuntimeNode } from '@fastgpt/global/core/app/tool/mcpTool/utils';
import { import {
...@@ -40,7 +39,6 @@ export const getWorkflowResponseWrite = ({ ...@@ -40,7 +39,6 @@ export const getWorkflowResponseWrite = ({
res, res,
detail, detail,
streamResponse, streamResponse,
id = getNanoid(24),
showNodeStatus = true, showNodeStatus = true,
streamResumeMirror streamResumeMirror
}: { }: {
...@@ -69,7 +67,7 @@ export const getWorkflowResponseWrite = ({ ...@@ -69,7 +67,7 @@ export const getWorkflowResponseWrite = ({
}); });
}; };
const fn: WorkflowResponseType = ({ id, stepId, event, data }) => { const fn: WorkflowResponseType = ({ id, event, data }) => {
if (typeof data === 'string') { if (typeof data === 'string') {
writeStreamChunk({ event, data }); writeStreamChunk({ event, data });
return; return;
...@@ -98,7 +96,6 @@ export const getWorkflowResponseWrite = ({ ...@@ -98,7 +96,6 @@ export const getWorkflowResponseWrite = ({
event: detail ? event : undefined, event: detail ? event : undefined,
data: JSON.stringify({ data: JSON.stringify({
...data, ...data,
...(stepId && detail && { stepId }),
...(id && detail && { responseValueId: id }) ...(id && detail && { responseValueId: id })
}) })
}); });
...@@ -107,16 +104,17 @@ export const getWorkflowResponseWrite = ({ ...@@ -107,16 +104,17 @@ export const getWorkflowResponseWrite = ({
}; };
export const getWorkflowChildResponseWrite = ({ export const getWorkflowChildResponseWrite = ({
id, id,
stepId,
fn fn
}: { }: {
id: string; id: string;
stepId: string;
fn?: WorkflowResponseType; fn?: WorkflowResponseType;
}): WorkflowResponseType | undefined => { }): WorkflowResponseType | undefined => {
if (!fn) return; if (!fn) return;
return (e: Parameters<WorkflowResponseType>[0]) => { return (e: Parameters<WorkflowResponseType>[0]) => {
return fn({ ...e, id, stepId }); return fn({
...e,
id: e.id || id
});
}; };
}; };
......
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"test": "vitest run -c vitest.config.ts", "test": "vitest run -c vitest.config.ts",
"test:benchmark": "vitest run -c vitest.benchmark.config.ts",
"test:watch": "vitest -c vitest.config.ts", "test:watch": "vitest -c vitest.config.ts",
"test:integration": "vitest run -c vitest.integration.config.ts", "test:integration": "vitest run -c vitest.integration.config.ts",
"test:integration:watch": "vitest -c vitest.integration.config.ts" "test:integration:watch": "vitest -c vitest.integration.config.ts"
......
...@@ -152,9 +152,19 @@ function generateComplexWorkflow(nodeCount: number) { ...@@ -152,9 +152,19 @@ function generateComplexWorkflow(nodeCount: number) {
} }
// 性能测试辅助函数 // 性能测试辅助函数
function measurePerformance(name: string, fn: () => void, iterations: number = 1) { function measurePerformance(
name: string,
fn: () => void,
iterations: number = 1,
warmups: number = 0
) {
const times: number[] = []; const times: number[] = [];
// 预热可以避开 JIT/模块首次执行带来的抖动,让 CI 上的性能断言更关注稳定路径。
for (let i = 0; i < warmups; i++) {
fn();
}
for (let i = 0; i < iterations; i++) { for (let i = 0; i < iterations; i++) {
const start = performance.now(); const start = performance.now();
fn(); fn();
...@@ -165,8 +175,12 @@ function measurePerformance(name: string, fn: () => void, iterations: number = 1 ...@@ -165,8 +175,12 @@ function measurePerformance(name: string, fn: () => void, iterations: number = 1
const avg = times.reduce((a, b) => a + b, 0) / times.length; const avg = times.reduce((a, b) => a + b, 0) / times.length;
const min = Math.min(...times); const min = Math.min(...times);
const max = Math.max(...times); const max = Math.max(...times);
const sortedTimes = [...times].sort((a, b) => a - b);
const mid = Math.floor(sortedTimes.length / 2);
const median =
sortedTimes.length % 2 === 0 ? (sortedTimes[mid - 1] + sortedTimes[mid]) / 2 : sortedTimes[mid];
return { avg, min, max, times }; return { avg, min, max, median, times };
} }
describe('Workflow Performance Benchmark', () => { describe('Workflow Performance Benchmark', () => {
...@@ -426,11 +440,12 @@ describe('Workflow Performance Benchmark', () => { ...@@ -426,11 +440,12 @@ describe('Workflow Performance Benchmark', () => {
edgeIndex edgeIndex
}); });
}, },
30,
5 5
); );
results.push({ scale, time: result.avg }); results.push({ scale, time: result.median });
console.log(`${scale}\t${nodes.length}\t${edges.length}\t${result.avg.toFixed(3)}`); console.log(`${scale}\t${nodes.length}\t${edges.length}\t${result.median.toFixed(3)}`);
} }
// 验证时间复杂度接近线性 // 验证时间复杂度接近线性
......
...@@ -3,7 +3,10 @@ import { EventEmitter } from 'node:events'; ...@@ -3,7 +3,10 @@ import { EventEmitter } from 'node:events';
import { createServer } from 'node:http'; import { createServer } from 'node:http';
import type { AddressInfo } from 'node:net'; import type { AddressInfo } from 'node:net';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { WorkflowQueue } from '@fastgpt/service/core/workflow/dispatch/index'; import {
WorkflowQueue,
mergeAssistantResponseAnswerText
} from '@fastgpt/service/core/workflow/dispatch/index';
import { createClientAbortTracker } from '@fastgpt/service/core/workflow/dispatch/utils/clientAbort'; import { createClientAbortTracker } from '@fastgpt/service/core/workflow/dispatch/utils/clientAbort';
import { createNode, createEdge } from '../utils'; import { createNode, createEdge } from '../utils';
...@@ -26,6 +29,41 @@ const waitWithTimeout = async <T>(promise: Promise<T>, timeoutMs: number, label: ...@@ -26,6 +29,41 @@ const waitWithTimeout = async <T>(promise: Promise<T>, timeoutMs: number, label:
} }
}; };
describe('mergeAssistantResponseAnswerText', () => {
it('should merge consecutive plain text assistant values', () => {
expect(
mergeAssistantResponseAnswerText([
{ text: { content: 'first' } },
{ text: { content: ' second' } }
])
).toEqual([{ text: { content: 'first second' } }]);
});
it('should preserve reasoning boundaries for consecutive AI chat nodes', () => {
expect(
mergeAssistantResponseAnswerText([
{
reasoning: { content: 'think 1' },
text: { content: 'answer 1' }
},
{
reasoning: { content: 'think 2' },
text: { content: 'answer 2' }
}
])
).toEqual([
{
reasoning: { content: 'think 1' },
text: { content: 'answer 1' }
},
{
reasoning: { content: 'think 2' },
text: { content: 'answer 2' }
}
]);
});
});
describe('createClientAbortTracker', () => { describe('createClientAbortTracker', () => {
const mockRes = (overrides: Record<string, any> = {}) => { const mockRes = (overrides: Record<string, any> = {}) => {
const res = new EventEmitter() as any; const res = new EventEmitter() as any;
......
...@@ -145,7 +145,7 @@ describe('getWorkflowResponseWrite', () => { ...@@ -145,7 +145,7 @@ describe('getWorkflowResponseWrite', () => {
expect(responseWrite).not.toHaveBeenCalled(); expect(responseWrite).not.toHaveBeenCalled();
}); });
it('should include stepId and responseValueId when detail is true', () => { it('should include responseValueId when detail is true', () => {
const res = mockRes(); const res = mockRes();
vi.mocked(responseWrite).mockClear(); vi.mocked(responseWrite).mockClear();
const fn = getWorkflowResponseWrite({ const fn = getWorkflowResponseWrite({
...@@ -154,7 +154,7 @@ describe('getWorkflowResponseWrite', () => { ...@@ -154,7 +154,7 @@ describe('getWorkflowResponseWrite', () => {
streamResponse: true, streamResponse: true,
id: 'test-id' id: 'test-id'
}); });
fn({ id: 'rid', stepId: 'sid', event: SseResponseEventEnum.answer, data: { text: 'hi' } }); fn({ id: 'rid', event: SseResponseEventEnum.answer, data: { text: 'hi' } });
expect(responseWrite).toHaveBeenCalledWith( expect(responseWrite).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
res, res,
...@@ -162,7 +162,6 @@ describe('getWorkflowResponseWrite', () => { ...@@ -162,7 +162,6 @@ describe('getWorkflowResponseWrite', () => {
}) })
); );
const callData = JSON.parse(vi.mocked(responseWrite).mock.calls[0][0].data as string); const callData = JSON.parse(vi.mocked(responseWrite).mock.calls[0][0].data as string);
expect(callData.stepId).toBe('sid');
expect(callData.responseValueId).toBe('rid'); expect(callData.responseValueId).toBe('rid');
}); });
...@@ -201,15 +200,14 @@ describe('getWorkflowResponseWrite', () => { ...@@ -201,15 +200,14 @@ describe('getWorkflowResponseWrite', () => {
describe('getWorkflowChildResponseWrite', () => { describe('getWorkflowChildResponseWrite', () => {
it('should return undefined when fn is undefined', () => { it('should return undefined when fn is undefined', () => {
const result = getWorkflowChildResponseWrite({ id: 'id', stepId: 'step' }); const result = getWorkflowChildResponseWrite({ id: 'id' });
expect(result).toBeUndefined(); expect(result).toBeUndefined();
}); });
it('should return a wrapper function that passes id and stepId', () => { it('should return a wrapper function that passes id', () => {
const mockFn = vi.fn(); const mockFn = vi.fn();
const wrapped = getWorkflowChildResponseWrite({ const wrapped = getWorkflowChildResponseWrite({
id: 'child-id', id: 'child-id',
stepId: 'child-step',
fn: mockFn as any fn: mockFn as any
}); });
expect(wrapped).toBeDefined(); expect(wrapped).toBeDefined();
...@@ -220,12 +218,32 @@ describe('getWorkflowChildResponseWrite', () => { ...@@ -220,12 +218,32 @@ describe('getWorkflowChildResponseWrite', () => {
expect(mockFn).toHaveBeenCalledWith( expect(mockFn).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
id: 'child-id', id: 'child-id',
stepId: 'child-step',
event: SseResponseEventEnum.answer, event: SseResponseEventEnum.answer,
data: { text: 'hi' } data: { text: 'hi' }
}) })
); );
}); });
it('should preserve explicit child event id', () => {
const mockFn = vi.fn();
const wrapped = getWorkflowChildResponseWrite({
id: 'node-response-id',
fn: mockFn as any
});
wrapped!({
id: 'tool-call-id',
event: SseResponseEventEnum.toolCall,
data: { tool: { id: 'tool-call-id' } }
});
expect(mockFn).toHaveBeenCalledWith(
expect.objectContaining({
id: 'tool-call-id',
event: SseResponseEventEnum.toolCall
})
);
});
}); });
describe('filterOrphanEdges', () => { describe('filterOrphanEdges', () => {
......
import { defineConfig } from 'vitest/config';
import baseConfig from './vitest.config';
export default defineConfig({
...baseConfig,
test: {
...baseConfig.test,
coverage: {
...baseConfig.test?.coverage,
enabled: false
},
fileParallelism: false,
include: ['test/**/*.benchmark.ts'],
maxWorkers: 1,
outputFile: 'benchmark-results.json'
}
});
...@@ -43,6 +43,30 @@ const selectedTagStyle: FlexProps = { ...@@ -43,6 +43,30 @@ const selectedTagStyle: FlexProps = {
color: 'myGray.900' color: 'myGray.900'
}; };
const selectedTagSizeStyleMap: Record<
MySelectSize,
Pick<FlexProps, 'fontSize' | 'minH' | 'px' | 'py'>
> = {
sm: {
fontSize: 'xs',
minH: 5,
px: 2,
py: 0.5
},
md: {
fontSize: 'sm',
minH: 6,
px: 2.5,
py: 1
},
lg: {
fontSize: 'sm',
minH: 7,
px: 3,
py: 1
}
};
export type SelectProps<T = any> = { export type SelectProps<T = any> = {
list: { list: {
icon?: string; icon?: string;
...@@ -132,6 +156,7 @@ const MultipleSelect = <T = any,>({ ...@@ -132,6 +156,7 @@ const MultipleSelect = <T = any,>({
return listItem || { value: val, label: String(val) }; return listItem || { value: val, label: String(val) };
}); });
}, [formatValue, list]); }, [formatValue, list]);
const selectedTagSizeStyle = selectedTagSizeStyleMap[size];
const tagWidth = tagStyle?.w; const tagWidth = tagStyle?.w;
const canInferSelectAll = !ScrollData && (isSelectAll !== undefined || !!setIsSelectAll); const canInferSelectAll = !ScrollData && (isSelectAll !== undefined || !!setIsSelectAll);
const isFullSelected = useMemo(() => { const isFullSelected = useMemo(() => {
...@@ -225,9 +250,9 @@ const MultipleSelect = <T = any,>({ ...@@ -225,9 +250,9 @@ const MultipleSelect = <T = any,>({
// 否则根据文本长度估算(更精确) // 否则根据文本长度估算(更精确)
const text = String(item.label || item.value); const text = String(item.label || item.value);
const baseWidth = 16; // 基础padding const baseWidth = size === 'sm' ? 16 : size === 'md' ? 20 : 24; // 基础padding
const charWidth = 8; // 每个字符约8px const charWidth = size === 'sm' ? 7.5 : 8; // 每个字符约8px
const closeIconWidth = closeable ? 20 : 0; // 关闭按钮宽度 const closeIconWidth = closeable ? 22 : 0; // 关闭按钮宽度
return baseWidth + text.length * charWidth + closeIconWidth; return baseWidth + text.length * charWidth + closeIconWidth;
}; };
...@@ -270,7 +295,7 @@ const MultipleSelect = <T = any,>({ ...@@ -270,7 +295,7 @@ const MultipleSelect = <T = any,>({
setVisibleItems(selectedItems.slice(0, visibleCount)); setVisibleItems(selectedItems.slice(0, visibleCount));
setOverflowItems(selectedItems.slice(visibleCount)); setOverflowItems(selectedItems.slice(visibleCount));
}, [closeable, formLabel, selectedItems, tagWidth]); }, [closeable, formLabel, selectedItems, size, tagWidth]);
// 动态监听容器宽度变化并重新计算布局 // 动态监听容器宽度变化并重新计算布局
useEffect(() => { useEffect(() => {
...@@ -401,8 +426,7 @@ const MultipleSelect = <T = any,>({ ...@@ -401,8 +426,7 @@ const MultipleSelect = <T = any,>({
color={'primary.700'} color={'primary.700'}
type={'fill'} type={'fill'}
borderRadius={'sm'} borderRadius={'sm'}
px={2} {...selectedTagSizeStyle}
py={0.5}
flexShrink={0} flexShrink={0}
{...selectedTagStyle} {...selectedTagStyle}
{...tagStyle} {...tagStyle}
...@@ -428,9 +452,9 @@ const MultipleSelect = <T = any,>({ ...@@ -428,9 +452,9 @@ const MultipleSelect = <T = any,>({
))} ))}
{overflowItems.length > 0 && ( {overflowItems.length > 0 && (
<Box <Box
fontSize={formLabelFontSize} {...selectedTagSizeStyle}
px={2} display={'flex'}
py={0.5} alignItems={'center'}
flexShrink={0} flexShrink={0}
borderRadius={'lg'} borderRadius={'lg'}
bg={'myGray.100'} bg={'myGray.100'}
......
Subproject commit 57de3ec7a12091a12dcd0545394dee3ad13e1c55 Subproject commit 64bd986cd29bdf6eeafeb3f65832a877e6a4e7d2
...@@ -175,6 +175,11 @@ const AIChatSettingsModal = ({ ...@@ -175,6 +175,11 @@ const AIChatSettingsModal = ({
[supportParams.audio, supportParams.video, supportParams.vision, t] [supportParams.audio, supportParams.video, supportParams.vision, t]
); );
const singleMultimodalOption = multimodalOptions[0]; const singleMultimodalOption = multimodalOptions[0];
const multimodalSettingLabel =
multimodalOptions.length === 1 &&
singleMultimodalOption?.value === NodeInputKeyEnum.aiChatVision
? t('app:llm_use_vision')
: t('app:llm_use_multimodal');
const selectedMultimodalValues = [ const selectedMultimodalValues = [
useVision && supportParams.vision && NodeInputKeyEnum.aiChatVision, useVision && supportParams.vision && NodeInputKeyEnum.aiChatVision,
useAudio && supportParams.audio && NodeInputKeyEnum.aiChatAudio, useAudio && supportParams.audio && NodeInputKeyEnum.aiChatAudio,
...@@ -377,7 +382,7 @@ const AIChatSettingsModal = ({ ...@@ -377,7 +382,7 @@ const AIChatSettingsModal = ({
)} )}
{showMultimodalSetting && ( {showMultimodalSetting && (
<SettingRow <SettingRow
label={t('app:llm_use_multimodal')} label={multimodalSettingLabel}
switchControl={ switchControl={
!supportParams.multimodal ? ( !supportParams.multimodal ? (
<Box fontSize={'sm'} color={'myGray.500'}> <Box fontSize={'sm'} color={'myGray.500'}>
......
import React from 'react';
import { Box, type BoxProps } from '@chakra-ui/react';
import type { RefObject } from 'react';
import type { UseFormReturn } from 'react-hook-form';
import type { ChatBoxInputFormType } from '../type';
import type { ChatTypeEnum } from '../constants';
import WelcomeBox from './WelcomeBox';
import VariableInputForm from './VariableInputForm';
import ChatRecordsList, { type ChatRecordsListProps } from './ChatRecordsList';
type ScrollDataComponent = ({
children,
...props
}: {
children: React.ReactNode;
ScrollContainerRef?: RefObject<HTMLDivElement>;
} & BoxProps) => React.JSX.Element;
type AppChatMainProps = {
ScrollData: ScrollDataComponent;
ScrollContainerRef: RefObject<HTMLDivElement>;
welcomeText?: string;
chatStarted: boolean;
chatForm: UseFormReturn<ChatBoxInputFormType>;
chatType: ChatTypeEnum;
recordsListProps: ChatRecordsListProps;
};
/**
* 渲染非 home 模式下的 ChatBox 主内容区。
*
* 这个组件直接承接原 `ChatBox/index.tsx` 中的 `AppChatRenderBox`:
* - 外层仍使用 `ChatRecordContext` 提供的 `ScrollData`,保持历史分页和滚动容器行为。
* - 内容区仍按原顺序渲染 welcome、变量表单和聊天记录列表。
* - 底部输入区、workorder、home 欢迎页和发送/停止逻辑都不进入本组件,继续由 `index.tsx`
* 编排,避免 UI 主区域拆分时改变输入或运行时行为。
*/
const AppChatMain = ({
ScrollData,
ScrollContainerRef,
welcomeText,
chatStarted,
chatForm,
chatType,
recordsListProps
}: AppChatMainProps) => {
return (
<ScrollData
ScrollContainerRef={ScrollContainerRef}
flex={'1 0 0'}
h={0}
w={'100%'}
overflow={'overlay'}
px={[4, 0]}
pb={6}
>
<Box maxW={['100%', '92%']} h={'100%'} mx={'auto'}>
{!!welcomeText && <WelcomeBox welcomeText={welcomeText} />}
<Box id="variable-input">
<VariableInputForm chatStarted={chatStarted} chatForm={chatForm} chatType={chatType} />
</Box>
<ChatRecordsList {...recordsListProps} />
</Box>
</ScrollData>
);
};
export default React.memo(AppChatMain);
import dynamic from 'next/dynamic';
import type { AdminFbkType } from '@fastgpt/global/core/chat/type';
import type { AdminMarkType } from './SelectMarkCollection';
const FeedbackModal = dynamic(() => import('./FeedbackModal'));
const SelectMarkCollection = dynamic(() => import('./SelectMarkCollection'));
type AdminMarkState = AdminMarkType & { dataId: string };
type ChatBoxModalsProps = {
appId?: string;
chatId?: string;
feedbackId?: string;
adminMarkData?: AdminMarkState;
onCloseFeedback: () => void;
onFeedbackSuccess: (content: string) => void;
onCloseAdminMark: () => void;
onAdminMarkChange: (adminMarkData: AdminMarkState) => void;
onAdminMarkSuccess: (adminFeedback: AdminFbkType) => void;
};
/**
* 集中渲染 ChatBox 的弹窗层。
*
* 本组件只负责把已有的 modal JSX 从 `ChatBox/index.tsx` 中移出,不持有业务状态,
* 也不直接调用 feedback/admin mark API。modal 的打开状态、关闭动作、提交成功后的 records
* 回写都仍由 `useChatFeedbackActions` 提供,避免 UI 组件拆分时重新改变反馈行为。
*
* 渲染边界:
* - `FeedbackModal` 需要同时具备 `feedbackId` 和 `chatId` 才能提交用户点踩反馈。
* - `SelectMarkCollection` 只依赖 `adminMarkData`;其中的 `dataId` 必须在 dataset/collection
* 多步选择过程中保留,所以 `onAdminMarkChange` 会继续补回当前 `dataId`。
*/
const ChatBoxModals = ({
appId,
chatId,
feedbackId,
adminMarkData,
onCloseFeedback,
onFeedbackSuccess,
onCloseAdminMark,
onAdminMarkChange,
onAdminMarkSuccess
}: ChatBoxModalsProps) => {
return (
<>
{!!feedbackId && appId && chatId && (
<FeedbackModal
appId={appId}
chatId={chatId}
dataId={feedbackId}
onClose={onCloseFeedback}
onSuccess={onFeedbackSuccess}
/>
)}
{!!adminMarkData && (
<SelectMarkCollection
adminMarkData={adminMarkData}
setAdminMarkData={(e) => onAdminMarkChange({ ...e, dataId: adminMarkData.dataId })}
onClose={onCloseAdminMark}
onSuccess={onAdminMarkSuccess}
/>
)}
</>
);
};
export default ChatBoxModals;
...@@ -5,7 +5,7 @@ import MyTooltip from '@fastgpt/web/components/common/MyTooltip'; ...@@ -5,7 +5,7 @@ import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import MyIcon from '@fastgpt/web/components/common/Icon'; import MyIcon from '@fastgpt/web/components/common/Icon';
import { formatChatValue2InputType } from '../utils'; import { formatChatValue2InputType } from '../utils/chatValue';
import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
import { ChatBoxContext } from '../Provider'; import { ChatBoxContext } from '../Provider';
import { useContextSelector } from 'use-context-selector'; import { useContextSelector } from 'use-context-selector';
......
...@@ -3,7 +3,7 @@ import React, { useMemo, useState, useRef } from 'react'; ...@@ -3,7 +3,7 @@ import React, { useMemo, useState, useRef } from 'react';
import ChatController, { type ChatControllerProps } from './ChatController'; import ChatController, { type ChatControllerProps } from './ChatController';
import ChatAvatar from './ChatAvatar'; import ChatAvatar from './ChatAvatar';
import { MessageCardStyle } from '../constants'; import { MessageCardStyle } from '../constants';
import { formatChatValue2InputType } from '../utils'; import { formatChatValue2InputType } from '../utils/chatValue';
import Markdown from '@/components/Markdown'; import Markdown from '@/components/Markdown';
import styles from '../index.module.scss'; import styles from '../index.module.scss';
import markdownStyles from '@/components/Markdown/index.module.scss'; import markdownStyles from '@/components/Markdown/index.module.scss';
......
import React, { type ChangeEvent, type MutableRefObject } from 'react';
import { Box, Checkbox } from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import MyIcon from '@fastgpt/web/components/common/Icon';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import type { ChatStatusEnum } from '@fastgpt/global/core/chat/constants';
import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
import ChatBoxDivider from '../../../Divider';
import DeletedItemsCollapse from '../../DeletedItemsCollapse';
import { formatChatValue2InputType } from '../utils/chatValue';
import type { ChatSiteItemType } from '../type';
import ChatItem from './ChatItem';
import TimeBox from './TimeBox';
export type ChatRecordsListProps = {
records: ChatSiteItemType[];
expandedDeletedGroups: Set<string>;
itemRefs: MutableRefObject<Map<string, HTMLElement | null>>;
userAvatar?: string;
appAvatar?: string;
showVoiceIcon: boolean;
showMarkIcon: boolean;
statusBoxData:
| {
status: `${ChatStatusEnum}`;
name: string;
}
| undefined;
questionGuides: string[];
onToggleDeletedGroup: (dataIds: string[]) => void;
onRetry: (dataId?: string) => (() => Promise<void>) | undefined;
onDelete: (dataId: string) => () => void;
onMark: (chat: ChatSiteItemType, q?: string) => (() => void) | undefined;
onAddUserLike: (chat: ChatSiteItemType) => (() => void) | undefined;
onAddUserDislike: (chat: ChatSiteItemType) => (() => void) | undefined;
onCloseCustomFeedback: (
chat: ChatSiteItemType,
index: number
) => (e: ChangeEvent<HTMLInputElement>) => void;
onToggleFeedbackReadStatus: (chat: ChatSiteItemType) => (() => Promise<void>) | undefined;
};
const shouldShowTimeDivider = ({
records,
item,
index
}: {
records: ChatSiteItemType[];
item: ChatSiteItemType;
index: number;
}) => {
if (index === 0 || !item.time || records[index - 1].time === undefined) return false;
return (
new Date(item.time).getTime() - new Date(records[index - 1].time!).getTime() > 10 * 60 * 1000
);
};
/**
* 渲染 ChatBox 的聊天记录列表。
*
* 本组件只接收已经预处理好的 `records`,不负责 log 模式 deleted group 的计算,也不直接
* 调用删除、重试、反馈、标注 API。所有动作都由上层 hook 生成后作为 props 注入,组件内部
* 只负责把 human/AI 记录、折叠按钮、时间分隔、自定义反馈和 admin mark 展示拼成原来的 JSX。
*
* 设计边界:
* - `expandedDeletedGroups` 只用于判断 deleted record 是否渲染,展开/收起状态更新仍在父组件。
* - `itemRefs` 继续由父级 context 持有,本组件只在每条可见记录渲染时登记 DOM 节点。
* - AI 的 q 默认值仍取上一条 processed record 的文本,保持 admin mark 默认问题内容不变。
*/
const ChatRecordsList = ({
records,
expandedDeletedGroups,
itemRefs,
userAvatar,
appAvatar,
showVoiceIcon,
showMarkIcon,
statusBoxData,
questionGuides,
onToggleDeletedGroup,
onRetry,
onDelete,
onMark,
onAddUserLike,
onAddUserDislike,
onCloseCustomFeedback,
onToggleFeedbackReadStatus
}: ChatRecordsListProps) => {
const { t } = useTranslation();
return (
<Box id={'history'}>
{records.map((item, index) => {
const shouldRender = !item.deleteTime || expandedDeletedGroups.has(item.dataId);
return (
<Box key={item.dataId}>
{item.collapseTop && (
<DeletedItemsCollapse
count={item.collapseTop.count}
isExpanded={item.collapseTop.isExpanded}
onToggle={() => onToggleDeletedGroup(item.collapseTop!.dataIds)}
position="top"
/>
)}
{shouldRender && (
<Box
ref={(e) => {
itemRefs.current.set(item.dataId, e);
}}
>
{shouldShowTimeDivider({ records, item, index }) && <TimeBox time={item.time!} />}
<Box py={item.hideInUI ? 0 : 6}>
{item.obj === ChatRoleEnum.Human && !item.hideInUI && (
<ChatItem
avatar={userAvatar}
chat={item}
onRetry={onRetry(item.dataId)}
onDelete={onDelete(item.dataId)}
isLastChild={index === records.length - 1}
/>
)}
{item.obj === ChatRoleEnum.AI && (
<ChatItem
avatar={appAvatar}
chat={item}
isLastChild={index === records.length - 1}
{...{
showVoiceIcon,
statusBoxData,
questionGuides,
onMark: onMark(
item,
formatChatValue2InputType(records[index - 1]?.value)?.text
),
onAddUserLike: onAddUserLike(item),
onAddUserDislike: onAddUserDislike(item),
onToggleFeedbackReadStatus: onToggleFeedbackReadStatus(item)
}}
>
{item.customFeedbacks && item.customFeedbacks.length > 0 && (
<Box>
<ChatBoxDivider
icon={'core/app/customFeedback'}
text={t('common:core.app.feedback.Custom feedback')}
/>
{item.customFeedbacks.map((text, i) => (
<Box key={i}>
<MyTooltip
label={t('common:core.app.feedback.close custom feedback')}
>
<Checkbox
onChange={onCloseCustomFeedback(item, i)}
icon={<MyIcon name={'common/check'} w={'12px'} />}
>
{text}
</Checkbox>
</MyTooltip>
</Box>
))}
</Box>
)}
{showMarkIcon && item.adminFeedback && (
<Box fontSize={'sm'}>
<ChatBoxDivider
icon="core/app/markLight"
text={t('common:core.chat.Admin Mark Content')}
/>
<Box whiteSpace={'pre-wrap'}>
<Box color={'black'}>{item.adminFeedback.q}</Box>
<Box color={'myGray.600'}>{item.adminFeedback.a}</Box>
</Box>
</Box>
)}
</ChatItem>
)}
</Box>
</Box>
)}
{item.collapseBottom && item.collapseBottom.isExpanded && (
<DeletedItemsCollapse
count={item.collapseBottom.count}
isExpanded={item.collapseBottom.isExpanded}
onToggle={() => onToggleDeletedGroup(item.collapseBottom!.dataIds)}
position="bottom"
/>
)}
</Box>
);
})}
</Box>
);
};
export default React.memo(ChatRecordsList);
import { useState, type ChangeEvent } from 'react';
import { useContextSelector } from 'use-context-selector';
import { useMemoizedFn } from 'ahooks';
import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
import type { AdminFbkType } from '@fastgpt/global/core/chat/type';
import {
closeCustomFeedback,
updateChatAdminFeedback,
updateChatUserFeedback,
updateFeedbackReadStatus
} from '@/web/core/chat/feedback/api';
import { ChatRecordContext } from '@/web/core/chat/context/chatRecordContext';
import { WorkflowRuntimeContext } from '../../context/workflowRuntimeContext';
import type { AdminMarkType } from '../components/SelectMarkCollection';
import { ChatTypeEnum, FeedbackTypeEnum } from '../constants';
import { formatChatValue2InputType } from '../utils/chatValue';
import type { ChatSiteItemType } from '../type';
type UseChatFeedbackActionsProps = {
feedbackType?: `${FeedbackTypeEnum}`;
showMarkIcon: boolean;
chatType: `${ChatTypeEnum}`;
onTriggerRefresh?: () => void;
};
type AdminMarkState = AdminMarkType & { dataId: string };
/**
* 管理 ChatBox 中反馈、标注和反馈已读状态相关动作。
*
* 这组逻辑和 PR 5 的 record actions 不同:它不会删除或重试消息,而是修改消息上的
* feedback 字段、custom feedback 列表、admin mark 数据以及 log 模式的反馈已读状态。
*
* 设计边界:
* - hook 负责动作和 modal 状态,`FeedbackModal`、`SelectMarkCollection` 的 JSX 仍留在
* `ChatBox/index.tsx`,避免本 PR 同时进入 UI 组件拆分。
* - 用户点赞/取消点赞、取消点踩、关闭 custom feedback 都沿用原来的乐观更新策略:
* 先更新本地 `chatRecords`,API 异常保持静默,不在本次拆分里新增 toast 或回滚。
* - admin mark 的弹窗流程需要跨三步选择 dataset、collection、输入数据,因此 hook 只保存
* 当前 modal 数据和 success 回写逻辑,不改 `SelectMarkCollection` 内部交互。
*/
export const useChatFeedbackActions = ({
feedbackType,
showMarkIcon,
chatType,
onTriggerRefresh
}: UseChatFeedbackActionsProps) => {
const [feedbackId, setFeedbackId] = useState<string>();
const [adminMarkData, setAdminMarkData] = useState<AdminMarkState>();
const setChatRecords = useContextSelector(ChatRecordContext, (v) => v.setChatRecords);
const appId = useContextSelector(WorkflowRuntimeContext, (v) => v.appId);
const chatId = useContextSelector(WorkflowRuntimeContext, (v) => v.chatId);
const outLinkAuthData = useContextSelector(WorkflowRuntimeContext, (v) => v.outLinkAuthData);
/**
* 生成 admin mark 入口回调。
*
* 只有开启 `showMarkIcon` 且目标消息是 AI 时才允许标注。已有 adminFeedback 时进入编辑态,
* 会带回原 dataset/collection/dataId;没有 adminFeedback 时用上一条 human 文本作为 q,
* 用当前 AI 文本作为 a,保持原来的默认标注内容。
*/
const onMark = useMemoizedFn((chat: ChatSiteItemType, q = '') => {
if (!showMarkIcon || chat.obj !== ChatRoleEnum.AI) return;
return () => {
if (!chat.dataId) return;
if (chat.adminFeedback) {
setAdminMarkData({
dataId: chat.dataId,
datasetId: chat.adminFeedback.datasetId,
collectionId: chat.adminFeedback.collectionId,
feedbackDataId: chat.adminFeedback.feedbackDataId,
q: chat.adminFeedback.q || q || '',
a: chat.adminFeedback.a
});
} else {
setAdminMarkData({
dataId: chat.dataId,
q,
a: formatChatValue2InputType(chat.value).text
});
}
};
});
/**
* 生成用户点赞回调。
*
* 点赞只在 user feedback 模式、AI 消息、且当前没有点踩内容时可用。再次点击已点赞消息会取消
* `userGoodFeedback`,本地和服务端都写入 undefined,保留原有“点击切换”语义。
*/
const onAddUserLike = useMemoizedFn((chat: ChatSiteItemType) => {
if (
feedbackType !== FeedbackTypeEnum.user ||
chat.obj !== ChatRoleEnum.AI ||
chat.userBadFeedback
)
return;
return () => {
if (!chat.dataId || !chatId || !appId) return;
const isGoodFeedback = !!chat.userGoodFeedback;
setChatRecords((state) =>
state.map((chatItem) =>
chatItem.dataId === chat.dataId
? {
...chatItem,
userGoodFeedback: isGoodFeedback ? undefined : 'yes'
}
: chatItem
)
);
try {
updateChatUserFeedback({
appId,
chatId,
dataId: chat.dataId,
userGoodFeedback: isGoodFeedback ? undefined : 'yes',
...outLinkAuthData
});
} catch {}
};
});
/**
* 生成用户点踩回调。
*
* 没有点踩内容时返回打开 `FeedbackModal` 的回调,由弹窗收集具体原因;已有点踩内容时返回
* 取消点踩回调,清空本地 `userBadFeedback` 并同步服务端。存在点赞时不允许点踩,保持互斥。
*/
const onAddUserDislike = useMemoizedFn((chat: ChatSiteItemType) => {
if (
feedbackType !== FeedbackTypeEnum.user ||
chat.obj !== ChatRoleEnum.AI ||
chat.userGoodFeedback
) {
return;
}
if (chat.userBadFeedback) {
return () => {
if (!chat.dataId || !chatId || !appId) return;
setChatRecords((state) =>
state.map((chatItem) =>
chatItem.dataId === chat.dataId ? { ...chatItem, userBadFeedback: undefined } : chatItem
)
);
try {
updateChatUserFeedback({
appId,
chatId,
dataId: chat.dataId,
...outLinkAuthData
});
} catch {}
};
}
return () => setFeedbackId(chat.dataId);
});
/**
* 生成关闭 custom feedback 的 checkbox change 回调。
*
* 原逻辑只在 checkbox 被勾选时关闭指定 custom feedback;这里保持同样的 DOM 驱动语义。
* 服务端通过 index unset/pull,本地则过滤同一 index,避免关闭后仍在当前消息下展示。
*/
const onCloseCustomFeedback = useMemoizedFn((chat: ChatSiteItemType, i: number) => {
return (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.checked && appId && chatId && chat.dataId) {
closeCustomFeedback({
appId,
chatId,
dataId: chat.dataId,
index: i
});
setChatRecords((state) =>
state.map((chatItem) =>
chatItem.obj === ChatRoleEnum.AI && chatItem.dataId === chat.dataId
? {
...chatItem,
customFeedbacks: chatItem.customFeedbacks?.filter((_, index) => index !== i)
}
: chatItem
)
);
}
};
});
/**
* 生成 log 模式下反馈已读状态切换回调。
*
* 该动作只对 log 模式 AI 消息生效。成功写入服务端后再更新本地 `isFeedbackRead`,
* 并触发 `onTriggerRefresh` 刷新外层统计,保持原有“服务端成功后同步 UI”的行为。
*/
const onToggleFeedbackReadStatus = useMemoizedFn((chat: ChatSiteItemType) => {
if (chatType !== ChatTypeEnum.log || chat.obj !== ChatRoleEnum.AI) return;
return async () => {
if (!appId || !chatId || !chat.dataId) return;
const newReadStatus = !chat.isFeedbackRead;
try {
await updateFeedbackReadStatus({
appId,
chatId,
dataId: chat.dataId,
isRead: newReadStatus
});
setChatRecords((state) =>
state.map((item) =>
item.dataId === chat.dataId
? {
...item,
isFeedbackRead: newReadStatus
}
: item
)
);
onTriggerRefresh?.();
} catch {}
};
});
/**
* FeedbackModal 提交成功后的本地回写。
*
* 点踩具体内容由 modal 内部提交到服务端;ChatBox 只需要把成功返回的内容写回当前消息,
* 并关闭 modal,避免等待下一次 records reload 才看到反馈状态。
*/
const onFeedbackSuccess = useMemoizedFn((content: string) => {
setChatRecords((state) =>
state.map((item) =>
item.dataId === feedbackId ? { ...item, userBadFeedback: content } : item
)
);
setFeedbackId(undefined);
});
/**
* SelectMarkCollection 提交成功后的服务端同步和本地回写。
*
* `adminMarkData.dataId` 是当前被标注的 AI 消息 id;如果基础上下文缺失则直接跳过,
* 和原实现一致,不额外弹错。成功后把完整 adminFeedback 写回本地消息用于即时展示。
*/
const onAdminMarkSuccess = useMemoizedFn((adminFeedback: AdminFbkType) => {
if (!appId || !chatId || !adminMarkData?.dataId) return;
updateChatAdminFeedback({
appId,
chatId,
dataId: adminMarkData.dataId,
...adminFeedback
});
setChatRecords((state) =>
state.map((chatItem) =>
chatItem.dataId === adminMarkData.dataId
? {
...chatItem,
adminFeedback
}
: chatItem
)
);
});
return {
feedbackId,
setFeedbackId,
adminMarkData,
setAdminMarkData,
onMark,
onAddUserLike,
onAddUserDislike,
onCloseCustomFeedback,
onToggleFeedbackReadStatus,
onFeedbackSuccess,
onAdminMarkSuccess
};
};
import { useMemo, type RefObject } from 'react';
import { useForm } from 'react-hook-form';
import { useDebounceEffect, useMemoizedFn } from 'ahooks';
import type { VariableItemType } from '@fastgpt/global/core/app/type';
import { VariableInputEnum } from '@fastgpt/global/core/workflow/constants';
import { ChatTypeEnum, textareaMinH } from '../constants';
import type { ChatBoxInputFormType, ChatBoxInputType } from '../type';
/**
* 管理 ChatBox 输入表单、输入草稿和“对话是否已开始”的派生状态。
*
* 这个 hook 是 ChatBox 输入区的状态边界,刻意不处理发送请求、变量表单校验和
* chatRecords 写入。调用方只需要从这里拿到 `chatForm`、`chatStarted` 和
* `resetInputVal`,再决定是否允许触发真正的发送流程。
*
* 输入约定:
* - `appId/chatId` 来自当前运行时上下文,用于区分不同 app 和不同会话。
* - `chatBoxAppId` 来自当前 ChatBox 数据,用于避免 app 切换过程复用旧表单状态。
* - `chatRecordsLength` 只用长度判断会话是否已有记录,避免把完整 records 传进输入 hook。
* - `TextareaDom` 只在重置输入后恢复 textarea 高度,不参与渲染状态计算。
*
* 输出约定:
* - `chatStarted` 表示当前会话已经可以展示输入区或直接发送。
* - `chatStartedWatch` 保留给原有 UI 分支使用,表示用户是否手动点过开始。
* - `resetInputVal` 会同时重置文本、文件、草稿和 textarea 高度。
*
* 关键边界:
* - 草稿按 `chatInput_${chatId}` 存储,避免不同会话之间串输入内容。
* - `chatStarted` 必须先确认 app 匹配;否则 app 切换时旧 records 或旧表单可能让新会话误判已开始。
* - custom 变量属于外部变量输入,internal 变量不需要用户填写,二者都不应当按普通变量阻塞开始。
*/
export const useChatInputForm = ({
appId,
chatId,
chatBoxAppId,
chatRecordsLength,
chatType,
variableList,
TextareaDom
}: {
appId?: string;
chatId?: string;
chatBoxAppId?: string;
chatRecordsLength: number;
chatType: ChatTypeEnum;
variableList: VariableItemType[];
TextareaDom: RefObject<HTMLTextAreaElement>;
}) => {
// 只有这些聊天入口会在 ChatBox 内展示外部变量;其它入口即使存在 custom 变量,
// 也不应该用这里的判断改变输入区启动状态。
const showExternalVariable = useMemo(() => {
const map: Record<string, boolean> = {
[ChatTypeEnum.log]: true,
[ChatTypeEnum.test]: true,
[ChatTypeEnum.chat]: true,
[ChatTypeEnum.home]: true
};
return map[chatType] && variableList.some((item) => item.type === VariableInputEnum.custom);
}, [variableList, chatType]);
const chatForm = useForm<ChatBoxInputFormType>({
defaultValues: {
// react-hook-form 的 defaultValues 只在初始化时读取一次。这里沿用原逻辑:
// 首次进入某个 chatId 时恢复草稿,后续切换由 ChatBox 重新挂载/状态流驱动。
input: sessionStorage.getItem(`chatInput_${chatId}`) || '',
files: [],
chatStarted: false
}
});
const { setValue, watch } = chatForm;
const chatStartedWatch = watch('chatStarted');
const inputValue = watch('input');
useDebounceEffect(
() => {
// 输入过程中写 sessionStorage 会比较频繁,保持 debounce 可以减少同步 IO。
// 空值直接删除 key,避免用户清空输入后下次进入会话仍恢复空草稿。
if (inputValue) {
sessionStorage.setItem(`chatInput_${chatId}`, inputValue);
} else {
sessionStorage.removeItem(`chatInput_${chatId}`);
}
},
[inputValue, chatId],
{ wait: 300 }
);
const commonVariableList = useMemo(
() =>
// internal 变量由系统注入,custom 变量走外部变量输入区;只有剩余变量才代表
// 需要用户在普通变量表单里完成填写,进而影响 `chatStarted`。
variableList.filter(
(item) => item.type !== VariableInputEnum.custom && item.type !== VariableInputEnum.internal
),
[variableList]
);
// 原 ChatBox 语义:同一个 app 下,有历史记录、用户手动开始,或没有需要填写的变量时,
// 都认为对话已经开始。这里不检查变量值是否有效,真正的变量校验仍由发送流程负责。
const chatStarted =
chatBoxAppId === appId &&
(chatRecordsLength > 0 ||
chatStartedWatch ||
(commonVariableList.length === 0 && !showExternalVariable));
/**
* 重置输入框内容。
*
* 调用场景包括发送成功后清空、编辑历史问题、发送失败后恢复用户输入。
* 它必须同步更新 react-hook-form、sessionStorage 草稿和 textarea DOM 高度:
* - form 值决定 ChatInput 当前展示内容。
* - 草稿删除可以避免已发送内容在刷新后再次出现。
* - textarea 高度是 DOM 计算结果,不能只靠 form state 自动恢复。
*/
const resetInputVal = useMemoizedFn(({ text = '', files = [] }: ChatBoxInputType) => {
if (!TextareaDom.current) return;
setValue('files', files);
setValue('input', text);
sessionStorage.removeItem(`chatInput_${chatId}`);
setTimeout(() => {
/* 回到最小高度 */
if (TextareaDom.current) {
TextareaDom.current.style.height =
text === '' ? textareaMinH : `${TextareaDom.current.scrollHeight}px`;
}
}, 100);
});
return {
chatForm,
setValue,
chatStarted,
chatStartedWatch,
resetInputVal
};
};
import { useState } from 'react';
import { useContextSelector } from 'use-context-selector';
import { useMemoizedFn } from 'ahooks';
import { useToast } from '@fastgpt/web/hooks/useToast';
import { getErrText } from '@fastgpt/global/common/error/utils';
import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
import { delChatRecordById } from '@/web/core/chat/record/api';
import { ChatRecordContext } from '@/web/core/chat/context/chatRecordContext';
import { WorkflowRuntimeContext } from '../../context/workflowRuntimeContext';
import { formatChatValue2InputType } from '../utils/chatValue';
import type { SendPromptFnType } from '../type';
type UseChatRecordActionsProps = {
sendPrompt: SendPromptFnType;
onDeleteChatItem?: (contentId: string, delFile?: boolean) => Promise<void>;
};
/**
* 管理 ChatBox 中“记录级动作”的副作用和本地 records 更新。
*
* 这里的“记录级动作”特指会直接改变 chatRecords 的删除、重试等操作:
* - `retryInput` 会删除目标 human 消息及其后续记录,再用旧输入重新发送。
* - `delOneMessage` 会删除一条 human 消息,并顺带删除紧随其后的 AI 回复。
* - `onDelMessage` 是 hook 内部的删除通道,优先走外部覆盖的 `onDeleteChatItem`,
* 否则使用默认 `delChatRecordById` API,并自动带上当前 app/chat/outLink 鉴权信息。
*
* 设计边界:
* - 本 hook 只处理删除和重试,不处理点赞、点踩、admin mark、log read status。
* 这些反馈类动作会进入后续 `useChatFeedbackActions`,避免阶段四的单个 PR 过大。
* - `sendPrompt` 由生成 hook 提供,本 hook 不关心普通发送的 placeholder、SSE 合并、
* TTS、问题引导等细节,只在重试时把恢复出的输入和裁剪后的 history 交还给生成链路。
* - 本地 `chatRecords` 会先按现有逻辑乐观更新;远端删除失败时只对重试流程 toast,
* 单条删除仍保持原来的 fire-and-forget 行为,不在本次拆分里改变用户可见语义。
*/
export const useChatRecordActions = ({
sendPrompt,
onDeleteChatItem
}: UseChatRecordActionsProps) => {
const { toast } = useToast();
const [isRecordActionLoading, setIsRecordActionLoading] = useState(false);
const chatRecords = useContextSelector(ChatRecordContext, (v) => v.chatRecords);
const setChatRecords = useContextSelector(ChatRecordContext, (v) => v.setChatRecords);
const appId = useContextSelector(WorkflowRuntimeContext, (v) => v.appId);
const chatId = useContextSelector(WorkflowRuntimeContext, (v) => v.chatId);
const outLinkAuthData = useContextSelector(WorkflowRuntimeContext, (v) => v.outLinkAuthData);
/**
* 删除一条服务端聊天记录。
*
* `delFile` 默认是 true,表示删除消息时一起删除关联文件;重试流程会传 false,
* 因为同一轮历史可能需要继续复用原文件输入,不能在删除旧记录时把文件也删掉。
*/
const onDelMessage = useMemoizedFn((contentId: string, delFile = true) => {
if (onDeleteChatItem) {
return onDeleteChatItem(contentId, delFile);
}
return delChatRecordById({
appId,
chatId,
contentId,
delFile,
...outLinkAuthData
});
});
/**
* 生成某条 human 消息的“重试”回调。
*
* 重试不是简单地重新发送当前文本,而是先定位目标 `dataId`:
* 1. 删除该消息以及它之后的所有服务端记录,避免新回答和旧后续历史并存。
* 2. 将本地 records 裁剪到目标消息之前,让生成链路看到正确的 history。
* 3. 从被删除的第一条记录恢复 text/files,并交给 `sendPrompt` 重新发送。
*
* 如果没有传入 `dataId`,返回 undefined,让调用方不渲染无效动作。
*/
const retryInput = useMemoizedFn((dataId?: string) => {
if (!dataId) return;
return async () => {
setIsRecordActionLoading(true);
const index = chatRecords.findIndex((item) => item.dataId === dataId);
const delHistory = chatRecords.slice(index);
try {
await Promise.all(
delHistory.map((item) => {
if (item.dataId) {
return onDelMessage(item.dataId, false);
}
})
);
setChatRecords((state) => (index === 0 ? [] : state.slice(0, index)));
sendPrompt({
...formatChatValue2InputType(delHistory[0].value),
history: chatRecords.slice(0, index)
});
} catch (error) {
toast({
status: 'warning',
title: getErrText(error, 'Retry failed')
});
}
setIsRecordActionLoading(false);
};
});
/**
* 生成单条 human 消息的删除回调。
*
* ChatBox 的一轮普通对话通常是 human 后面紧跟 AI 回复,因此删除 human 时需要同步删除
* 紧随其后的 AI 记录,避免界面只剩下一条失去问题上下文的回答。这里只删除“紧随其后”
* 且仍有 `dataId` 的 AI,避免误删更远处的历史或还未落库的临时消息。
*/
const delOneMessage = useMemoizedFn((dataId: string) => {
return () => {
setChatRecords((state) => {
let aiIndex = -1;
return state.filter((chat, i) => {
if (chat.dataId === dataId) {
aiIndex = i + 1;
onDelMessage(dataId);
return false;
} else if (aiIndex === i && chat.obj === ChatRoleEnum.AI && chat.dataId) {
onDelMessage(chat.dataId);
return false;
}
return true;
});
});
};
});
return {
isRecordActionLoading,
retryInput,
delOneMessage
};
};
import { useRef } from 'react';
import { useMemoizedFn, useThrottleFn } from 'ahooks';
import { shouldFollowGeneratingScroll } from '../utils/scrollUtils';
/**
* 管理 ChatBox 的滚动容器和生成中跟随底部逻辑。
*
* 这个 hook 只提供滚动能力,不决定哪些业务时机要滚动。records loaded、发送消息、
* 恢复生成、问题引导等流程仍由调用方判断时机后调用 `scrollToBottom` 或
* `generatingScroll`。
*
* 输出约定:
* - `ScrollContainerRef` 绑定到聊天记录滚动容器。
* - `scrollToBottom` 用于明确要求滚到底部,支持 smooth/auto 和延迟。
* - `generatingScroll` 用于流式生成中“条件跟随底部”,避免打断用户查看历史。
*
* 关键边界:
* - `scrollToBottom` 保留 DOM 未挂载时的延迟重试,因为 ChatBox 有动态加载记录、
* home/chat 分支切换和恢复生成占位消息,调用时机可能早于滚动容器真实出现。
* - `generatingScroll` 复用 `shouldFollowGeneratingScroll`,只有用户接近底部或调用方
* 传入 `force` 时才跟随;这能避免用户向上查看历史时被流式 token 拉回底部。
*/
export const useChatScroll = () => {
const ScrollContainerRef = useRef<HTMLDivElement>(null);
/**
* 滚动到底部。
*
* `delay` 用于等待 React 渲染新消息或新高度;内部 `runScroll` 的二次重试用于等待
* scroll 容器挂载完成。这里不把失败暴露给调用方,因为原 ChatBox 行为就是尽力滚动。
*/
const scrollToBottom = useMemoizedFn((behavior: 'smooth' | 'auto' = 'smooth', delay = 0) => {
const runScroll = () => {
if (!ScrollContainerRef.current) {
setTimeout(runScroll, 500);
return;
}
ScrollContainerRef.current.scrollTo({
top: ScrollContainerRef.current.scrollHeight,
behavior
});
};
setTimeout(() => {
runScroll();
}, delay);
});
const { run: generatingScroll } = useThrottleFn(
(force?: boolean) => {
if (!ScrollContainerRef.current) return;
// 流式响应会高频触发,先判断用户是否仍在底部附近,再决定是否滚动。
// `force` 用于发送新消息、恢复占位等明确需要贴底的场景。
const isBottom = shouldFollowGeneratingScroll({
scrollTop: ScrollContainerRef.current.scrollTop,
clientHeight: ScrollContainerRef.current.clientHeight,
scrollHeight: ScrollContainerRef.current.scrollHeight,
force
});
if (isBottom) {
scrollToBottom('auto');
}
},
{
wait: 100
}
);
return {
ScrollContainerRef,
scrollToBottom,
generatingScroll
};
};
...@@ -17,7 +17,7 @@ import { type OutLinkChatAuthProps } from '@fastgpt/global/support/permission/ch ...@@ -17,7 +17,7 @@ import { type OutLinkChatAuthProps } from '@fastgpt/global/support/permission/ch
import { getUploadChatFilePresignedUrl } from '@/web/common/file/api'; import { getUploadChatFilePresignedUrl } from '@/web/common/file/api';
import { getUploadFileType } from '@fastgpt/global/core/app/constants'; import { getUploadFileType } from '@fastgpt/global/core/app/constants';
import { putFileToS3 } from '@fastgpt/web/common/file/utils'; import { putFileToS3 } from '@fastgpt/web/common/file/utils';
import { getUploadChatFileType } from '../utils'; import { getUploadChatFileType } from '../utils/file';
type UseFileUploadOptions = { type UseFileUploadOptions = {
fileSelectConfig: AppFileSelectConfigType; fileSelectConfig: AppFileSelectConfigType;
......
import { useCallback, type MutableRefObject } from 'react';
import type { AppQGConfigType } from '@fastgpt/global/core/app/type';
import { postQuestionGuide } from '@/web/core/ai/api';
import type { OutLinkChatAuthProps } from '@fastgpt/global/support/permission/chat';
/**
* 创建回答后的问题引导。
*
* 这个 hook 只封装 question guide 请求本身,不决定触发时机。调用方仍然负责在一次
* AI 回答完成、且没有 interactive 等更高优先级 UI 时调用返回的 `createQuestionGuide`。
*
* 输入约定:
* - `appId/chatId/outLinkAuthData` 组成请求目标和鉴权上下文。
* - `questionGuide` 是 app 的问题引导配置,hook 只读取 `open` 和请求所需配置。
* - `chatControllerRef` 指向主聊天请求,用于判断当前回答是否已经被用户停止。
* - `questionGuideControllerRef` 指向问题引导自己的请求,用于让 `abortRequest` 可以同时停止它。
*
* 输出约定:
* - 返回一个稳定的 async 函数;成功拿到字符串数组时写入 `setQuestionGuide`。
* - 请求失败保持静默,沿用原 ChatBox 行为,不在本 PR 改变 toast 或错误上报策略。
*
* 关键边界:
* - 主聊天请求已经 abort 时不再生成问题引导,否则用户点击停止后仍可能看到新推荐问题。
* - 每次请求前都会创建新的 AbortController,并写回 ref,让后续 stop/leave 能中断当前请求。
* - 结果写入后延迟滚动到底部,给推荐问题组件一次渲染高度的时间。
*/
export const useQuestionGuide = ({
appId,
chatId,
questionGuide,
outLinkAuthData,
chatControllerRef,
questionGuideControllerRef,
setQuestionGuide,
scrollToBottom
}: {
appId: string;
chatId: string;
questionGuide: AppQGConfigType;
outLinkAuthData?: OutLinkChatAuthProps;
chatControllerRef: MutableRefObject<AbortController>;
questionGuideControllerRef: MutableRefObject<AbortController>;
setQuestionGuide: (guides: string[]) => void;
scrollToBottom: (behavior?: 'smooth' | 'auto', delay?: number) => void;
}) => {
return useCallback(async () => {
// 保留拆分前语义:只用主聊天请求的 abort 状态阻止回答结束后的问题引导。
// question guide 自身的旧 controller 可能在新一轮发送开始时被 abort,不能阻断新请求。
if (!questionGuide.open || chatControllerRef.current?.signal?.aborted) {
return;
}
try {
// question guide 独立于主聊天请求,但需要被同一个 abortRequest 管理。
// 因此创建新 controller 后必须写回 ref,而不是只保存在局部变量里。
const abortSignal = new AbortController();
questionGuideControllerRef.current = abortSignal;
const result = await postQuestionGuide(
{
appId,
chatId,
questionGuide,
...outLinkAuthData
},
abortSignal
);
if (Array.isArray(result)) {
setQuestionGuide(result);
// 推荐问题渲染会增加底部高度,延迟滚动可以避免在 DOM 高度更新前滚动失败。
setTimeout(() => {
scrollToBottom();
}, 100);
}
} catch {}
}, [
questionGuide,
chatControllerRef,
questionGuideControllerRef,
appId,
chatId,
outLinkAuthData,
setQuestionGuide,
scrollToBottom
]);
};
import { ChatGenerateStatusEnum } from '@fastgpt/global/core/chat/constants';
import { ChatContext } from '@/web/core/chat/context/chatContext';
import { ChatItemContext } from '@/web/core/chat/context/chatItemContext';
import { WorkflowRuntimeContext } from '../../context/workflowRuntimeContext';
import { useContextSelector } from 'use-context-selector';
import { useTranslation } from 'next-i18next';
import { useMemoizedFn } from 'ahooks';
/**
* 同步侧边栏历史会话的生成状态。
*
* ChatBox 内部会同时维护当前会话详情和左侧历史列表。发送、恢复生成完成或失败时,
* 当前 `chatBoxData.chatGenerateStatus` 会更新,但侧边栏历史列表也需要同步展示
* generating/done/error 和已读状态。
*
* 这个 hook 只处理侧边栏历史列表,不直接修改当前 ChatBox 数据:
* - 当前会话详情由调用方通过 `setChatBoxData` 更新。
* - 历史列表由这里通过 `setHistories` 局部更新。
* - 当历史列表里找不到目标会话时,先补一条本地历史,并异步触发 `loadHistories`
* 让服务端数据随后校准。
*
* 输入约定:
* - 默认同步当前 runtime 的 `appId/chatId`。
* - 恢复生成可能在异步完成时已经切换页面,因此允许通过 `targetAppId/targetChatId`
* 明确指定要同步的历史项。
*
* 边界行为:
* - 如果目标 app 已不是当前 app,直接跳过,避免跨 app 把历史状态写错。
* - `hasBeenRead` 未显式传入时,只有 generating 默认未读,其它状态默认已读。
*/
export const useSidebarChatGenerateStatus = () => {
const { t } = useTranslation();
const appId = useContextSelector(WorkflowRuntimeContext, (v) => v.appId);
const chatId = useContextSelector(WorkflowRuntimeContext, (v) => v.chatId);
const chatTitle = useContextSelector(ChatItemContext, (v) => v.chatBoxData?.title);
const setHistories = useContextSelector(ChatContext, (v) => v.setHistories);
const loadHistories = useContextSelector(ChatContext, (v) => v.loadHistories);
return useMemoizedFn(
(
status: ChatGenerateStatusEnum,
options?: {
hasBeenRead?: boolean;
targetAppId?: string;
targetChatId?: string;
title?: string;
}
) => {
const targetAppId = options?.targetAppId ?? appId;
if (targetAppId !== appId) return;
const targetChatId = options?.targetChatId ?? chatId;
if (!targetChatId) return;
setHistories((prev) => {
const idx = prev.findIndex((h) => h.chatId === targetChatId && h.appId === targetAppId);
if (idx === -1) {
queueMicrotask(loadHistories);
return [
{
chatId: targetChatId,
appId: targetAppId,
title: options?.title || chatTitle || t('common:core.chat.New Chat'),
customTitle: '',
top: false,
updateTime: new Date(),
chatGenerateStatus: status,
hasBeenRead: options?.hasBeenRead ?? status !== ChatGenerateStatusEnum.generating
},
...prev
];
}
return prev.map((h) =>
h.chatId === targetChatId && h.appId === targetAppId
? {
...h,
chatGenerateStatus: status,
updateTime: new Date(),
...(options?.hasBeenRead !== undefined ? { hasBeenRead: options.hasBeenRead } : {})
}
: h
);
});
}
);
};
import { useEffect, type RefObject } from 'react';
/**
* 监听聊天滚动容器,判断变量输入区是否处于可视区域。
*
* ChatBox 外层需要知道变量表单是否可见,用于同步其它入口的变量展示状态。
* 这个 hook 只负责 DOM 可见性观测,不负责渲染变量表单,也不改变变量值。
*
* 输入约定:
* - `ScrollContainerRef` 必须绑定聊天滚动容器。
* - 变量表单节点继续沿用 `#variable-input` 作为查询锚点,避免 PR 2 同时改动 UI 结构。
* - `setIsVariableVisible` 来自 ChatItemContext,负责把可见性同步给外层。
*
* 边界行为:
* - 容器未挂载、变量表单不存在或变量表单高度为 0 时,不更新外层状态。
* - 初次挂载时立即检查一次,之后只在滚动事件中重新计算。
* - effect cleanup 会移除当前容器上的 listener,避免切换 chat 或卸载时残留监听。
*/
export const useVariableInputVisibility = ({
ScrollContainerRef,
setIsVariableVisible
}: {
ScrollContainerRef: RefObject<HTMLDivElement>;
setIsVariableVisible: (visible: boolean) => void;
}) => {
useEffect(() => {
const checkVariableVisibility = () => {
if (!ScrollContainerRef.current) return;
const container = ScrollContainerRef.current;
// 继续使用现有 DOM id 作为边界,避免变量表单组件被迫感知这个 hook。
const variableInput = container.querySelector('#variable-input');
if (!variableInput) return;
const containerRect = container.getBoundingClientRect();
const elementRect = variableInput.getBoundingClientRect();
// 高度为 0 通常表示变量区域被折叠或尚未完成布局,此时不写 false,
// 避免短暂布局状态把外层可见性误置为不可见。
if (elementRect.height === 0) return;
setIsVariableVisible(
containerRect.top < elementRect.bottom && containerRect.bottom > elementRect.top
);
};
const container = ScrollContainerRef.current;
if (container) {
checkVariableVisibility();
container.addEventListener('scroll', checkVariableVisibility);
return () => {
container.removeEventListener('scroll', checkVariableVisibility);
};
}
}, [ScrollContainerRef, setIsVariableVisible]);
};
import { ChatGenerateStatusEnum, ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; import { ChatGenerateStatusEnum, ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
import type { ChatSiteItemType } from './type'; import type { ChatSiteItemType } from '../type';
type ChatRoundStatusItem = Pick<ChatSiteItemType, 'obj' | 'status'>; type ChatRoundStatusItem = Pick<ChatSiteItemType, 'obj' | 'status'>;
......
import type {
ChatItemValueItemType,
UserChatItemValueItemType
} from '@fastgpt/global/core/chat/type';
import { type ChatBoxInputType, type UserInputFileItemType } from '../type';
import { getFileIcon } from '@fastgpt/global/common/file/icon';
export const formatChatValue2InputType = (value?: ChatItemValueItemType[]): ChatBoxInputType => {
if (!value) {
return { text: '', files: [] };
}
if (!Array.isArray(value)) {
console.error('value is error', value);
return { text: '', files: [] };
}
const text = value
.filter((item) => item.text?.content)
.map((item) => item.text?.content || '')
.join('');
const files =
(value
?.map((item) =>
'file' in item && item.file
? {
id: item.file.url,
type: item.file.type,
name: item.file.name,
icon: getFileIcon(item.file.name),
url: item.file.url,
key: item.file.key
}
: undefined
)
.filter(Boolean) as UserInputFileItemType[]) || [];
return {
text,
files
};
};
export const stripChatValueFileUrls = (value: UserChatItemValueItemType[] = []) =>
value.map((item) => {
if ('file' in item && item.file?.key) {
return {
...item,
file: {
...item.file,
url: ''
}
};
}
return item;
});
import { ChatFileTypeEnum } from '@fastgpt/global/core/chat/constants';
export const getUploadChatFileType = (file: File) => {
if (file.type.includes('image')) return ChatFileTypeEnum.image;
if (file.type.includes('audio')) return ChatFileTypeEnum.audio;
if (file.type.includes('video')) return ChatFileTypeEnum.video;
return ChatFileTypeEnum.file;
};
import type { ChatHistoryItemResType } from '@fastgpt/global/core/chat/type';
import { ChatRoleEnum, ChatStatusEnum } from '@fastgpt/global/core/chat/constants';
import {
extractDeepestInteractive,
getLastInteractiveValue
} from '@fastgpt/global/core/workflow/runtime/utils';
import type { WorkflowInteractiveResponseType } from '@fastgpt/global/core/workflow/template/system/interactive/type';
import { checkInteractiveResponseStatus } from '@fastgpt/global/core/chat/utils';
import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { normalizeFormInputResultFile } from '../../../components/FormInputResult';
import type { ChatSiteItemType } from '../type';
/**
* 恢复流收到 `flowNodeResponse` 且带 `formInputResult` 时,把节点结果写回已提交的表单交互节点。
*
* 匹配目标交互节点(二者满足其一即可):
* 1. `entryNodeIds` 包含 `nodeResponse.nodeId`;
* 2. 全历史仅有一个 submitted 表单交互,且其字段 key 与 `formInputResult` 有交集(dataId 变化时的兜底)。
*
* `fileSelect` 字段会把 URL 字符串数组归一化为 `{ name, url }[]`(复用 `normalizeFormInputResultFile`)。
* 无任何字段更新时返回原 `histories` 引用,避免触发多余渲染。
*/
export const refreshSubmittedFormInteractiveValues = ({
histories,
nodeResponse
}: {
histories: ChatSiteItemType[];
nodeResponse: ChatHistoryItemResType;
}): ChatSiteItemType[] => {
const formInputResult = nodeResponse.formInputResult;
if (!formInputResult || typeof formInputResult !== 'object' || Array.isArray(formInputResult)) {
return histories;
}
const formInputValueMap = formInputResult as Record<string, unknown>;
const formInputKeys = Object.keys(formInputValueMap);
const submittedFormInteractiveCount = histories.reduce((count, history) => {
if (history.obj !== ChatRoleEnum.AI) return count;
return (
count +
history.value.filter((value) => {
if (!value.interactive) return false;
const finalInteractive = extractDeepestInteractive(value.interactive);
return finalInteractive.type === 'userInput' && !!finalInteractive.params.submitted;
}).length
);
}, 0);
let hasUpdated = false;
const nextHistories = histories.map((history) => {
if (history.obj !== ChatRoleEnum.AI) return history;
const nextValues = history.value.map((value) => {
if (!value.interactive) return value;
const finalInteractive = extractDeepestInteractive(value.interactive);
if (finalInteractive.type !== 'userInput') {
return value;
}
if (!finalInteractive.params.submitted) return value;
// 优先 nodeId 精确匹配;仅一个 submitted 表单时允许 key 交集兜底(覆盖 dataId 漂移)。
const matchedByNodeId = finalInteractive.entryNodeIds?.includes(nodeResponse.nodeId);
const matchedByOnlySubmittedForm =
submittedFormInteractiveCount === 1 &&
finalInteractive.params.inputForm.some((input) => formInputKeys.includes(input.key));
if (!matchedByNodeId && !matchedByOnlySubmittedForm) return value;
const nextInputForm = finalInteractive.params.inputForm.map((input) => {
if (!(input.key in formInputValueMap)) return input;
const nextValue = (() => {
const responseValue = formInputValueMap[input.key];
if (input.type !== FlowNodeInputTypeEnum.fileSelect || !Array.isArray(responseValue)) {
return responseValue;
}
return responseValue
.map(normalizeFormInputResultFile)
.filter((file): file is NonNullable<ReturnType<typeof normalizeFormInputResultFile>> =>
Boolean(file)
);
})();
hasUpdated = true;
return {
...input,
value: nextValue
};
});
return {
...value,
interactive: {
...finalInteractive,
params: {
...finalInteractive.params,
inputForm: nextInputForm,
submitted: true
}
}
};
});
return {
...history,
value: nextValues
};
});
return hasUpdated ? nextHistories : histories;
};
// 用于判断当前对话框状态。如果是 child interactive,需要递归找到最深层交互。
export const getInteractiveByHistories = (
chatHistories: ChatSiteItemType[]
): {
interactive: WorkflowInteractiveResponseType | undefined;
canSendQuery: boolean;
} => {
const lastInreactive = getLastInteractiveValue(chatHistories);
if (!lastInreactive) {
return {
interactive: undefined,
canSendQuery: true
};
}
const finalInteractive = extractDeepestInteractive(lastInreactive);
// 如果用户已经完成选择,则不认为是交互模式,允许发起新一轮对话。
if (finalInteractive.type === 'userSelect' && !finalInteractive.params.userSelectedVal) {
return {
interactive: finalInteractive,
canSendQuery: false
};
} else if (finalInteractive.type === 'userInput' && !finalInteractive.params.submitted) {
return {
interactive: finalInteractive,
canSendQuery: false
};
} else if (finalInteractive.type === 'paymentPause' && !finalInteractive.params.continue) {
return {
interactive: finalInteractive,
canSendQuery: false
};
} else if (finalInteractive.type === 'agentPlanAskQuery') {
return {
interactive: finalInteractive,
canSendQuery: true
};
}
return {
interactive: undefined,
canSendQuery: true
};
};
export const rewriteHistoriesByInteractiveResponse = ({
histories,
interactiveVal,
interactive
}: {
histories: ChatSiteItemType[];
interactiveVal: string;
interactive: WorkflowInteractiveResponseType;
}): ChatSiteItemType[] => {
const status = checkInteractiveResponseStatus({
interactive,
input: interactiveVal
});
const formatHistories = (() => {
if (status === 'query') {
return histories;
}
return histories.slice(0, -2);
})();
const newHistories = formatHistories.map((item, i) => {
if (i !== formatHistories.length - 1) return item;
const value = item.value.map((val, i) => {
if (i !== item.value.length - 1) {
return val;
}
if (!('interactive' in val) || !val.interactive) return val;
const finalInteractive = extractDeepestInteractive(val.interactive);
if (finalInteractive.type === 'userSelect') {
return {
...val,
interactive: {
...finalInteractive,
params: {
...finalInteractive.params,
userSelectedVal: finalInteractive.params.userSelectOptions.find(
(item) => item.value === interactiveVal
)?.value
}
}
};
}
if (finalInteractive.type === 'userInput') {
const submittedData: Record<string, any> = (() => {
try {
return JSON.parse(interactiveVal);
} catch {
return {};
}
})();
// 更新 inputForm 中的 value。
const updatedInputForm = finalInteractive.params.inputForm.map((item) => ({
...item,
value: submittedData[item.key] ?? item.value
}));
return {
...val,
interactive: {
...finalInteractive,
params: {
...finalInteractive.params,
inputForm: updatedInputForm,
submitted: true
}
}
};
}
if (finalInteractive.type === 'paymentPause') {
return {
...val,
interactive: {
...finalInteractive,
params: {
...finalInteractive.params,
continue: true
}
}
};
}
return val;
});
return {
...item,
status: ChatStatusEnum.loading,
value
} as ChatSiteItemType;
});
return newHistories;
};
import { ChatTypeEnum } from '../constants';
import type { ChatSiteItemType } from '../type';
/**
* 为 log 模式的连续删除消息补充折叠元信息。
*
* 输入输出约定:
* - 非 log 模式不需要删除记录折叠,直接返回原 records 引用,避免无意义重算。
* - log 模式会把相邻的 `deleteTime` 消息视为一个删除组,并在组首补
* `collapseTop`、组尾补 `collapseBottom`,供列表渲染折叠开关。
* - 删除组内的消息会被浅拷贝,避免把折叠展示字段写回原始 `chatRecords`。
* - 非删除消息仍保持原对象引用,减少渲染层不必要的对象变化。
*
* 边界行为:
* - 单条删除消息会同时拥有 `collapseTop` 和 `collapseBottom`。
* - 只有删除组内所有 dataId 都在 `expandedDeletedGroups` 中时,该组才算展开。
* - 连续删除组可以出现在列表开头、结尾或中间,统一通过前后 deleteTime 判断边界。
*/
export const getProcessedChatRecords = ({
chatType,
chatRecords,
expandedDeletedGroups
}: {
chatType: ChatTypeEnum;
chatRecords: ChatSiteItemType[];
expandedDeletedGroups: Set<string>;
}): ChatSiteItemType[] => {
if (chatType !== ChatTypeEnum.log) {
return chatRecords;
}
const result: ChatSiteItemType[] = [];
let currentGroup: {
items: ChatSiteItemType[];
dataIds: string[];
} | null = null;
chatRecords.forEach((item, index) => {
const isDeleted = !!item.deleteTime;
const prevIsDeleted = index > 0 ? !!chatRecords[index - 1].deleteTime : false;
const nextIsDeleted =
index < chatRecords.length - 1 ? !!chatRecords[index + 1].deleteTime : false;
// 当前 deleted item 的前一条不是 deleted,说明进入了一个新的连续删除组。
if (isDeleted && !prevIsDeleted) {
currentGroup = {
items: [],
dataIds: []
};
}
// 删除组内保留原始 item,等组结束时统一浅拷贝并补充首尾折叠信息。
if (currentGroup && isDeleted) {
currentGroup.items.push(item);
currentGroup.dataIds.push(item.dataId);
}
// 当前 deleted item 后面不再是 deleted,或已经到列表末尾,说明删除组结束。
if (currentGroup && (!nextIsDeleted || index === chatRecords.length - 1)) {
const isExpanded = currentGroup.dataIds.every((id) => expandedDeletedGroups.has(id));
const count = currentGroup.dataIds.length;
currentGroup.items.forEach((groupItem, groupIndex) => {
const extendedItem: ChatSiteItemType = { ...groupItem };
// 组首渲染顶部折叠入口;单条删除消息也会命中该分支。
if (groupIndex === 0) {
extendedItem.collapseTop = {
count,
dataIds: currentGroup!.dataIds,
isExpanded
};
}
// 组尾渲染底部折叠入口;单条删除消息会同时拥有 top 和 bottom。
if (groupIndex === currentGroup!.items.length - 1) {
extendedItem.collapseBottom = {
count,
dataIds: currentGroup!.dataIds,
isExpanded
};
}
result.push(extendedItem);
});
currentGroup = null;
} else if (!isDeleted) {
// 非删除消息不参与折叠分组,保持原对象引用。
result.push(item);
}
});
return result;
};
import type { VariableItemType } from '@fastgpt/global/core/app/type';
import { VariableInputEnum } from '@fastgpt/global/core/workflow/constants';
import { valueTypeFormat } from '@fastgpt/global/core/workflow/runtime/utils';
import { formatTime2YMDHMS } from '@fastgpt/global/common/string/time';
/**
* 将变量表单值转换为发送聊天请求时的 variables。
*
* 输入输出约定:
* - `variableList` 是唯一可信的变量声明来源,返回值只包含声明过的 key。
* - `variables` 是 react-hook-form 收集到的用户输入,可能包含空值或额外字段。
* - 空字符串、null、undefined 统一回退到变量配置里的 `defaultValue`。
* - 返回值会按变量的 `valueType` 走 `valueTypeFormat`,与 workflow runtime 期望的
* string/number/boolean/object/array 类型对齐。
*
* 时间变量的特殊处理:
* - timePointSelect 和 timeRangeSelect 在表单层可能是 Date 可解析值。
* - 发给 workflow 前要先转成 `YYYY-MM-DD HH:mm:ss` 字符串,再做 valueType 格式化。
* - timeRangeSelect 中的空字符串保留为空字符串,用于表达未选择的边界。
*/
export const formatChatRequestVariables = ({
variableList,
variables = {}
}: {
variableList?: VariableItemType[];
variables?: Record<string, any>;
}) => {
const requestVariables: Record<string, any> = {};
variableList?.forEach((item) => {
// 只处理变量配置声明过的 key;未声明字段不会进入 requestVariables。
let val =
variables[item.key] === '' ||
variables[item.key] === undefined ||
variables[item.key] === null
? item.defaultValue
: variables[item.key];
// 时间变量先统一成 workflow 可直接消费的本地时间字符串。
if (item.type === VariableInputEnum.timePointSelect && val) {
val = formatTime2YMDHMS(new Date(val));
} else if (item.type === VariableInputEnum.timeRangeSelect && val) {
val = val.map((item: string) => (item ? formatTime2YMDHMS(new Date(item)) : ''));
}
// 最后按 valueType 收敛类型,避免表单字符串直接进入 workflow runtime。
requestVariables[item.key] = valueTypeFormat(val, item.valueType);
});
return requestVariables;
};
...@@ -17,7 +17,6 @@ import type { AgentPlanStatusType, AgentPlanType } from '@fastgpt/global/core/ai ...@@ -17,7 +17,6 @@ import type { AgentPlanStatusType, AgentPlanType } from '@fastgpt/global/core/ai
export type generatingMessageProps = { export type generatingMessageProps = {
event: SseResponseEventEnum; event: SseResponseEventEnum;
responseValueId?: string; responseValueId?: string;
stepId?: string;
text?: string; text?: string;
reasoningText?: string; reasoningText?: string;
......
import React from 'react'; import React from 'react';
import type { HelperBotChatItemSiteType } from '@fastgpt/global/core/chat/helperBot/type'; import { formatChatValue2InputType } from '../../ChatContainer/ChatBox/utils/chatValue';
import { formatChatValue2InputType } from '../../ChatContainer/ChatBox/utils'; import { Box, Flex } from '@chakra-ui/react';
import { Box, Card, Flex } from '@chakra-ui/react';
import Markdown from '@/components/Markdown';
import FileBlock from '../../ChatContainer/ChatBox/components/FilesBox'; import FileBlock from '../../ChatContainer/ChatBox/components/FilesBox';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import { useTranslation } from 'next-i18next';
import { useCopyData } from '@fastgpt/web/hooks/useCopyData';
import MyIconButton from '@fastgpt/web/components/common/Icon/button';
import type { UserChatItemType } from '@fastgpt/global/core/chat/type'; import type { UserChatItemType } from '@fastgpt/global/core/chat/type';
import ChatAvatar from '@/components/core/chat/ChatContainer/ChatBox/components/ChatAvatar'; import ChatAvatar from '@/components/core/chat/ChatContainer/ChatBox/components/ChatAvatar';
import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
...@@ -15,8 +9,6 @@ import { useUserStore } from '@/web/support/user/useUserStore'; ...@@ -15,8 +9,6 @@ import { useUserStore } from '@/web/support/user/useUserStore';
import { getWebReqUrl } from '@fastgpt/web/common/system/utils'; import { getWebReqUrl } from '@fastgpt/web/common/system/utils';
const HumanItem = ({ chat }: { chat: UserChatItemType }) => { const HumanItem = ({ chat }: { chat: UserChatItemType }) => {
const { t } = useTranslation();
const { copyData } = useCopyData();
const { text, files = [] } = formatChatValue2InputType(chat.value); const { text, files = [] } = formatChatValue2InputType(chat.value);
const userInfo = useUserStore((state) => state.userInfo); const userInfo = useUserStore((state) => state.userInfo);
const humanAvatar = userInfo?.avatar || getWebReqUrl('/imgs/botClosed.svg'); const humanAvatar = userInfo?.avatar || getWebReqUrl('/imgs/botClosed.svg');
......
...@@ -17,7 +17,7 @@ import type { HelperBotTypeEnumType } from '@fastgpt/global/core/chat/helperBot/ ...@@ -17,7 +17,7 @@ import type { HelperBotTypeEnumType } from '@fastgpt/global/core/chat/helperBot/
import { getHelperBotFilePresign } from '../api'; import { getHelperBotFilePresign } from '../api';
import { useRequest } from '@fastgpt/web/hooks/useRequest'; import { useRequest } from '@fastgpt/web/hooks/useRequest';
import { putFileToS3 } from '@fastgpt/web/common/file/utils'; import { putFileToS3 } from '@fastgpt/web/common/file/utils';
import { getUploadChatFileType } from '../../ChatContainer/ChatBox/utils'; import { getUploadChatFileType } from '../../ChatContainer/ChatBox/utils/file';
type UseFileUploadOptions = { type UseFileUploadOptions = {
fileSelectConfig?: AppFileSelectConfigType; fileSelectConfig?: AppFileSelectConfigType;
......
...@@ -63,7 +63,6 @@ const shouldSendStreamResumeHeader = (url: string) => ...@@ -63,7 +63,6 @@ const shouldSendStreamResumeHeader = (url: string) =>
type CommonResponseType = { type CommonResponseType = {
responseValueId?: string; responseValueId?: string;
stepId?: string;
}; };
type ResponseQueueItemType = CommonResponseType & type ResponseQueueItemType = CommonResponseType &
( (
...@@ -124,7 +123,7 @@ function handleEventSourceData(params: HandleEventSourceDataParams) { ...@@ -124,7 +123,7 @@ function handleEventSourceData(params: HandleEventSourceDataParams) {
const parsed: any = JSON.parse(data); const parsed: any = JSON.parse(data);
if (typeof parsed !== 'object') throw new Error('Invalid JSON'); if (typeof parsed !== 'object') throw new Error('Invalid JSON');
const { responseValueId, stepId, ...obj } = parsed; const { responseValueId, ...obj } = parsed;
switch (event) { switch (event) {
case SseResponseEventEnum.toolCall: case SseResponseEventEnum.toolCall:
...@@ -134,22 +133,22 @@ function handleEventSourceData(params: HandleEventSourceDataParams) { ...@@ -134,22 +133,22 @@ function handleEventSourceData(params: HandleEventSourceDataParams) {
case SseResponseEventEnum.plan: case SseResponseEventEnum.plan:
case SseResponseEventEnum.planStatus: case SseResponseEventEnum.planStatus:
case SseResponseEventEnum.skillCall: { case SseResponseEventEnum.skillCall: {
enqueue({ responseValueId, stepId, event, ...obj }); enqueue({ responseValueId, event, ...obj });
break; break;
} }
case SseResponseEventEnum.answer: { case SseResponseEventEnum.answer: {
const reasoningText = obj.choices?.[0]?.delta?.reasoning_content || ''; const reasoningText = obj.choices?.[0]?.delta?.reasoning_content || '';
enqueue({ responseValueId, stepId, event, reasoningText }); enqueue({ responseValueId, event, reasoningText });
const content = obj.choices?.[0]?.delta?.content || ''; const content = obj.choices?.[0]?.delta?.content || '';
if (splitAnswerTextByCharacter) { if (splitAnswerTextByCharacter) {
for (const item of content) { for (const item of content) {
enqueue({ responseValueId, stepId, event, text: item }); enqueue({ responseValueId, event, text: item });
} }
} else { } else {
enqueue({ responseValueId, stepId, event, text: content }); enqueue({ responseValueId, event, text: content });
} }
break; break;
...@@ -157,10 +156,10 @@ function handleEventSourceData(params: HandleEventSourceDataParams) { ...@@ -157,10 +156,10 @@ function handleEventSourceData(params: HandleEventSourceDataParams) {
case SseResponseEventEnum.fastAnswer: { case SseResponseEventEnum.fastAnswer: {
const reasoningText = obj.choices?.[0]?.delta?.reasoning_content || ''; const reasoningText = obj.choices?.[0]?.delta?.reasoning_content || '';
enqueue({ responseValueId, stepId, event, reasoningText }); enqueue({ responseValueId, event, reasoningText });
const text = obj.choices?.[0]?.delta?.content || ''; const text = obj.choices?.[0]?.delta?.content || '';
enqueue({ responseValueId, stepId, event, text }); enqueue({ responseValueId, event, text });
break; break;
} }
......
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { ChatGenerateStatusEnum, ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; import { ChatGenerateStatusEnum, ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
import { isChatRoundPending } from '@/components/core/chat/ChatContainer/ChatBox/chatStatus'; import { isChatRoundPending } from '@/components/core/chat/ChatContainer/ChatBox/utils/chatStatus';
describe('isChatRoundPending', () => { describe('isChatRoundPending', () => {
it('returns true while the local chat is streaming', () => { it('returns true while the local chat is streaming', () => {
......
import { describe, expect, it, vi } from 'vitest';
import { ChatFileTypeEnum } from '@fastgpt/global/core/chat/constants';
import {
formatChatValue2InputType,
stripChatValueFileUrls
} from '@/components/core/chat/ChatContainer/ChatBox/utils/chatValue';
import type { ChatItemValueItemType } from '@fastgpt/global/core/chat/type';
describe('formatChatValue2InputType', () => {
it('joins text fragments and converts file values into input files', () => {
const value: ChatItemValueItemType[] = [
{
file: {
type: ChatFileTypeEnum.image,
name: 'image.png',
url: 'https://example.com/image.png',
key: 'chat/image.png'
}
},
{
text: {
content: 'hello '
}
},
{
text: {
content: 'FastGPT'
}
}
];
const result = formatChatValue2InputType(value);
expect(result.text).toBe('hello FastGPT');
expect(result.files).toHaveLength(1);
expect(result.files[0]).toMatchObject({
id: 'https://example.com/image.png',
type: ChatFileTypeEnum.image,
name: 'image.png',
url: 'https://example.com/image.png',
key: 'chat/image.png'
});
expect(result.files[0].icon).toBeTruthy();
});
it('returns an empty input for missing values', () => {
expect(formatChatValue2InputType()).toEqual({
text: '',
files: []
});
});
it('guards against non-array values', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(formatChatValue2InputType({ text: { content: 'bad' } } as any)).toEqual({
text: '',
files: []
});
expect(consoleSpy).toHaveBeenCalledWith('value is error', {
text: {
content: 'bad'
}
});
consoleSpy.mockRestore();
});
});
describe('stripChatValueFileUrls', () => {
it('removes signed urls only from keyed files before sending messages', () => {
const value: ChatItemValueItemType[] = [
{
file: {
type: ChatFileTypeEnum.image,
name: 'image.png',
key: 'chat/files/image.png',
url: 'https://preview.example.com/image.png'
}
},
{
file: {
type: ChatFileTypeEnum.file,
name: 'external.pdf',
url: 'https://external.example.com/external.pdf'
}
},
{
text: {
content: 'hello'
}
}
];
expect(stripChatValueFileUrls(value)).toEqual([
{
file: {
type: ChatFileTypeEnum.image,
name: 'image.png',
key: 'chat/files/image.png',
url: ''
}
},
{
file: {
type: ChatFileTypeEnum.file,
name: 'external.pdf',
url: 'https://external.example.com/external.pdf'
}
},
{
text: {
content: 'hello'
}
}
]);
expect(value[0].file.url).toBe('https://preview.example.com/image.png');
});
it('returns an empty array for missing values', () => {
expect(stripChatValueFileUrls()).toEqual([]);
});
});
import { describe, expect, it } from 'vitest';
import { ChatFileTypeEnum } from '@fastgpt/global/core/chat/constants';
import { getUploadChatFileType } from '@/components/core/chat/ChatContainer/ChatBox/utils/file';
describe('getUploadChatFileType', () => {
it('detects image, audio and video mime types', () => {
expect(getUploadChatFileType({ type: 'image/png' } as File)).toBe(ChatFileTypeEnum.image);
expect(getUploadChatFileType({ type: 'audio/mpeg' } as File)).toBe(ChatFileTypeEnum.audio);
expect(getUploadChatFileType({ type: 'video/mp4' } as File)).toBe(ChatFileTypeEnum.video);
});
it('falls back to common file type for unknown or empty mime types', () => {
expect(getUploadChatFileType({ type: 'application/pdf' } as File)).toBe(ChatFileTypeEnum.file);
expect(getUploadChatFileType({ type: '' } as File)).toBe(ChatFileTypeEnum.file);
});
});
import { describe, expect, it } from 'vitest';
import { ChatRoleEnum, ChatStatusEnum } from '@fastgpt/global/core/chat/constants';
import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants';
import type { WorkflowInteractiveResponseType } from '@fastgpt/global/core/workflow/template/system/interactive/type';
import type { ChatSiteItemType } from '@/components/core/chat/ChatContainer/ChatBox/type';
import {
getInteractiveByHistories,
rewriteHistoriesByInteractiveResponse
} from '@/components/core/chat/ChatContainer/ChatBox/utils/interactive';
const baseInteractive = {
entryNodeIds: ['node-1'],
memoryEdges: [],
nodeOutputs: [],
usageId: 'usage-1'
};
const createAiRecord = (
interactive?: WorkflowInteractiveResponseType,
override: Partial<ChatSiteItemType> = {}
): ChatSiteItemType =>
({
id: override.id ?? 'ai-1',
dataId: override.dataId ?? 'ai-1',
obj: ChatRoleEnum.AI,
status: ChatStatusEnum.finish,
value: interactive
? [
{
interactive
}
]
: [
{
text: {
content: 'done'
}
}
],
...override
}) as ChatSiteItemType;
const createHumanRecord = (id = 'human-1'): ChatSiteItemType =>
({
id,
dataId: id,
obj: ChatRoleEnum.Human,
status: ChatStatusEnum.finish,
value: [
{
text: {
content: id
}
}
]
}) as ChatSiteItemType;
const createAiPlaceholder = (id = 'ai-placeholder'): ChatSiteItemType =>
createAiRecord(undefined, {
id,
dataId: id,
status: ChatStatusEnum.loading,
value: [
{
text: {
content: ''
}
}
]
});
const createUserSelectInteractive = (userSelectedVal?: string): WorkflowInteractiveResponseType =>
({
...baseInteractive,
type: 'userSelect',
params: {
description: 'choose one',
userSelectOptions: [
{
key: 'A',
value: 'A'
},
{
key: 'B',
value: 'B'
}
],
userSelectedVal
}
}) as WorkflowInteractiveResponseType;
const createUserInputInteractive = (submitted = false): WorkflowInteractiveResponseType =>
({
...baseInteractive,
type: 'userInput',
params: {
description: 'fill form',
submitted,
inputForm: [
{
type: FlowNodeInputTypeEnum.input,
key: 'name',
label: 'Name',
value: '',
valueType: WorkflowIOValueTypeEnum.string,
required: false
}
]
}
}) as WorkflowInteractiveResponseType;
describe('getInteractiveByHistories', () => {
it('allows normal chat when no pending interactive exists', () => {
expect(getInteractiveByHistories([createAiRecord()])).toEqual({
interactive: undefined,
canSendQuery: true
});
});
it('blocks normal query for unselected userSelect interactive', () => {
const interactive = createUserSelectInteractive();
expect(getInteractiveByHistories([createAiRecord(interactive)])).toEqual({
interactive,
canSendQuery: false
});
});
it('allows normal query after userSelect has been answered', () => {
expect(getInteractiveByHistories([createAiRecord(createUserSelectInteractive('A'))])).toEqual({
interactive: undefined,
canSendQuery: true
});
});
it('allows sending a query while preserving agent plan ask interactive', () => {
const interactive = {
...baseInteractive,
type: 'agentPlanAskQuery',
params: {
content: 'Need more detail',
options: ['A', 'B', 'C']
}
} as WorkflowInteractiveResponseType;
expect(getInteractiveByHistories([createAiRecord(interactive)])).toEqual({
interactive,
canSendQuery: true
});
});
});
describe('rewriteHistoriesByInteractiveResponse', () => {
it('writes userSelect answer into the previous interactive and removes temporary round records', () => {
const interactive = createUserSelectInteractive();
const result = rewriteHistoriesByInteractiveResponse({
histories: [createAiRecord(interactive), createHumanRecord(), createAiPlaceholder()],
interactive,
interactiveVal: 'B'
});
expect(result).toHaveLength(1);
expect(result[0].status).toBe(ChatStatusEnum.loading);
expect((result[0].value[0] as any).interactive.params.userSelectedVal).toBe('B');
});
it('writes parsed userInput values into the submitted form', () => {
const interactive = createUserInputInteractive();
const result = rewriteHistoriesByInteractiveResponse({
histories: [createAiRecord(interactive), createHumanRecord(), createAiPlaceholder()],
interactive,
interactiveVal: JSON.stringify({
name: 'FastGPT'
})
});
expect(result).toHaveLength(1);
expect((result[0].value[0] as any).interactive.params.submitted).toBe(true);
expect((result[0].value[0] as any).interactive.params.inputForm[0].value).toBe('FastGPT');
});
it('marks paymentPause as continued and removes temporary round records', () => {
const interactive = {
...baseInteractive,
type: 'paymentPause',
params: {
description: 'insufficient points'
}
} as WorkflowInteractiveResponseType;
const result = rewriteHistoriesByInteractiveResponse({
histories: [createAiRecord(interactive), createHumanRecord(), createAiPlaceholder()],
interactive,
interactiveVal: ''
});
expect(result).toHaveLength(1);
expect((result[0].value[0] as any).interactive.params.continue).toBe(true);
});
it('keeps the temporary user round when agentPlanAskQuery becomes a normal query', () => {
const interactive = {
...baseInteractive,
type: 'agentPlanAskQuery',
params: {
content: 'Need more detail',
options: ['A', 'B', 'C']
}
} as WorkflowInteractiveResponseType;
const histories = [createAiRecord(interactive), createHumanRecord(), createAiPlaceholder()];
const result = rewriteHistoriesByInteractiveResponse({
histories,
interactive,
interactiveVal: 'new user question'
});
expect(result).toHaveLength(3);
expect(result[1]).toBe(histories[1]);
expect(result[2]).toEqual({
...histories[2],
status: ChatStatusEnum.loading
});
});
});
import { describe, expect, it } from 'vitest';
import { ChatRoleEnum, ChatStatusEnum } from '@fastgpt/global/core/chat/constants';
import { ChatTypeEnum } from '@/components/core/chat/ChatContainer/ChatBox/constants';
import { getProcessedChatRecords } from '@/components/core/chat/ChatContainer/ChatBox/utils/recordGroups';
import type { ChatSiteItemType } from '@/components/core/chat/ChatContainer/ChatBox/type';
const createRecord = ({
dataId,
deleteTime
}: {
dataId: string;
deleteTime?: Date | null;
}): ChatSiteItemType => ({
id: dataId,
dataId,
obj: ChatRoleEnum.Human,
value: [
{
text: {
content: dataId
}
}
],
status: ChatStatusEnum.finish,
deleteTime
});
describe('getProcessedChatRecords', () => {
it('returns original records for non-log chat types', () => {
const records = [createRecord({ dataId: 'normal-1', deleteTime: new Date() })];
const result = getProcessedChatRecords({
chatType: ChatTypeEnum.chat,
chatRecords: records,
expandedDeletedGroups: new Set()
});
expect(result).toBe(records);
expect(result[0].collapseTop).toBeUndefined();
});
it('adds collapse metadata to each continuous deleted group in log mode', () => {
const deletedAt = new Date('2026-05-19T00:00:00.000Z');
const records = [
createRecord({ dataId: 'normal-1' }),
createRecord({ dataId: 'deleted-1', deleteTime: deletedAt }),
createRecord({ dataId: 'deleted-2', deleteTime: deletedAt }),
createRecord({ dataId: 'normal-2' }),
createRecord({ dataId: 'deleted-3', deleteTime: deletedAt })
];
const result = getProcessedChatRecords({
chatType: ChatTypeEnum.log,
chatRecords: records,
expandedDeletedGroups: new Set(['deleted-1', 'deleted-2'])
});
expect(result).toHaveLength(records.length);
expect(result[0]).toBe(records[0]);
expect(result[3]).toBe(records[3]);
expect(result[1]).not.toBe(records[1]);
expect(result[1].collapseTop).toEqual({
count: 2,
dataIds: ['deleted-1', 'deleted-2'],
isExpanded: true
});
expect(result[1].collapseBottom).toBeUndefined();
expect(result[2]).not.toBe(records[2]);
expect(result[2].collapseTop).toBeUndefined();
expect(result[2].collapseBottom).toEqual({
count: 2,
dataIds: ['deleted-1', 'deleted-2'],
isExpanded: true
});
expect(result[4].collapseTop).toEqual({
count: 1,
dataIds: ['deleted-3'],
isExpanded: false
});
expect(result[4].collapseBottom).toEqual({
count: 1,
dataIds: ['deleted-3'],
isExpanded: false
});
expect(records[1].collapseTop).toBeUndefined();
expect(records[2].collapseBottom).toBeUndefined();
});
it('marks a deleted group as collapsed until every item in the group is expanded', () => {
const deletedAt = new Date('2026-05-19T00:00:00.000Z');
const records = [
createRecord({ dataId: 'deleted-1', deleteTime: deletedAt }),
createRecord({ dataId: 'deleted-2', deleteTime: deletedAt })
];
const result = getProcessedChatRecords({
chatType: ChatTypeEnum.log,
chatRecords: records,
expandedDeletedGroups: new Set(['deleted-1'])
});
expect(result[0].collapseTop?.isExpanded).toBe(false);
expect(result[1].collapseBottom?.isExpanded).toBe(false);
});
});
import { describe, expect, it } from 'vitest';
import type { VariableItemType } from '@fastgpt/global/core/app/type';
import {
VariableInputEnum,
WorkflowIOValueTypeEnum
} from '@fastgpt/global/core/workflow/constants';
import { formatTime2YMDHMS } from '@fastgpt/global/common/string/time';
import { formatChatRequestVariables } from '@/components/core/chat/ChatContainer/ChatBox/utils/requestVariables';
const createVariable = (override: Partial<VariableItemType>): VariableItemType =>
({
key: 'key',
label: 'label',
description: '',
type: VariableInputEnum.input,
valueType: WorkflowIOValueTypeEnum.string,
required: false,
defaultValue: '',
...override
}) as VariableItemType;
describe('formatChatRequestVariables', () => {
it('keeps only declared variables and formats values by valueType', () => {
const result = formatChatRequestVariables({
variableList: [
createVariable({
key: 'name',
valueType: WorkflowIOValueTypeEnum.string
}),
createVariable({
key: 'age',
type: VariableInputEnum.numberInput,
valueType: WorkflowIOValueTypeEnum.number
}),
createVariable({
key: 'enabled',
type: VariableInputEnum.switch,
valueType: WorkflowIOValueTypeEnum.boolean
})
],
variables: {
name: 'FastGPT',
age: '18',
enabled: 'true',
ignored: 'not declared'
}
});
expect(result).toEqual({
name: 'FastGPT',
age: 18,
enabled: true
});
});
it('uses defaultValue for empty string, null and undefined form values', () => {
const result = formatChatRequestVariables({
variableList: [
createVariable({
key: 'emptyText',
defaultValue: 'fallback'
}),
createVariable({
key: 'nullNumber',
type: VariableInputEnum.numberInput,
valueType: WorkflowIOValueTypeEnum.number,
defaultValue: '42'
}),
createVariable({
key: 'missingArray',
type: VariableInputEnum.multipleSelect,
valueType: WorkflowIOValueTypeEnum.arrayString,
defaultValue: ['a', 'b']
})
],
variables: {
emptyText: '',
nullNumber: null
}
});
expect(result).toEqual({
emptyText: 'fallback',
nullNumber: 42,
missingArray: ['a', 'b']
});
});
it('formats time point and time range variables before value type conversion', () => {
const startAtInput = '2026-05-19T01:02:03.000Z';
const periodStartInput = '2026-05-19T03:04:05.000Z';
const result = formatChatRequestVariables({
variableList: [
createVariable({
key: 'startAt',
type: VariableInputEnum.timePointSelect,
valueType: WorkflowIOValueTypeEnum.string
}),
createVariable({
key: 'period',
type: VariableInputEnum.timeRangeSelect,
valueType: WorkflowIOValueTypeEnum.arrayString
})
],
variables: {
startAt: startAtInput,
period: [periodStartInput, '']
}
});
expect(result).toEqual({
startAt: formatTime2YMDHMS(new Date(startAtInput)),
period: [formatTime2YMDHMS(new Date(periodStartInput)), '']
});
});
it('returns an empty object when variableList is missing', () => {
expect(formatChatRequestVariables({ variables: { name: 'FastGPT' } })).toEqual({});
});
});
import { describe, expect, it } from 'vitest';
import { ChatRoleEnum, ChatStatusEnum } from '@fastgpt/global/core/chat/constants';
import { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/constants';
import {
hasMeaningfulAiOutput,
shouldCreateResumeAiPlaceholder
} from '@/components/core/chat/ChatContainer/ChatBox/utils/resume';
import type { ChatSiteItemType } from '@/components/core/chat/ChatContainer/ChatBox/type';
const createAiRecord = (override: Partial<ChatSiteItemType>): ChatSiteItemType =>
({
id: 'ai-1',
dataId: 'ai-1',
obj: ChatRoleEnum.AI,
value: [],
status: ChatStatusEnum.loading,
...override
}) as ChatSiteItemType;
describe('shouldCreateResumeAiPlaceholder', () => {
it('returns true for visible resume stream events', () => {
expect(shouldCreateResumeAiPlaceholder(SseResponseEventEnum.flowNodeResponse)).toBe(true);
expect(shouldCreateResumeAiPlaceholder(SseResponseEventEnum.answer)).toBe(true);
expect(shouldCreateResumeAiPlaceholder(SseResponseEventEnum.fastAnswer)).toBe(true);
expect(shouldCreateResumeAiPlaceholder(SseResponseEventEnum.toolCall)).toBe(true);
expect(shouldCreateResumeAiPlaceholder(SseResponseEventEnum.toolParams)).toBe(true);
expect(shouldCreateResumeAiPlaceholder(SseResponseEventEnum.toolResponse)).toBe(true);
expect(shouldCreateResumeAiPlaceholder(SseResponseEventEnum.interactive)).toBe(true);
expect(shouldCreateResumeAiPlaceholder(SseResponseEventEnum.plan)).toBe(true);
expect(shouldCreateResumeAiPlaceholder(SseResponseEventEnum.planStatus)).toBe(true);
expect(shouldCreateResumeAiPlaceholder(SseResponseEventEnum.workflowDuration)).toBe(true);
});
it('returns false for stream control events that do not create chat content', () => {
expect(shouldCreateResumeAiPlaceholder(SseResponseEventEnum.error)).toBe(false);
expect(shouldCreateResumeAiPlaceholder(SseResponseEventEnum.updateVariables)).toBe(false);
});
});
describe('hasMeaningfulAiOutput', () => {
it('returns false when the record is missing, human, or empty AI placeholder', () => {
expect(hasMeaningfulAiOutput()).toBe(false);
expect(
hasMeaningfulAiOutput({
id: 'human-1',
dataId: 'human-1',
obj: ChatRoleEnum.Human,
value: [],
status: ChatStatusEnum.finish
} as ChatSiteItemType)
).toBe(false);
expect(hasMeaningfulAiOutput(createAiRecord({ value: [] }))).toBe(false);
expect(
hasMeaningfulAiOutput(
createAiRecord({
value: [
{
text: {
content: ''
}
}
]
})
)
).toBe(false);
});
it('returns true for AI records with response data or visible value content', () => {
expect(
hasMeaningfulAiOutput(
createAiRecord({
responseData: [{} as any]
})
)
).toBe(true);
expect(
hasMeaningfulAiOutput(
createAiRecord({
value: [
{
text: {
content: 'answer'
}
}
]
})
)
).toBe(true);
expect(
hasMeaningfulAiOutput(
createAiRecord({
value: [
{
reasoning: {
content: 'reasoning'
}
}
]
})
)
).toBe(true);
expect(
hasMeaningfulAiOutput(
createAiRecord({
value: [
{
tools: [
{
id: 'tool-1',
name: 'tool',
params: '{}'
} as any
]
}
]
})
)
).toBe(true);
expect(
hasMeaningfulAiOutput(
createAiRecord({
value: [
{
skills: [
{
id: 'skill-1',
skillName: 'skill',
skillAvatar: '',
description: '',
skillMdPath: '/tmp/skill.md'
}
]
}
]
})
)
).toBe(true);
expect(
hasMeaningfulAiOutput(
createAiRecord({
value: [
{
plan: {} as any
}
]
})
)
).toBe(true);
expect(
hasMeaningfulAiOutput(
createAiRecord({
value: [
{
interactive: {} as any
}
]
})
)
).toBe(true);
});
});
...@@ -4,7 +4,7 @@ import { ...@@ -4,7 +4,7 @@ import {
getChatScrollTargetKey, getChatScrollTargetKey,
shouldFollowGeneratingScroll, shouldFollowGeneratingScroll,
shouldForceScrollAfterRecordsLoaded shouldForceScrollAfterRecordsLoaded
} from '@/components/core/chat/ChatContainer/ChatBox/scrollUtils'; } from '@/components/core/chat/ChatContainer/ChatBox/utils/scrollUtils';
describe('ChatBox scrollUtils', () => { describe('ChatBox scrollUtils', () => {
it('should build stable scroll target keys only when appId and chatId exist', () => { it('should build stable scroll target keys only when appId and chatId exist', () => {
......
...@@ -3,14 +3,14 @@ import { ChatFileTypeEnum, ChatRoleEnum } from '@fastgpt/global/core/chat/consta ...@@ -3,14 +3,14 @@ import { ChatFileTypeEnum, ChatRoleEnum } from '@fastgpt/global/core/chat/consta
import type { ChatItemValueItemType } from '@fastgpt/global/core/chat/type'; import type { ChatItemValueItemType } from '@fastgpt/global/core/chat/type';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import type { ChatSiteItemType } from '@/components/core/chat/ChatContainer/ChatBox/type'; import type { ChatSiteItemType } from '@/components/core/chat/ChatContainer/ChatBox/type';
import { stripChatValueFileUrls } from '@/components/core/chat/ChatContainer/ChatBox/utils/chatValue';
import { refreshSubmittedFormInteractiveValues } from '@/components/core/chat/ChatContainer/ChatBox/utils/interactive';
import { import {
mergeResumeCompletedChatRecords, mergeResumeCompletedChatRecords,
refreshSubmittedFormInteractiveValues,
shouldAppendResumeInteractive, shouldAppendResumeInteractive,
shouldReplaceResumeAiValue, shouldReplaceResumeAiValue,
shouldResetResumeAiPlaceholder, shouldResetResumeAiPlaceholder
stripChatValueFileUrls } from '@/components/core/chat/ChatContainer/ChatBox/utils/resume';
} from '@/components/core/chat/ChatContainer/ChatBox/utils';
describe('stripChatValueFileUrls', () => { describe('stripChatValueFileUrls', () => {
it('removes signed urls from keyed files before sending messages', () => { it('removes signed urls from keyed files before sending messages', () => {
......
...@@ -19,8 +19,7 @@ ...@@ -19,8 +19,7 @@
"exclude": [ "exclude": [
"**/*.test.ts", "**/*.test.ts",
"**/*.test.tsx", "**/*.test.tsx",
"../../packages/**/vitest.config.ts", "../../packages/**/vitest*.config.ts",
"../../packages/**/vitest.integration.config.ts",
"../../packages/**/test/**", "../../packages/**/test/**",
".next", ".next",
"dist", "dist",
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment