chat quote reader (#3912)

* init chat quote full text reader

* linked structure

* dataset data linked

* optimize code

* fix ts build

* test finish

* delete log

* fix

* fix ts

* fix ts

* remove nextId

* initial scroll

* fix

* fix
This commit is contained in:
heheer 2025-03-11 19:44:33 +08:00 committed by archer
parent 16832caaf6
commit ac7091f8d6
No known key found for this signature in database
GPG Key ID: 4446499B846D4A9E
64 changed files with 2676 additions and 369 deletions

View File

@ -134,6 +134,7 @@ export type ChatItemType = (UserChatItemType | SystemChatItemType | AIChatItemTy
// Frontend type
export type ChatSiteItemType = (UserChatItemType | SystemChatItemType | AIChatItemType) & {
_id?: string;
dataId: string;
status: `${ChatStatusEnum}`;
moduleName?: string;

View File

@ -112,12 +112,15 @@ export type DatasetDataSchemaType = {
tmbId: string;
datasetId: string;
collectionId: string;
datasetId: string;
collectionId: string;
chunkIndex: number;
updateTime: Date;
q: string; // large chunks or question
a: string; // answer or custom content
history?: {
q: string;
a: string;
updateTime: Date;
}[];
forbid?: boolean;
fullTextToken: string;
indexes: DatasetDataIndexItemType[];

View File

@ -63,6 +63,8 @@ export type OutLinkSchema<T extends OutlinkAppType = undefined> = {
responseDetail: boolean;
// whether to hide the node status
showNodeStatus?: boolean;
// wheter to show the full text reader
// showFullText?: boolean;
// whether to show the complete quote
showRawSource?: boolean;
@ -89,6 +91,7 @@ export type OutLinkEditType<T = undefined> = {
name: string;
responseDetail?: OutLinkSchema<T>['responseDetail'];
showNodeStatus?: OutLinkSchema<T>['showNodeStatus'];
// showFullText?: OutLinkSchema<T>['showFullText'];
showRawSource?: OutLinkSchema<T>['showRawSource'];
// response when request
immediateResponse?: string;

View File

@ -15,6 +15,7 @@ import { AppChatConfigType } from '@fastgpt/global/core/app/type';
import { mergeChatResponseData } from '@fastgpt/global/core/chat/utils';
import { pushChatLog } from './pushChatLog';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants';
type Props = {
chatId: string;
@ -74,8 +75,42 @@ export async function saveChat({
)?.inputs;
await mongoSessionRun(async (session) => {
const processedContent = content.map((item) => {
if (item.obj === ChatRoleEnum.AI) {
const nodeResponse = item[DispatchNodeResponseKeyEnum.nodeResponse];
if (nodeResponse) {
return {
...item,
[DispatchNodeResponseKeyEnum.nodeResponse]: nodeResponse.map((responseItem) => {
if (
responseItem.moduleType === FlowNodeTypeEnum.datasetSearchNode &&
responseItem.quoteList
) {
return {
...item,
quoteList: responseItem.quoteList.map((quote: any) => ({
id: quote.id,
chunkIndex: quote.chunkIndex,
datasetId: quote.datasetId,
collectionId: quote.collectionId,
sourceId: quote.sourceId,
sourceName: quote.sourceName,
score: quote.score,
tokens: quote.tokens
}))
};
}
return item;
})
};
}
}
return item;
});
const [{ _id: chatItemIdHuman }, { _id: chatItemIdAi }] = await MongoChatItem.insertMany(
content.map((item) => ({
processedContent.map((item) => ({
chatId,
teamId,
tmbId,

View File

@ -40,6 +40,15 @@ const DatasetDataSchema = new Schema({
type: String,
default: ''
},
history: {
type: [
{
q: String,
a: String,
updateTime: Date
}
]
},
indexes: {
type: [
{

View File

@ -51,6 +51,9 @@ const OutLinkSchema = new Schema({
type: Boolean,
default: true
},
// showFullText: {
// type: Boolean
// },
showRawSource: {
type: Boolean
},

View File

@ -11,3 +11,22 @@ type PaginationResponse<T = {}> = {
total: number;
list: T[];
};
type LinkedPaginationProps<T = {}> = T & {
pageSize: number;
} & RequireOnlyOne<{
initialId: string;
nextId: string;
prevId: string;
}> &
RequireOnlyOne<{
initialIndex: number;
nextIndex: number;
prevIndex: number;
}>;
type LinkedListResponse<T = {}> = {
list: Array<T & { _id: string; index: number }>;
hasMorePrev: boolean;
hasMoreNext: boolean;
};

View File

@ -35,10 +35,13 @@ export const iconPaths = {
'common/dingtalkFill': () => import('./icons/common/dingtalkFill.svg'),
'common/disable': () => import('./icons/common/disable.svg'),
'common/downArrowFill': () => import('./icons/common/downArrowFill.svg'),
'common/download': () => import('./icons/common/download.svg'),
'common/edit': () => import('./icons/common/edit.svg'),
'common/editor/resizer': () => import('./icons/common/editor/resizer.svg'),
'common/enable': () => import('./icons/common/enable.svg'),
'common/errorFill': () => import('./icons/common/errorFill.svg'),
'common/file/move': () => import('./icons/common/file/move.svg'),
'common/fileNotFound': () => import('./icons/common/fileNotFound.svg'),
'common/folderFill': () => import('./icons/common/folderFill.svg'),
'common/folderImport': () => import('./icons/common/folderImport.svg'),
'common/fullScreenLight': () => import('./icons/common/fullScreenLight.svg'),
@ -86,7 +89,9 @@ export const iconPaths = {
'common/selectLight': () => import('./icons/common/selectLight.svg'),
'common/setting': () => import('./icons/common/setting.svg'),
'common/settingLight': () => import('./icons/common/settingLight.svg'),
'common/solidChevronDown': () => import('./icons/common/solidChevronDown.svg'),
'common/solidChevronRight': () => import('./icons/common/solidChevronRight.svg'),
'common/solidChevronUp': () => import('./icons/common/solidChevronUp.svg'),
'common/subtract': () => import('./icons/common/subtract.svg'),
'common/templateMarket': () => import('./icons/common/templateMarket.svg'),
'common/text/t': () => import('./icons/common/text/t.svg'),
@ -96,6 +101,7 @@ export const iconPaths = {
'common/trash': () => import('./icons/common/trash.svg'),
'common/upRightArrowLight': () => import('./icons/common/upRightArrowLight.svg'),
'common/uploadFileFill': () => import('./icons/common/uploadFileFill.svg'),
'common/upperRight': () => import('./icons/common/upperRight.svg'),
'common/userInfo': () => import('./icons/common/userInfo.svg'),
'common/variable': () => import('./icons/common/variable.svg'),
'common/viewLight': () => import('./icons/common/viewLight.svg'),

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.2703 9.04924C15.2703 10.8968 13.8022 12.4015 11.9689 12.4602V12.462H11.7009C11.3327 12.462 11.0342 12.1635 11.0342 11.7953C11.0342 11.4271 11.3327 11.1287 11.7009 11.1287H11.8689C13.0121 11.1226 13.937 10.1939 13.937 9.04924C13.937 8.07583 13.2669 7.25561 12.361 7.03087L11.5956 6.84096L11.3934 6.0787C10.9949 4.57696 9.62484 3.47188 8.00003 3.47188C6.37523 3.47188 5.00513 4.57696 4.60668 6.0787L4.40444 6.84096L3.63901 7.03087C2.73317 7.25561 2.06307 8.07583 2.06307 9.04924C2.06307 10.1977 2.99405 11.1287 4.14248 11.1287L4.14588 11.1286L4.33524 11.1287C4.70343 11.1287 5.00191 11.4271 5.00191 11.7953C5.00191 12.1635 4.70343 12.462 4.33524 12.462H4.14248H4.06858V12.4612C2.2179 12.4219 0.729736 10.9093 0.729736 9.04924C0.729736 7.44874 1.83149 6.10557 3.31794 5.73677C3.86757 3.6652 5.75552 2.13855 8.00003 2.13855C10.2445 2.13855 12.1325 3.6652 12.6821 5.73677C14.1686 6.10557 15.2703 7.44874 15.2703 9.04924ZM8.00002 7.30026C7.63183 7.30026 7.33335 7.59874 7.33335 7.96693V11.8435L6.96682 11.477C6.74816 11.2583 6.39364 11.2583 6.17498 11.477C5.95632 11.6957 5.95632 12.0502 6.17498 12.2688L7.50545 13.5993C7.52049 13.6159 7.53634 13.6318 7.55296 13.6468L7.60322 13.6971C7.82237 13.9162 8.17767 13.9162 8.39682 13.6971L8.44709 13.6468C8.4637 13.6318 8.47955 13.6159 8.49457 13.5993L9.82418 12.2697C10.0433 12.0506 10.0433 11.6953 9.82418 11.4761C9.60503 11.257 9.24973 11.257 9.03058 11.4761L8.66668 11.84V7.96693C8.66668 7.59874 8.36821 7.30026 8.00002 7.30026Z" />
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" fill="none">
<path d="M0.246607 10.0893C0.241922 10.0968 0.237518 10.1045 0.23341 10.1124C0.194824 10.1867 0.194824 10.2843 0.194824 10.4795V10.7803C0.194824 10.9755 0.194824 11.0731 0.23341 11.1473C0.265926 11.2099 0.316964 11.261 0.37956 11.2935C0.453841 11.3321 0.55143 11.3321 0.746607 11.3321H1.04222C1.20929 11.3321 1.30485 11.3321 1.3755 11.3079C1.38016 11.3066 1.38481 11.3052 1.38944 11.3037C1.50916 11.2658 1.61267 11.1623 1.81968 10.9553L8.4032 4.37179L7.15796 3.12655L0.574444 9.71007C0.397236 9.88728 0.29587 9.98864 0.246607 10.0893Z" />
<path d="M9.43317 3.34182L8.18793 2.09658L9.34895 0.935566C9.36436 0.920152 9.37233 0.912183 9.37902 0.905775C9.71036 0.588537 10.2328 0.588537 10.5641 0.905775C10.5708 0.91219 10.5786 0.919938 10.594 0.935383L10.5946 0.935931C10.6099 0.951255 10.6176 0.958969 10.624 0.965642C10.9412 1.29698 10.9412 1.81939 10.624 2.15073C10.6175 2.15745 10.6098 2.16524 10.5942 2.1808L9.43317 3.34182Z" />
<path d="M4.47921 10.6383C4.47921 10.2552 4.78981 9.9446 5.17296 9.9446H11.1114C11.4946 9.9446 11.8052 10.2552 11.8052 10.6383C11.8052 11.0215 11.4946 11.3321 11.1114 11.3321H5.17296C4.78981 11.3321 4.47921 11.0215 4.47921 10.6383Z" />
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
<path d="M4 5H26V10H4V5Z" fill="#ECECEC"/>
<path d="M4 12H26V17H4V12Z" fill="#ECECEC"/>
<path d="M4 19H26V24H4V19Z" fill="#ECECEC"/>
<path d="M21.8982 27.7967C18.0945 27.7967 15 24.7021 15 20.8983C15 17.0947 18.0945 14 21.8982 14C25.7019 14 28.7965 17.0947 28.7965 20.8983C28.7965 24.702 25.7019 27.7967 21.8982 27.7967ZM21.8982 15.698C19.0307 15.698 16.698 18.0309 16.698 20.8983C16.698 23.7658 19.0307 26.0988 21.8982 26.0988C24.7657 26.0988 27.0986 23.7658 27.0986 20.8983C27.0986 18.0309 24.7657 15.698 21.8982 15.698Z" fill="#BEC2C9"/>
<path d="M29.9551 30C29.6876 30 29.4203 29.8979 29.2162 29.694L25.6122 26.0902C25.2042 25.6821 25.2042 25.0205 25.6122 24.6124C26.0204 24.2044 26.6819 24.2044 27.09 24.6124L30.694 28.2162C31.102 28.6242 31.102 29.2859 30.694 29.694C30.4899 29.8979 30.2225 30 29.9551 30Z" fill="#BEC2C9"/>
</svg>

After

Width:  |  Height:  |  Size: 942 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 19 18" fill="none">
<path d="M13.4032 6C14.7396 6 15.4088 7.61571 14.4639 8.56066L10.6388 12.3858C10.053 12.9715 9.10324 12.9715 8.51745 12.3858L4.69235 8.56066C3.7474 7.61571 4.41665 6 5.75301 6H13.4032Z" />
</svg>

After

Width:  |  Height:  |  Size: 270 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 19 18" fill="none">
<path d="M13.4032 12.0751C14.7396 12.0751 15.4088 10.4594 14.4639 9.51441L10.6388 5.68931C10.053 5.10352 9.10324 5.10352 8.51745 5.68931L4.69235 9.51441C3.7474 10.4594 4.41665 12.0751 5.75301 12.0751H13.4032Z" />
</svg>

After

Width:  |  Height:  |  Size: 294 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none">
<path d="M5.44766 4.22876C5.44765 4.59695 5.74613 4.89542 6.11432 4.89542L10.1617 4.89542L3.7573 11.2998C3.49695 11.5602 3.49695 11.9823 3.7573 12.2426C4.01765 12.503 4.43976 12.503 4.70011 12.2426L11.1045 5.83823L11.1045 9.88561C11.1045 10.2538 11.403 10.5523 11.7712 10.5523C12.1394 10.5523 12.4378 10.2538 12.4378 9.88561L12.4378 4.22876C12.4378 3.86057 12.1394 3.56209 11.7712 3.56209L6.11432 3.56209C5.74613 3.56209 5.44765 3.86057 5.44766 4.22876Z"/>
</svg>

After

Width:  |  Height:  |  Size: 539 B

View File

@ -1 +1,3 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1698497259520" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="10081" xmlns:xlink="http://www.w3.org/1999/xlink" width="128" height="128"><path d="M156.09136 606.57001a457.596822 457.596822 0 0 1 221.680239-392.516385 50.844091 50.844091 0 1 1 50.844091 86.943396 355.90864 355.90864 0 0 0-138.804369 152.532274h16.77855a152.532274 152.532274 0 1 1-152.532274 152.532274z m406.752731 0a457.596822 457.596822 0 0 1 221.680239-392.007944 50.844091 50.844091 0 1 1 50.844091 86.943396 355.90864 355.90864 0 0 0-138.804369 152.532274h16.77855a152.532274 152.532274 0 1 1-152.532274 152.532274z" fill="#E67E22" p-id="10082"></path></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M11.2308 13.6099V18.2252C11.2308 18.8663 11.0064 19.4111 10.5577 19.8599C10.109 20.3086 9.5641 20.5329 8.92307 20.5329H4.30769C3.66667 20.5329 3.12179 20.3086 2.67308 19.8599C2.22436 19.4111 2 18.8663 2 18.2252V9.76371C2 8.93038 2.16226 8.1351 2.48678 7.37789C2.8113 6.62068 3.25 5.96563 3.80288 5.41275C4.35577 4.85986 5.01082 4.42116 5.76803 4.09664C6.52524 3.77212 7.32051 3.60986 8.15384 3.60986H8.92307C9.13141 3.60986 9.3117 3.68598 9.46394 3.83823C9.61618 3.99047 9.6923 4.17076 9.6923 4.37909V5.91756C9.6923 6.12589 9.61618 6.30618 9.46394 6.45842C9.3117 6.61066 9.13141 6.68679 8.92307 6.68679H8.15384C7.30448 6.68679 6.57933 6.98727 5.97836 7.58823C5.3774 8.18919 5.07692 8.91435 5.07692 9.76371V10.1483C5.07692 10.4688 5.1891 10.7413 5.41346 10.9656C5.63782 11.19 5.91025 11.3022 6.23077 11.3022H8.92307C9.5641 11.3022 10.109 11.5265 10.5577 11.9752C11.0064 12.424 11.2308 12.9688 11.2308 13.6099ZM22 13.6099V18.2252C22 18.8663 21.7756 19.4111 21.3269 19.8599C20.8782 20.3086 20.3333 20.5329 19.6923 20.5329H15.0769C14.4359 20.5329 13.891 20.3086 13.4423 19.8599C12.9936 19.4111 12.7692 18.8663 12.7692 18.2252V9.76371C12.7692 8.93038 12.9315 8.1351 13.256 7.37789C13.5805 6.62068 14.0192 5.96563 14.5721 5.41275C15.125 4.85986 15.78 4.42116 16.5373 4.09664C17.2945 3.77212 18.0897 3.60986 18.9231 3.60986H19.6923C19.9006 3.60986 20.0809 3.68598 20.2332 3.83823C20.3854 3.99047 20.4615 4.17076 20.4615 4.37909V5.91756C20.4615 6.12589 20.3854 6.30618 20.2332 6.45842C20.0809 6.61066 19.9006 6.68679 19.6923 6.68679H18.9231C18.0737 6.68679 17.3486 6.98727 16.7476 7.58823C16.1466 8.18919 15.8461 8.91435 15.8461 9.76371V10.1483C15.8461 10.4688 15.9583 10.7413 16.1827 10.9656C16.407 11.19 16.6795 11.3022 17 11.3022H19.6923C20.3333 11.3022 20.8782 11.5265 21.3269 11.9752C21.7756 12.424 22 12.9688 22 13.6099Z" />
</svg>

Before

Width:  |  Height:  |  Size: 819 B

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -0,0 +1,307 @@
import { useCallback, useEffect, useRef, useState, ReactNode } from 'react';
import { LinkedListResponse, LinkedPaginationProps } from '../common/fetch/type';
import { Box, BoxProps } from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import { useScroll, useMemoizedFn, useDebounceEffect } from 'ahooks';
import MyBox from '../components/common/MyBox';
import { useRequest2 } from './useRequest';
const threshold = 100;
export function useLinkedScroll<
TParams extends LinkedPaginationProps & { isInitialLoad?: boolean },
TData extends LinkedListResponse
>(
api: (data: TParams) => Promise<TData>,
{
refreshDeps = [],
pageSize = 15,
params = {},
initialId,
initialIndex,
canLoadData = false
}: {
refreshDeps?: any[];
pageSize?: number;
params?: Record<string, any>;
initialId?: string;
initialIndex?: number;
canLoadData?: boolean;
}
) {
const { t } = useTranslation();
const [dataList, setDataList] = useState<TData['list']>([]);
const [hasMorePrev, setHasMorePrev] = useState(true);
const [hasMoreNext, setHasMoreNext] = useState(true);
const [initialLoadDone, setInitialLoadDone] = useState(false);
const hasScrolledToInitial = useRef(false);
const anchorRef = useRef({
top: null as { _id: string; index: number } | null,
bottom: null as { _id: string; index: number } | null
});
const containerRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<(HTMLElement | null)[]>([]);
const { runAsync: callApi, loading: isLoading } = useRequest2(
async (apiParams: TParams) => await api(apiParams),
{
onError: (error) => {
return Promise.reject(error);
}
}
);
const loadData = useCallback(
async ({
id,
index,
isInitialLoad = false
}: {
id: string;
index: number;
isInitialLoad?: boolean;
}) => {
if (isLoading) return null;
const response = await callApi({
initialId: id,
initialIndex: index,
pageSize,
isInitialLoad,
...params
} as TParams);
if (!response) return null;
setHasMorePrev(response.hasMorePrev);
setHasMoreNext(response.hasMoreNext);
setDataList(response.list);
if (response.list.length > 0) {
anchorRef.current.top = response.list[0];
anchorRef.current.bottom = response.list[response.list.length - 1];
}
setInitialLoadDone(true);
const scrollIndex = response.list.findIndex((item) => item._id === id);
if (scrollIndex !== -1 && itemRefs.current?.[scrollIndex]) {
setTimeout(() => {
scrollToItem(scrollIndex);
}, 100);
}
return response;
},
[callApi, params, dataList, hasMorePrev, hasMoreNext, isLoading]
);
const loadPrevData = useCallback(
async (scrollRef = containerRef) => {
if (!anchorRef.current.top || !hasMorePrev || isLoading) return;
const prevScrollTop = scrollRef?.current?.scrollTop || 0;
const prevScrollHeight = scrollRef?.current?.scrollHeight || 0;
const response = await callApi({
prevId: anchorRef.current.top._id,
prevIndex: anchorRef.current.top.index,
pageSize,
...params
} as TParams);
if (!response) return;
setHasMorePrev(response.hasMorePrev);
if (response.list.length > 0) {
setDataList((prev) => [...response.list, ...prev]);
anchorRef.current.top = response.list[0];
setTimeout(() => {
if (scrollRef?.current) {
const newHeight = scrollRef.current.scrollHeight;
const heightDiff = newHeight - prevScrollHeight;
scrollRef.current.scrollTop = prevScrollTop + heightDiff;
}
}, 0);
}
return response;
},
[callApi, hasMorePrev, isLoading, params, pageSize]
);
const loadNextData = useCallback(
async (scrollRef = containerRef) => {
if (!anchorRef.current.bottom || !hasMoreNext || isLoading) return;
const prevScrollTop = scrollRef?.current?.scrollTop || 0;
const response = await callApi({
nextId: anchorRef.current.bottom._id,
nextIndex: anchorRef.current.bottom.index,
pageSize,
...params
} as TParams);
if (!response) return;
setHasMoreNext(response.hasMoreNext);
if (response.list.length > 0) {
setDataList((prev) => [...prev, ...response.list]);
anchorRef.current.bottom = response.list[response.list.length - 1];
setTimeout(() => {
if (scrollRef?.current) {
scrollRef.current.scrollTop = prevScrollTop;
}
}, 0);
}
return response;
},
[callApi, hasMoreNext, isLoading, params, pageSize]
);
const scrollToItem = useCallback(
(itemIndex: number) => {
if (itemIndex >= 0 && itemIndex < dataList.length && itemRefs.current?.[itemIndex]) {
try {
const element = itemRefs.current[itemIndex];
if (!element) {
return false;
}
setTimeout(() => {
if (element && containerRef.current) {
const elementRect = element.getBoundingClientRect();
const containerRect = containerRef.current.getBoundingClientRect();
const relativeTop = elementRect.top - containerRect.top;
const scrollTop =
containerRef.current.scrollTop +
relativeTop -
containerRect.height / 2 +
elementRect.height / 2;
containerRef.current.scrollTo({
top: scrollTop,
behavior: 'smooth'
});
}
}, 50);
return true;
} catch (error) {
console.error('Error scrolling to item:', error);
return false;
}
}
return false;
},
[dataList.length]
);
// 初始加载
useEffect(() => {
if (canLoadData) {
// 重置初始滚动状态
hasScrolledToInitial.current = false;
loadData({
id: initialId || '',
index: initialIndex || 0,
isInitialLoad: true
});
}
}, [canLoadData, ...refreshDeps]);
// 监听初始加载完成,执行初始滚动
useEffect(() => {
if (initialLoadDone && dataList.length > 0 && !hasScrolledToInitial.current) {
hasScrolledToInitial.current = true;
const foundIndex = dataList.findIndex((item) => item._id === initialId);
if (foundIndex >= 0) {
setTimeout(() => {
scrollToItem(foundIndex);
}, 200);
}
}
}, [initialLoadDone, ...refreshDeps]);
const ScrollData = useMemoizedFn(
({
children,
ScrollContainerRef,
isLoading: externalLoading,
...props
}: {
isLoading?: boolean;
children: ReactNode;
ScrollContainerRef?: React.RefObject<HTMLDivElement>;
} & BoxProps) => {
const ref = ScrollContainerRef || containerRef;
const scroll = useScroll(ref);
useDebounceEffect(
() => {
if (!ref?.current || isLoading || !initialLoadDone) return;
const { scrollTop, scrollHeight, clientHeight } = ref.current;
// 滚动到底部附近,加载更多下方数据
if (scrollTop + clientHeight >= scrollHeight - threshold && hasMoreNext) {
loadNextData(ref);
}
// 滚动到顶部附近,加载更多上方数据
if (scrollTop <= threshold && hasMorePrev) {
loadPrevData(ref);
}
},
[scroll],
{ wait: 200 }
);
return (
<MyBox
ref={ref}
h={'100%'}
overflow={'auto'}
isLoading={externalLoading || isLoading}
{...props}
>
{hasMorePrev && isLoading && initialLoadDone && (
<Box mt={2} fontSize={'xs'} color={'blackAlpha.500'} textAlign={'center'}>
{t('common:common.is_requesting')}
</Box>
)}
{children}
{hasMoreNext && isLoading && initialLoadDone && (
<Box mt={2} fontSize={'xs'} color={'blackAlpha.500'} textAlign={'center'}>
{t('common:common.is_requesting')}
</Box>
)}
</MyBox>
);
}
);
return {
dataList,
setDataList,
isLoading,
loadData,
initialLoadDone,
ScrollData,
itemRefs,
scrollToItem
};
}

View File

@ -3,6 +3,8 @@
"Delete_all": "Clear All Lexicon",
"LLM_model_response_empty": "The model flow response is empty, please check whether the model flow output is normal.",
"ai_reasoning": "Thinking process",
"chat.quote.No Data": "The file cannot be found",
"chat.quote.deleted": "This data has been deleted ~",
"chat_history": "Conversation History",
"chat_input_guide_lexicon_is_empty": "Lexicon not configured yet",
"chat_test_app": "Debug-{{name}}",

View File

@ -1,5 +1,6 @@
{
"App": "Application",
"Download": "Download",
"Export": "Export",
"FAQ.ai_point_a": "Each time you use the AI model, a certain amount of AI points will be deducted. For detailed calculation standards, please refer to the 'AI Points Calculation Standards' above.\nToken calculation uses the same formula as GPT-3.5, where 1 Token ≈ 0.7 Chinese characters ≈ 0.9 English words. Consecutive characters may be considered as 1 Token.",
"FAQ.ai_point_expire_a": "Yes, they will expire. After the current package expires, the AI points will be reset to the new package's AI points. Annual package AI points are valid for one year, not monthly.",
@ -450,6 +451,8 @@
"core.chat.module_unexist": "Running failed: Application missing components",
"core.chat.quote.Quote Tip": "Only the actual quoted content is displayed here. If the data is updated, it will not be updated in real-time here.",
"core.chat.quote.Read Quote": "View Quote",
"core.chat.quote.afterUpdate": "After update",
"core.chat.quote.beforeUpdate": "Before update",
"core.chat.response.Complete Response": "Complete Response",
"core.chat.response.Extension model": "Question Optimization Model",
"core.chat.response.Read complete response": "View Details",
@ -495,9 +498,11 @@
"core.dataset.Dataset": "Dataset",
"core.dataset.Dataset ID": "Dataset ID",
"core.dataset.Delete Confirm": "Confirm to Delete This Dataset? Data Cannot Be Recovered After Deletion, Please Confirm!",
"core.dataset.Download the parsed content": "Download the parsed content",
"core.dataset.Empty Dataset": "Empty Dataset",
"core.dataset.Empty Dataset Tips": "No Dataset Yet, Create One Now!",
"core.dataset.Folder placeholder": "This is a Directory",
"core.dataset.Get the raw data": "Get the raw data",
"core.dataset.Go Dataset": "Go to Dataset",
"core.dataset.Intro Placeholder": "This Dataset Has No Introduction Yet",
"core.dataset.Manual collection": "Manual Dataset",
@ -540,6 +545,7 @@
"core.dataset.data.Empty Tip": "This collection has no data yet",
"core.dataset.data.Search data placeholder": "Search Related Data",
"core.dataset.data.Too Long": "Total Length Exceeded",
"core.dataset.data.Updated": "Updated",
"core.dataset.data.group": "Group",
"core.dataset.data.unit": "Items",
"core.dataset.embedding model tip": "The index model can convert natural language into vectors for semantic search.\nNote that different index models cannot be used together. Once an index model is selected, it cannot be changed.",
@ -1027,6 +1033,8 @@
"support.outlink.Max usage points": "Points Limit",
"support.outlink.Max usage points tip": "The maximum number of points allowed for this link. It cannot be used after exceeding the limit. -1 means unlimited.",
"support.outlink.Usage points": "Points Consumption",
"support.outlink.share.Chat_quote_reader": "Full text reader",
"support.outlink.share.Full_text tips": "Allows reading of the complete dataset from which the referenced fragment is derived",
"support.outlink.share.Response Quote": "Return Quote",
"support.outlink.share.Response Quote tips": "Return quoted content in the share link, but do not allow users to download the original document",
"support.outlink.share.running_node": "Running node",

View File

@ -3,6 +3,8 @@
"Delete_all": "清空词库",
"LLM_model_response_empty": "模型流响应为空,请检查模型流输出是否正常",
"ai_reasoning": "思考过程",
"chat.quote.No Data": "找不到该文件",
"chat.quote.deleted": "该数据已被删除~",
"chat_history": "聊天记录",
"chat_input_guide_lexicon_is_empty": "还没有配置词库",
"chat_test_app": "调试-{{name}}",

View File

@ -1,5 +1,6 @@
{
"App": "应用",
"Download": "下载",
"Export": "导出",
"FAQ.ai_point_a": "每次调用AI模型时都会消耗一定的AI积分。具体的计算标准可参考上方的“AI 积分计算标准”。\nToken计算采用GPT3.5相同公式1Token≈0.7中文字符≈0.9英文单词连续出现的字符可能被认为是1个Tokens。",
"FAQ.ai_point_expire_a": "会过期。当前套餐过期后AI积分将会清空并更新为新套餐的AI积分。年度套餐的AI积分时长为1年而不是每个月。",
@ -453,6 +454,8 @@
"core.chat.module_unexist": "运行失败:应用缺失组件",
"core.chat.quote.Quote Tip": "此处仅显示实际引用内容,若数据有更新,此处不会实时更新",
"core.chat.quote.Read Quote": "查看引用",
"core.chat.quote.afterUpdate": "更新后",
"core.chat.quote.beforeUpdate": "更新前",
"core.chat.response.Complete Response": "完整响应",
"core.chat.response.Extension model": "问题优化模型",
"core.chat.response.Read complete response": "查看详情",
@ -498,9 +501,11 @@
"core.dataset.Dataset": "知识库",
"core.dataset.Dataset ID": "知识库 ID",
"core.dataset.Delete Confirm": "确认删除该知识库?删除后数据无法恢复,请确认!",
"core.dataset.Download the parsed content": "下载解析内容",
"core.dataset.Empty Dataset": "空数据集",
"core.dataset.Empty Dataset Tips": "还没有知识库,快去创建一个吧!",
"core.dataset.Folder placeholder": "这是一个目录",
"core.dataset.Get the raw data": "获取源数据",
"core.dataset.Go Dataset": "前往知识库",
"core.dataset.Intro Placeholder": "这个知识库还没有介绍~",
"core.dataset.Manual collection": "手动数据集",
@ -543,6 +548,7 @@
"core.dataset.data.Empty Tip": "这个集合还没有数据~",
"core.dataset.data.Search data placeholder": "搜索相关数据",
"core.dataset.data.Too Long": "总长度超长了",
"core.dataset.data.Updated": "已更新",
"core.dataset.data.group": "组",
"core.dataset.data.unit": "条",
"core.dataset.embedding model tip": "索引模型可以将自然语言转成向量,用于进行语义检索。\n注意不同索引模型无法一起使用选择完索引模型后将无法修改。",
@ -1031,6 +1037,8 @@
"support.outlink.Max usage points": "积分上限",
"support.outlink.Max usage points tip": "该链接最多允许使用多少积分,超出后将无法使用。-1 代表无限制。",
"support.outlink.Usage points": "积分消耗",
"support.outlink.share.Chat_quote_reader": "全文阅读器",
"support.outlink.share.Full_text tips": "允许阅读该引用片段来源的完整数据集",
"support.outlink.share.Response Quote": "引用内容",
"support.outlink.share.Response Quote tips": "查看知识库搜索的引用内容,不可查看完整引用文档或跳转引用网站",
"support.outlink.share.running_node": "运行节点",

View File

@ -3,6 +3,8 @@
"Delete_all": "清除所有詞彙",
"LLM_model_response_empty": "模型流程回應為空,請檢查模型流程輸出是否正常",
"ai_reasoning": "思考過程",
"chat.quote.No Data": "找不到該文件",
"chat.quote.deleted": "該數據已被刪除~",
"chat_history": "對話紀錄",
"chat_input_guide_lexicon_is_empty": "尚未設定詞彙庫",
"chat_test_app": "調試-{{name}}",

View File

@ -1,5 +1,6 @@
{
"App": "應用程式",
"Download": "下載",
"Export": "匯出",
"FAQ.ai_point_a": "每次呼叫 AI 模型時,都會消耗一定數量的 AI 點數。詳細的計算標準請參考上方的「AI 點數計算標準」。\nToken 計算採用與 GPT3.5 相同的公式1 Token ≈ 0.7 個中文字 ≈ 0.9 個英文單字,連續出現的字元可能會被視為 1 個 Token。",
"FAQ.ai_point_expire_a": "會過期。目前方案過期後AI 點數將會清空並更新為新方案的 AI 點數。年度方案的 AI 點數有效期為一年,而不是每個月重置。",
@ -449,6 +450,8 @@
"core.chat.module_unexist": "運行失敗:應用缺失組件",
"core.chat.quote.Quote Tip": "此處僅顯示實際引用內容,若資料有更新,此處不會即時更新",
"core.chat.quote.Read Quote": "檢視引用",
"core.chat.quote.afterUpdate": "更新後",
"core.chat.quote.beforeUpdate": "更新前",
"core.chat.response.Complete Response": "完整回應",
"core.chat.response.Extension model": "問題最佳化模型",
"core.chat.response.Read complete response": "檢視詳細資料",
@ -494,9 +497,11 @@
"core.dataset.Dataset": "知識庫",
"core.dataset.Dataset ID": "知識庫 ID",
"core.dataset.Delete Confirm": "確認刪除此知識庫?刪除後資料無法復原,請確認!",
"core.dataset.Download the parsed content": "下載解析內容",
"core.dataset.Empty Dataset": "空資料集",
"core.dataset.Empty Dataset Tips": "還沒有知識庫,快來建立一個吧!",
"core.dataset.Folder placeholder": "這是一個目錄",
"core.dataset.Get the raw data": "獲取源數據",
"core.dataset.Go Dataset": "前往知識庫",
"core.dataset.Intro Placeholder": "這個知識庫還沒有介紹",
"core.dataset.Manual collection": "手動資料集",
@ -539,6 +544,7 @@
"core.dataset.data.Empty Tip": "此集合還沒有資料",
"core.dataset.data.Search data placeholder": "搜尋相關資料",
"core.dataset.data.Too Long": "總長度超出上限",
"core.dataset.data.Updated": "已更新",
"core.dataset.data.group": "組",
"core.dataset.data.unit": "筆",
"core.dataset.embedding model tip": "索引模型可以將自然語言轉換成向量,用於進行語意搜尋。\n注意不同索引模型無法一起使用。選擇索引模型後就無法修改。",
@ -1026,6 +1032,8 @@
"support.outlink.Max usage points": "點數上限",
"support.outlink.Max usage points tip": "此連結最多允許使用多少點數,超出後將無法使用。-1 代表無限制。",
"support.outlink.Usage points": "點數消耗",
"support.outlink.share.Chat_quote_reader": "全文閱讀器",
"support.outlink.share.Full_text tips": "允許閱讀該引用片段來源的完整數據集",
"support.outlink.share.Response Quote": "回傳引用",
"support.outlink.share.Response Quote tips": "在分享連結中回傳引用內容,但不允許使用者下載原始文件",
"support.outlink.share.running_node": "執行節點",

View File

@ -1,22 +1,41 @@
import React, { useState } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { Box, Flex } from '@chakra-ui/react';
import type { BoxProps } from '@chakra-ui/react';
import MyIcon from '@fastgpt/web/components/common/Icon';
interface Props extends BoxProps {}
interface Props extends BoxProps {
externalTrigger?: Boolean;
}
const SideBar = (e?: Props) => {
const {
w = ['100%', '0 0 250px', '0 0 270px', '0 0 290px', '0 0 310px'],
children,
externalTrigger,
...props
} = e || {};
const [foldSideBar, setFoldSideBar] = useState(false);
const [isFolded, setIsFolded] = useState(false);
const prevExternalTriggerRef = useRef<Boolean | undefined>(undefined);
useEffect(() => {
if (externalTrigger && !prevExternalTriggerRef.current && !isFolded) {
setIsFolded(true);
}
prevExternalTriggerRef.current = externalTrigger;
}, [externalTrigger, isFolded]);
const handleToggle = () => {
const newFolded = !isFolded;
setIsFolded(newFolded);
};
return (
<Box
position={'relative'}
flex={foldSideBar ? '0 0 0' : w}
flex={isFolded ? '0 0 0' : w}
w={['100%', 0]}
h={'100%'}
zIndex={1}
@ -40,7 +59,7 @@ const SideBar = (e?: Props) => {
bg={'rgba(0,0,0,0.5)'}
cursor={'pointer'}
transition={'0.2s'}
{...(foldSideBar
{...(isFolded
? {
opacity: 0.6
}
@ -48,16 +67,16 @@ const SideBar = (e?: Props) => {
visibility: 'hidden',
opacity: 0
})}
onClick={() => setFoldSideBar(!foldSideBar)}
onClick={handleToggle}
>
<MyIcon
name={'common/backLight'}
transform={foldSideBar ? 'rotate(180deg)' : ''}
transform={isFolded ? 'rotate(180deg)' : ''}
w={'14px'}
color={'white'}
/>
</Flex>
<Box position={'relative'} h={'100%'} overflow={foldSideBar ? 'hidden' : 'visible'}>
<Box position={'relative'} h={'100%'} overflow={isFolded ? 'hidden' : 'visible'}>
{children}
</Box>
</Box>

View File

@ -0,0 +1,96 @@
import React, { useMemo } from 'react';
import { Box, useTheme } from '@chakra-ui/react';
import type { SearchDataResponseItemType } from '@fastgpt/global/core/dataset/type';
import QuoteItem, { formatScore } from '@/components/core/dataset/QuoteItem';
import { useContextSelector } from 'use-context-selector';
import { ChatBoxContext } from '../Provider';
import { ChatItemContext } from '@/web/core/chat/context/chatItemContext';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { useChatStore } from '@/web/core/chat/context/useChatStore';
import { getQuoteDataList } from '@/web/core/chat/api';
const QuoteList = React.memo(function QuoteList({
chatItemId,
rawSearch = [],
chatTime
}: {
chatItemId?: string;
rawSearch: SearchDataResponseItemType[];
chatTime: Date;
}) {
const theme = useTheme();
const { chatId, appId, outLinkAuthData } = useChatStore();
const RawSourceBoxProps = useContextSelector(ChatBoxContext, (v) => ({
chatItemId,
appId: v.appId,
chatId: v.chatId,
...(v.outLinkAuthData || {})
}));
const showRawSource = useContextSelector(ChatItemContext, (v) => v.isShowReadRawSource);
const showRouteToDatasetDetail = useContextSelector(
ChatItemContext,
(v) => v.showRouteToDatasetDetail
);
const { data } = useRequest2(
async () =>
await getQuoteDataList({
datasetDataIdList: rawSearch.map((item) => item.id),
chatTime,
collectionIdList: [...new Set(rawSearch.map((item) => item.collectionId))],
chatItemId: chatItemId || '',
appId,
chatId,
...outLinkAuthData
}),
{
manual: false
}
);
const formatedDataList = useMemo(() => {
return rawSearch
.map((item) => {
const currentFilterItem = data?.quoteList.find((res) => res._id === item.id);
return {
...item,
q: currentFilterItem?.q || '',
a: currentFilterItem?.a || ''
};
})
.sort((a, b) => {
const aScore = formatScore(a.score);
const bScore = formatScore(b.score);
return (bScore.primaryScore?.value || 0) - (aScore.primaryScore?.value || 0);
});
}, [data?.quoteList, rawSearch]);
return (
<>
{formatedDataList.map((item, i) => (
<Box
key={i}
flex={'1 0 0'}
p={2}
borderRadius={'sm'}
border={theme.borders.base}
_notLast={{ mb: 2 }}
_hover={{ '& .hover-data': { display: 'flex' } }}
bg={i % 2 === 0 ? 'white' : 'myWhite.500'}
>
<QuoteItem
quoteItem={item}
canViewSource={showRawSource}
canEditDataset={showRouteToDatasetDetail}
{...RawSourceBoxProps}
/>
</Box>
))}
</>
);
});
export default QuoteList;

View File

@ -1,129 +0,0 @@
import React, { useMemo } from 'react';
import { ModalBody, Box, useTheme } from '@chakra-ui/react';
import MyModal from '@fastgpt/web/components/common/MyModal';
import { useTranslation } from 'next-i18next';
import type { SearchDataResponseItemType } from '@fastgpt/global/core/dataset/type';
import QuoteItem from '@/components/core/dataset/QuoteItem';
import RawSourceBox from '@/components/core/dataset/RawSourceBox';
import { getWebReqUrl } from '@fastgpt/web/common/system/utils';
import { useContextSelector } from 'use-context-selector';
import { ChatBoxContext } from '../Provider';
import { ChatItemContext } from '@/web/core/chat/context/chatItemContext';
const QuoteModal = ({
rawSearch = [],
onClose,
chatItemId,
metadata
}: {
rawSearch: SearchDataResponseItemType[];
onClose: () => void;
chatItemId: string;
metadata?: {
collectionId: string;
sourceId?: string;
sourceName: string;
};
}) => {
const { t } = useTranslation();
const filterResults = useMemo(
() =>
metadata
? rawSearch.filter(
(item) =>
item.collectionId === metadata.collectionId && item.sourceId === metadata.sourceId
)
: rawSearch,
[metadata, rawSearch]
);
const RawSourceBoxProps = useContextSelector(ChatBoxContext, (v) => ({
appId: v.appId,
chatId: v.chatId,
chatItemId,
...(v.outLinkAuthData || {})
}));
const showRawSource = useContextSelector(ChatItemContext, (v) => v.isShowReadRawSource);
const showRouteToDatasetDetail = useContextSelector(
ChatItemContext,
(v) => v.showRouteToDatasetDetail
);
return (
<>
<MyModal
isOpen={true}
onClose={onClose}
h={['90vh', '80vh']}
isCentered
minW={['90vw', '600px']}
iconSrc={!!metadata ? undefined : getWebReqUrl('/imgs/modal/quote.svg')}
title={
<Box>
{metadata ? (
<RawSourceBox {...metadata} {...RawSourceBoxProps} canView={showRawSource} />
) : (
<>{t('common:core.chat.Quote Amount', { amount: rawSearch.length })}</>
)}
<Box fontSize={'xs'} color={'myGray.500'} fontWeight={'normal'}>
{t('common:core.chat.quote.Quote Tip')}
</Box>
</Box>
}
>
<ModalBody>
<QuoteList rawSearch={filterResults} chatItemId={chatItemId} />
</ModalBody>
</MyModal>
</>
);
};
export default QuoteModal;
export const QuoteList = React.memo(function QuoteList({
chatItemId,
rawSearch = []
}: {
chatItemId?: string;
rawSearch: SearchDataResponseItemType[];
}) {
const theme = useTheme();
const RawSourceBoxProps = useContextSelector(ChatBoxContext, (v) => ({
chatItemId,
appId: v.appId,
chatId: v.chatId,
...(v.outLinkAuthData || {})
}));
const showRawSource = useContextSelector(ChatItemContext, (v) => v.isShowReadRawSource);
const showRouteToDatasetDetail = useContextSelector(
ChatItemContext,
(v) => v.showRouteToDatasetDetail
);
return (
<>
{rawSearch.map((item, i) => (
<Box
key={i}
flex={'1 0 0'}
p={2}
borderRadius={'sm'}
border={theme.borders.base}
_notLast={{ mb: 2 }}
_hover={{ '& .hover-data': { display: 'flex' } }}
bg={i % 2 === 0 ? 'white' : 'myWhite.500'}
>
<QuoteItem
quoteItem={item}
canViewSource={showRawSource}
canEditDataset={showRouteToDatasetDetail}
{...RawSourceBoxProps}
/>
</Box>
))}
</>
);
});

View File

@ -14,8 +14,8 @@ import { addStatisticalDataToHistoryItem } from '@/global/core/chat/utils';
import { useSize } from 'ahooks';
import { useContextSelector } from 'use-context-selector';
import { ChatBoxContext } from '../Provider';
import { ChatItemContext } from '@/web/core/chat/context/chatItemContext';
const QuoteModal = dynamic(() => import('./QuoteModal'));
const ContextModal = dynamic(() => import('./ContextModal'));
const WholeResponseModal = dynamic(() => import('../../../components/WholeResponseModal'));
@ -30,6 +30,7 @@ const ResponseTags = ({
const { t } = useTranslation();
const quoteListRef = React.useRef<HTMLDivElement>(null);
const dataId = historyItem.dataId;
const chatTime = historyItem.time || new Date();
const {
totalQuoteList: quoteList = [],
@ -38,17 +39,12 @@ const ResponseTags = ({
historyPreviewLength = 0
} = useMemo(() => addStatisticalDataToHistoryItem(historyItem), [historyItem]);
const [quoteModalData, setQuoteModalData] = useState<{
rawSearch: SearchDataResponseItemType[];
metadata?: {
collectionId: string;
sourceId?: string;
sourceName: string;
};
}>();
const [quoteFolded, setQuoteFolded] = useState<boolean>(true);
const chatType = useContextSelector(ChatBoxContext, (v) => v.chatType);
const setQuoteData = useContextSelector(ChatItemContext, (v) => v.setQuoteData);
const notSharePage = useMemo(() => chatType !== 'share', [chatType]);
const {
@ -81,7 +77,8 @@ const ResponseTags = ({
sourceName: item.sourceName,
sourceId: item.sourceId,
icon: getSourceNameIcon({ sourceId: item.sourceId, sourceName: item.sourceName }),
collectionId: item.collectionId
collectionId: item.collectionId,
datasetId: item.datasetId
}));
}, [quoteList]);
@ -99,7 +96,11 @@ const ResponseTags = ({
<>
<Flex justifyContent={'space-between'} alignItems={'center'}>
<Box width={'100%'}>
<ChatBoxDivider icon="core/chat/quoteFill" text={t('common:core.chat.Quote')} />
<ChatBoxDivider
icon="core/chat/quoteFill"
text={t('common:core.chat.Quote')}
iconColor="#E82F72"
/>
</Box>
{quoteFolded && quoteIsOverflow && (
<MyIcon
@ -135,15 +136,13 @@ const ResponseTags = ({
: {}
}
>
{sourceList.map((item) => {
{sourceList.map((item, index) => {
return (
<MyTooltip key={item.collectionId} label={t('common:core.chat.quote.Read Quote')}>
<Flex
alignItems={'center'}
fontSize={'xs'}
border={'sm'}
py={1.5}
px={2}
borderRadius={'sm'}
_hover={{
'.controller': {
@ -155,20 +154,46 @@ const ResponseTags = ({
cursor={'pointer'}
onClick={(e) => {
e.stopPropagation();
setQuoteModalData({
setQuoteData({
chatTime,
rawSearch: quoteList,
metadata: {
collectionId: item.collectionId,
sourceId: item.sourceId,
sourceName: item.sourceName
collectionIdList: [
...new Set(quoteList.map((item) => item.collectionId))
],
sourceId: item.sourceId || '',
sourceName: item.sourceName,
datasetId: item.datasetId,
chatItemId: historyItem.dataId
}
});
}}
height={6}
>
<MyIcon name={item.icon as any} mr={1} flexShrink={0} w={'12px'} />
<Box className="textEllipsis3" wordBreak={'break-all'} flex={'1 0 0'}>
{item.sourceName}
</Box>
<Flex
color={'myGray.500'}
bg={'myGray.150'}
w={4}
justifyContent={'center'}
fontSize={'10px'}
h={'full'}
alignItems={'center'}
>
{index + 1}
</Flex>
<Flex px={1.5}>
<MyIcon name={item.icon as any} mr={1} flexShrink={0} w={'12px'} />
<Box
className="textEllipsis3"
wordBreak={'break-all'}
flex={'1 0 0'}
fontSize={'mini'}
>
{item.sourceName}
</Box>
</Flex>
</Flex>
</MyTooltip>
);
@ -196,7 +221,22 @@ const ResponseTags = ({
colorSchema="blue"
type="borderSolid"
cursor={'pointer'}
onClick={() => setQuoteModalData({ rawSearch: quoteList })}
onClick={(e) => {
e.stopPropagation();
setQuoteData({
chatTime,
rawSearch: quoteList,
metadata: {
collectionId: '',
collectionIdList: [...new Set(quoteList.map((item) => item.collectionId))],
chatItemId: historyItem.dataId,
sourceId: '',
sourceName: '',
datasetId: ''
}
});
}}
>
{t('chat:citations', { num: quoteList.length })}
</MyTag>
@ -246,15 +286,10 @@ const ResponseTags = ({
</Flex>
)}
{!!quoteModalData && (
<QuoteModal
{...quoteModalData}
chatItemId={historyItem.dataId}
onClose={() => setQuoteModalData(undefined)}
/>
)}
{isOpenContextModal && <ContextModal dataId={dataId} onClose={onCloseContextModal} />}
{isOpenWholeModal && <WholeResponseModal dataId={dataId} onClose={onCloseWholeModal} />}
{isOpenWholeModal && (
<WholeResponseModal dataId={dataId} chatTime={chatTime} onClose={onCloseWholeModal} />
)}
</>
);
};

View File

@ -12,12 +12,18 @@ const RenderResponseDetail = () => {
const isChatting = useContextSelector(PluginRunContext, (v) => v.isChatting);
const responseData = chatRecords?.[1]?.responseData || [];
const chatTime = new Date();
return isChatting ? (
<>{t('chat:in_progress')}</>
) : (
<Box flex={'1 0 0'} h={'100%'} overflow={'auto'}>
<ResponseBox useMobile={true} response={responseData} dataId={chatRecords?.[1]?.dataId} />
<ResponseBox
useMobile={true}
response={responseData}
dataId={chatRecords?.[1]?.dataId}
chatTime={chatTime}
/>
</Box>
);
};

View File

@ -3,11 +3,19 @@ import React from 'react';
import MyIcon from '@fastgpt/web/components/common/Icon';
import type { IconNameType } from '@fastgpt/web/components/common/Icon/type.d';
const ChatBoxDivider = ({ icon, text }: { icon: IconNameType; text: string }) => {
const ChatBoxDivider = ({
icon,
text,
iconColor
}: {
icon: IconNameType;
text: string;
iconColor?: string;
}) => {
return (
<Box>
<Flex alignItems={'center'} py={2} gap={2}>
<MyIcon name={icon} w={'14px'} color={'myGray.900'} />
<MyIcon name={icon} w={'14px'} color={iconColor || 'myGray.900'} />
<Box color={'myGray.500'} fontSize={'sm'}>
{text}
</Box>

View File

@ -5,7 +5,7 @@ import { useTranslation } from 'next-i18next';
import { moduleTemplatesFlat } from '@fastgpt/global/core/workflow/template/constants';
import MyModal from '@fastgpt/web/components/common/MyModal';
import Markdown from '@/components/Markdown';
import { QuoteList } from '../ChatContainer/ChatBox/components/QuoteModal';
import QuoteList from '../ChatContainer/ChatBox/components/QuoteList';
import { DatasetSearchModeMap } from '@fastgpt/global/core/dataset/constants';
import { formatNumber } from '@fastgpt/global/common/math/tools';
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
@ -32,11 +32,13 @@ type sideTabItemType = {
export const WholeResponseContent = ({
activeModule,
hideTabs,
dataId
dataId,
chatTime
}: {
activeModule: ChatHistoryItemResType;
hideTabs?: boolean;
dataId?: string;
chatTime?: Date;
}) => {
const { t } = useTranslation();
@ -263,7 +265,13 @@ export const WholeResponseContent = ({
{activeModule.quoteList && activeModule.quoteList.length > 0 && (
<Row
label={t('common:core.chat.response.module quoteList')}
rawDom={<QuoteList chatItemId={dataId} rawSearch={activeModule.quoteList} />}
rawDom={
<QuoteList
chatItemId={dataId}
chatTime={chatTime || new Date()}
rawSearch={activeModule.quoteList}
/>
}
/>
)}
</>
@ -562,11 +570,13 @@ const SideTabItem = ({
export const ResponseBox = React.memo(function ResponseBox({
response,
dataId,
chatTime,
hideTabs = false,
useMobile = false
}: {
response: ChatHistoryItemResType[];
dataId?: string;
chatTime: Date;
hideTabs?: boolean;
useMobile?: boolean;
}) {
@ -689,7 +699,12 @@ export const ResponseBox = React.memo(function ResponseBox({
</Box>
</Box>
<Box flex={'5 0 0'} w={0} height={'100%'}>
<WholeResponseContent dataId={dataId} activeModule={activeModule} hideTabs={hideTabs} />
<WholeResponseContent
dataId={dataId}
activeModule={activeModule}
hideTabs={hideTabs}
chatTime={chatTime}
/>
</Box>
</Flex>
) : (
@ -753,6 +768,7 @@ export const ResponseBox = React.memo(function ResponseBox({
dataId={dataId}
activeModule={activeModule}
hideTabs={hideTabs}
chatTime={chatTime}
/>
</Box>
</Flex>
@ -763,7 +779,15 @@ export const ResponseBox = React.memo(function ResponseBox({
);
});
const WholeResponseModal = ({ onClose, dataId }: { onClose: () => void; dataId: string }) => {
const WholeResponseModal = ({
onClose,
dataId,
chatTime
}: {
onClose: () => void;
dataId: string;
chatTime: Date;
}) => {
const { t } = useTranslation();
const { getHistoryResponseData } = useContextSelector(ChatBoxContext, (v) => v);
@ -792,7 +816,7 @@ const WholeResponseModal = ({ onClose, dataId }: { onClose: () => void; dataId:
}
>
{!!response?.length ? (
<ResponseBox response={response} dataId={dataId} />
<ResponseBox response={response} dataId={dataId} chatTime={chatTime} />
) : (
<EmptyTip text={t('chat:no_workflow_response')} />
)}

View File

@ -14,8 +14,8 @@ import Markdown from '@/components/Markdown';
const InputDataModal = dynamic(() => import('@/pageComponents/dataset/detail/InputDataModal'));
type ScoreItemType = SearchDataResponseItemType['score'][0];
const scoreTheme: Record<
export type ScoreItemType = SearchDataResponseItemType['score'][0];
export const scoreTheme: Record<
string,
{
color: string;
@ -44,6 +44,47 @@ const scoreTheme: Record<
}
};
export const formatScore = (score: ScoreItemType[]) => {
if (!Array.isArray(score)) {
return {
primaryScore: undefined,
secondaryScore: []
};
}
// rrf -> rerank -> embedding -> fullText 优先级
let rrfScore: ScoreItemType | undefined = undefined;
let reRankScore: ScoreItemType | undefined = undefined;
let embeddingScore: ScoreItemType | undefined = undefined;
let fullTextScore: ScoreItemType | undefined = undefined;
score.forEach((item) => {
if (item.type === SearchScoreTypeEnum.rrf) {
rrfScore = item;
} else if (item.type === SearchScoreTypeEnum.reRank) {
reRankScore = item;
} else if (item.type === SearchScoreTypeEnum.embedding) {
embeddingScore = item;
} else if (item.type === SearchScoreTypeEnum.fullText) {
fullTextScore = item;
}
});
const primaryScore = (rrfScore ||
reRankScore ||
embeddingScore ||
fullTextScore) as unknown as ScoreItemType;
const secondaryScore = [rrfScore, reRankScore, embeddingScore, fullTextScore].filter(
// @ts-ignore
(item) => item && primaryScore && item.type !== primaryScore.type
) as unknown as ScoreItemType[];
return {
primaryScore,
secondaryScore
};
};
const QuoteItem = ({
quoteItem,
canViewSource,
@ -58,44 +99,7 @@ const QuoteItem = ({
const [editInputData, setEditInputData] = useState<{ dataId: string; collectionId: string }>();
const score = useMemo(() => {
if (!Array.isArray(quoteItem.score)) {
return {
primaryScore: undefined,
secondaryScore: []
};
}
// rrf -> rerank -> embedding -> fullText 优先级
let rrfScore: ScoreItemType | undefined = undefined;
let reRankScore: ScoreItemType | undefined = undefined;
let embeddingScore: ScoreItemType | undefined = undefined;
let fullTextScore: ScoreItemType | undefined = undefined;
quoteItem.score.forEach((item) => {
if (item.type === SearchScoreTypeEnum.rrf) {
rrfScore = item;
} else if (item.type === SearchScoreTypeEnum.reRank) {
reRankScore = item;
} else if (item.type === SearchScoreTypeEnum.embedding) {
embeddingScore = item;
} else if (item.type === SearchScoreTypeEnum.fullText) {
fullTextScore = item;
}
});
const primaryScore = (rrfScore ||
reRankScore ||
embeddingScore ||
fullTextScore) as unknown as ScoreItemType;
const secondaryScore = [rrfScore, reRankScore, embeddingScore, fullTextScore].filter(
// @ts-ignore
(item) => item && primaryScore && item.type !== primaryScore.type
) as unknown as ScoreItemType[];
return {
primaryScore,
secondaryScore
};
return formatScore(quoteItem.score);
}, [quoteItem.score]);
return (

View File

@ -39,5 +39,6 @@ export type DatasetDataListItemType = {
q: string; // embedding content
a: string; // bonus content
chunkIndex?: number;
updated?: boolean;
// indexes: DatasetDataSchemaType['indexes'];
};

View File

@ -19,6 +19,7 @@ import ChatRecordContextProvider, {
} from '@/web/core/chat/context/chatRecordContext';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { useContextSelector } from 'use-context-selector';
import ChatQuoteList from '@/pageComponents/chat/ChatQuoteList';
const PluginRunBox = dynamic(() => import('@/components/core/chat/ChatContainer/PluginRunBox'));
const ChatBox = dynamic(() => import('@/components/core/chat/ChatContainer/ChatBox'));
@ -37,6 +38,8 @@ const DetailLogsModal = ({ appId, chatId, onClose }: Props) => {
const setChatBoxData = useContextSelector(ChatItemContext, (v) => v.setChatBoxData);
const pluginRunTab = useContextSelector(ChatItemContext, (v) => v.pluginRunTab);
const setPluginRunTab = useContextSelector(ChatItemContext, (v) => v.setPluginRunTab);
const quoteData = useContextSelector(ChatItemContext, (v) => v.quoteData);
const setQuoteData = useContextSelector(ChatItemContext, (v) => v.setQuoteData);
const chatRecords = useContextSelector(ChatRecordContext, (v) => v.chatRecords);
const totalRecordsCount = useContextSelector(ChatRecordContext, (v) => v.totalRecordsCount);
@ -76,7 +79,7 @@ const DetailLogsModal = ({ appId, chatId, onClose }: Props) => {
zIndex={3}
position={['fixed', 'absolute']}
top={[0, '2%']}
right={0}
right={quoteData ? 600 : 0}
h={['100%', '96%']}
w={'100%'}
maxW={['100%', '600px']}
@ -168,6 +171,26 @@ const DetailLogsModal = ({ appId, chatId, onClose }: Props) => {
)}
</Box>
</MyBox>
{quoteData && (
<Box
w={['full', '588px']}
zIndex={300}
position={'absolute'}
top={5}
right={0}
h={'95%'}
bg={'white'}
boxShadow={'3px 0 20px rgba(0,0,0,0.2)'}
borderRadius={'md'}
>
<ChatQuoteList
chatTime={quoteData.chatTime}
rawSearch={quoteData.rawSearch}
metadata={quoteData.metadata}
onClose={() => setQuoteData(undefined)}
/>
</Box>
)}
<Box zIndex={2} position={'fixed'} top={0} left={0} bottom={0} right={0} onClick={onClose} />
</>
);
@ -189,6 +212,7 @@ const Render = (props: Props) => {
showRouteToAppDetail={true}
showRouteToDatasetDetail={true}
isShowReadRawSource={true}
// isShowFullText={true}
showNodeStatus
>
<ChatRecordContextProvider params={params}>

View File

@ -42,7 +42,6 @@ import { getDocPath } from '@/web/common/system/doc';
import dynamic from 'next/dynamic';
import MyMenu from '@fastgpt/web/components/common/MyMenu';
import { useConfirm } from '@fastgpt/web/hooks/useConfirm';
import { useI18n } from '@/web/context/I18n';
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
import EmptyTip from '@fastgpt/web/components/common/EmptyTip';
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
@ -185,6 +184,7 @@ const Share = ({ appId }: { appId: string; type: PublishChannelEnum }) => {
name: item.name,
responseDetail: item.responseDetail ?? false,
showRawSource: item.showRawSource ?? false,
// showFullText: item.showFullText ?? false,
showNodeStatus: item.showNodeStatus ?? false,
limit: item.limit
})
@ -270,7 +270,6 @@ function EditLinkModal({
}) {
const { feConfigs } = useSystemStore();
const { t } = useTranslation();
const { publishT } = useI18n();
const {
register,
setValue,
@ -281,6 +280,7 @@ function EditLinkModal({
});
const responseDetail = watch('responseDetail');
// const showFullText = watch('showFullText');
const showRawSource = watch('showRawSource');
const isEdit = useMemo(() => !!defaultData._id, [defaultData]);
@ -306,7 +306,7 @@ function EditLinkModal({
<MyModal
isOpen={true}
iconSrc="/imgs/modal/shareFill.svg"
title={isEdit ? publishT('edit_link') : publishT('create_link')}
title={isEdit ? t('publish:edit_link') : t('publish:create_link')}
maxW={['90vw', '700px']}
w={'100%'}
h={['90vh', 'auto']}
@ -325,10 +325,10 @@ function EditLinkModal({
<Flex alignItems={'center'} mt={4}>
<FormLabel flex={'0 0 90px'}>{t('common:Name')}</FormLabel>
<Input
placeholder={publishT('link_name')}
placeholder={t('publish:link_name')}
maxLength={20}
{...register('name', {
required: t('common:common.name_is_empty') || 'name_is_empty'
required: t('common:common.name_is_empty')
})}
/>
</Flex>
@ -353,7 +353,7 @@ function EditLinkModal({
<Flex alignItems={'center'} mt={4}>
<Flex flex={'0 0 90px'} alignItems={'center'}>
<FormLabel>QPM</FormLabel>
<QuestionTip ml={1} label={publishT('qpm_tips')}></QuestionTip>
<QuestionTip ml={1} label={t('publish:qpm_tips')}></QuestionTip>
</Flex>
<Input
max={1000}
@ -361,7 +361,7 @@ function EditLinkModal({
min: 0,
max: 1000,
valueAsNumber: true,
required: publishT('qpm_is_empty') || ''
required: t('publish:qpm_is_empty')
})}
/>
</Flex>
@ -385,11 +385,11 @@ function EditLinkModal({
<Flex alignItems={'center'} mt={4}>
<Flex flex={'0 0 90px'} alignItems={'center'}>
<FormLabel>{publishT('token_auth')}</FormLabel>
<QuestionTip ml={1} label={publishT('token_auth_tips') || ''}></QuestionTip>
<FormLabel>{t('publish:token_auth')}</FormLabel>
<QuestionTip ml={1} label={t('publish:token_auth_tips')}></QuestionTip>
</Flex>
<Input
placeholder={publishT('token_auth_tips') || ''}
placeholder={t('publish:token_auth_tips')}
fontSize={'sm'}
{...register('limit.hookUrl')}
/>
@ -400,7 +400,7 @@ function EditLinkModal({
fontSize={'xs'}
color={'myGray.500'}
>
{publishT('token_auth_use_cases')}
{t('publish:token_auth_use_cases')}
</Link>
</>
)}
@ -421,8 +421,39 @@ function EditLinkModal({
label={t('common:support.outlink.share.Response Quote tips')}
></QuestionTip>
</Flex>
<Switch {...register('responseDetail')} isChecked={responseDetail} />
<Switch
{...register('responseDetail', {
onChange(e) {
if (!e.target.checked) {
// setValue('showFullText', false);
setValue('showRawSource', false);
}
}
})}
isChecked={responseDetail}
/>
</Flex>
{/* <Flex alignItems={'center'} mt={4} justify={'space-between'} height={'36px'}>
<Flex alignItems={'center'}>
<FormLabel>{t('common:support.outlink.share.Chat_quote_reader')}</FormLabel>
<QuestionTip
ml={1}
label={t('common:support.outlink.share.Full_text tips')}
></QuestionTip>
</Flex>
<Switch
{...register('showFullText', {
onChange(e) {
if (e.target.checked) {
setValue('responseDetail', true);
} else {
setValue('showRawSource', false);
}
}
})}
isChecked={showFullText}
/>
</Flex> */}
<Flex alignItems={'center'} mt={4} justify={'space-between'} height={'36px'}>
<Flex alignItems={'center'}>
<FormLabel>{t('common:support.outlink.share.show_complete_quote')}</FormLabel>
@ -436,6 +467,7 @@ function EditLinkModal({
onChange(e) {
if (e.target.checked) {
setValue('responseDetail', true);
// setValue('showFullText', true);
}
}
})}

View File

@ -7,21 +7,26 @@ import MyIcon from '@fastgpt/web/components/common/Icon';
import { useSafeState } from 'ahooks';
import { AppSimpleEditFormType } from '@fastgpt/global/core/app/type';
import { form2AppWorkflow } from '@/web/core/app/utils';
import { useI18n } from '@/web/context/I18n';
import { useContextSelector } from 'use-context-selector';
import { AppContext } from '../context';
import { useChatTest } from '../useChatTest';
import ChatItemContextProvider from '@/web/core/chat/context/chatItemContext';
import ChatItemContextProvider, { ChatItemContext } from '@/web/core/chat/context/chatItemContext';
import ChatRecordContextProvider from '@/web/core/chat/context/chatRecordContext';
import { useChatStore } from '@/web/core/chat/context/useChatStore';
import MyBox from '@fastgpt/web/components/common/MyBox';
import { cardStyles } from '../constants';
import ChatQuoteList from '@/pageComponents/chat/ChatQuoteList';
type Props = { appForm: AppSimpleEditFormType };
const ChatTest = ({ appForm }: Props) => {
type Props = {
appForm: AppSimpleEditFormType;
setRenderEdit: React.Dispatch<React.SetStateAction<boolean>>;
};
const ChatTest = ({ appForm, setRenderEdit }: Props) => {
const { t } = useTranslation();
const { appT } = useI18n();
const { appDetail } = useContextSelector(AppContext, (v) => v);
const quoteData = useContextSelector(ChatItemContext, (v) => v.quoteData);
const setQuoteData = useContextSelector(ChatItemContext, (v) => v.setQuoteData);
const [workflowData, setWorkflowData] = useSafeState({
nodes: appDetail.modules || [],
@ -32,6 +37,11 @@ const ChatTest = ({ appForm }: Props) => {
const { nodes, edges } = form2AppWorkflow(appForm, t);
setWorkflowData({ nodes, edges });
}, [appForm, setWorkflowData, t]);
useEffect(() => {
setRenderEdit(!quoteData);
}, [quoteData, setRenderEdit]);
const { ChatContainer, restartChat, loading } = useChatTest({
...workflowData,
chatConfig: appForm.chatConfig,
@ -39,41 +49,56 @@ const ChatTest = ({ appForm }: Props) => {
});
return (
<MyBox
isLoading={loading}
display={'flex'}
position={'relative'}
flexDirection={'column'}
h={'100%'}
py={4}
>
<Flex px={[2, 5]}>
<Box fontSize={['md', 'lg']} fontWeight={'bold'} flex={1} color={'myGray.900'}>
{appT('chat_debug')}
<Flex h={'full'} gap={2}>
<MyBox
isLoading={loading}
display={'flex'}
position={'relative'}
flexDirection={'column'}
h={'full'}
w={quoteData ? '' : 'full'}
py={4}
{...cardStyles}
boxShadow={'3'}
>
<Flex px={[2, 5]}>
<Box fontSize={['md', 'lg']} fontWeight={'bold'} flex={1} color={'myGray.900'}>
{t('app:chat_debug')}
</Box>
<MyTooltip label={t('common:core.chat.Restart')}>
<IconButton
className="chat"
size={'smSquare'}
icon={<MyIcon name={'common/clearLight'} w={'14px'} />}
variant={'whiteDanger'}
borderRadius={'md'}
aria-label={'delete'}
onClick={(e) => {
e.stopPropagation();
restartChat();
}}
/>
</MyTooltip>
</Flex>
<Box flex={1}>
<ChatContainer />
</Box>
<MyTooltip label={t('common:core.chat.Restart')}>
<IconButton
className="chat"
size={'smSquare'}
icon={<MyIcon name={'common/clearLight'} w={'14px'} />}
variant={'whiteDanger'}
borderRadius={'md'}
aria-label={'delete'}
onClick={(e) => {
e.stopPropagation();
restartChat();
}}
</MyBox>
{quoteData && (
<Box w={['full', '588px']} {...cardStyles} boxShadow={'3'}>
<ChatQuoteList
chatTime={quoteData.chatTime}
rawSearch={quoteData.rawSearch}
metadata={quoteData.metadata}
onClose={() => setQuoteData(undefined)}
/>
</MyTooltip>
</Flex>
<Box flex={1}>
<ChatContainer />
</Box>
</MyBox>
</Box>
)}
</Flex>
);
};
const Render = ({ appForm }: Props) => {
const Render = ({ appForm, setRenderEdit }: Props) => {
const { chatId } = useChatStore();
const { appDetail } = useContextSelector(AppContext, (v) => v);
@ -90,10 +115,11 @@ const Render = ({ appForm }: Props) => {
showRouteToAppDetail={true}
showRouteToDatasetDetail={true}
isShowReadRawSource={true}
// isShowFullText={true}
showNodeStatus
>
<ChatRecordContextProvider params={chatRecordProviderParams}>
<ChatTest appForm={appForm} />
<ChatTest appForm={appForm} setRenderEdit={setRenderEdit} />
</ChatRecordContextProvider>
</ChatItemContextProvider>
);

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { useState } from 'react';
import { Box } from '@chakra-ui/react';
import ChatTest from './ChatTest';
@ -21,6 +21,7 @@ const Edit = ({
setPast: (value: React.SetStateAction<SimpleAppSnapshotType[]>) => void;
}) => {
const { isPc } = useSystem();
const [renderEdit, setRenderEdit] = useState(true);
return (
<Box
@ -32,24 +33,26 @@ const Edit = ({
borderRadius={'lg'}
overflowY={['auto', 'unset']}
>
<Box
className={styles.EditAppBox}
pr={[0, 1]}
overflowY={'auto'}
minW={['auto', '580px']}
flex={'1'}
>
<Box {...cardStyles} boxShadow={'2'}>
<AppCard appForm={appForm} setPast={setPast} />
</Box>
{renderEdit && (
<Box
className={styles.EditAppBox}
pr={[0, 1]}
overflowY={'auto'}
minW={['auto', '580px']}
flex={'1'}
>
<Box {...cardStyles} boxShadow={'2'}>
<AppCard appForm={appForm} setPast={setPast} />
</Box>
<Box mt={4} {...cardStyles} boxShadow={'3.5'}>
<EditForm appForm={appForm} setAppForm={setAppForm} />
<Box mt={4} {...cardStyles} boxShadow={'3.5'}>
<EditForm appForm={appForm} setAppForm={setAppForm} />
</Box>
</Box>
</Box>
)}
{isPc && (
<Box {...cardStyles} boxShadow={'3'} flex={'2 0 0'} w={0} mb={3}>
<ChatTest appForm={appForm} />
<Box flex={'2 0 0'} w={0} mb={3}>
<ChatTest appForm={appForm} setRenderEdit={setRenderEdit} />
</Box>
)}
</Box>

View File

@ -20,6 +20,7 @@ import ChatRecordContextProvider, {
} from '@/web/core/chat/context/chatRecordContext';
import { useChatStore } from '@/web/core/chat/context/useChatStore';
import MyBox from '@fastgpt/web/components/common/MyBox';
import ChatQuoteList from '@/pageComponents/chat/ChatQuoteList';
type Props = {
isOpen: boolean;
@ -41,10 +42,13 @@ const ChatTest = ({ isOpen, nodes = [], edges = [], onClose }: Props) => {
});
const pluginRunTab = useContextSelector(ChatItemContext, (v) => v.pluginRunTab);
const setPluginRunTab = useContextSelector(ChatItemContext, (v) => v.setPluginRunTab);
const quoteData = useContextSelector(ChatItemContext, (v) => v.quoteData);
const setQuoteData = useContextSelector(ChatItemContext, (v) => v.setQuoteData);
const chatRecords = useContextSelector(ChatRecordContext, (v) => v.chatRecords);
return (
<>
<Flex h={'full'}>
<Box
zIndex={300}
display={isOpen ? 'block' : 'none'}
@ -53,7 +57,10 @@ const ChatTest = ({ isOpen, nodes = [], edges = [], onClose }: Props) => {
left={0}
bottom={0}
right={0}
onClick={onClose}
onClick={() => {
setQuoteData(undefined);
onClose();
}}
/>
<MyBox
isLoading={loading}
@ -62,7 +69,7 @@ const ChatTest = ({ isOpen, nodes = [], edges = [], onClose }: Props) => {
flexDirection={'column'}
position={'absolute'}
top={5}
right={0}
right={quoteData ? 600 : 0}
h={isOpen ? '95%' : '0'}
w={isOpen ? ['100%', '460px'] : '0'}
bg={'white'}
@ -141,7 +148,27 @@ const ChatTest = ({ isOpen, nodes = [], edges = [], onClose }: Props) => {
<ChatContainer />
</Box>
</MyBox>
</>
{quoteData && (
<Box
w={['full', '588px']}
zIndex={300}
position={'absolute'}
top={5}
right={0}
h={'95%'}
bg={'white'}
boxShadow={'3px 0 20px rgba(0,0,0,0.2)'}
borderRadius={'md'}
>
<ChatQuoteList
chatTime={quoteData.chatTime}
rawSearch={quoteData.rawSearch}
metadata={quoteData.metadata}
onClose={() => setQuoteData(undefined)}
/>
</Box>
)}
</Flex>
);
};
@ -162,6 +189,7 @@ const Render = (Props: Props) => {
showRouteToAppDetail={true}
showRouteToDatasetDetail={true}
isShowReadRawSource={true}
// isShowFullText={true}
showNodeStatus
>
<ChatRecordContextProvider params={chatRecordProviderParams}>

View File

@ -46,6 +46,7 @@ const ChatHistorySlider = ({ confirmClearText }: { confirmClearText: string }) =
const appName = useContextSelector(ChatItemContext, (v) => v.chatBoxData?.app.name);
const appAvatar = useContextSelector(ChatItemContext, (v) => v.chatBoxData?.app.avatar);
const showRouteToAppDetail = useContextSelector(ChatItemContext, (v) => v.showRouteToAppDetail);
const setQuoteData = useContextSelector(ChatItemContext, (v) => v.setQuoteData);
const concatHistory = useMemo(() => {
const formatHistories: HistoryItemType[] = histories.map((item) => {
@ -144,7 +145,10 @@ const ChatHistorySlider = ({ confirmClearText }: { confirmClearText: string }) =
borderRadius={'xl'}
leftIcon={<MyIcon name={'core/chat/chatLight'} w={'16px'} />}
overflow={'hidden'}
onClick={() => onChangeChatId()}
onClick={() => {
onChangeChatId();
setQuoteData(undefined);
}}
>
{t('common:core.chat.New Chat')}
</Button>
@ -199,6 +203,7 @@ const ChatHistorySlider = ({ confirmClearText }: { confirmClearText: string }) =
: {
onClick: () => {
onChangeChatId(item.id);
setQuoteData(undefined);
}
})}
{...(i !== concatHistory.length - 1 && {
@ -270,6 +275,7 @@ const ChatHistorySlider = ({ confirmClearText }: { confirmClearText: string }) =
onDelHistory(item.id);
if (item.id === activeChatId) {
onChangeChatId();
setQuoteData(undefined);
}
},
type: 'danger'

View File

@ -0,0 +1,192 @@
import Markdown from '@/components/Markdown';
import { Box, Flex } from '@chakra-ui/react';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import { Dispatch, MutableRefObject, SetStateAction, useState } from 'react';
import { useTranslation } from 'react-i18next';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { useCopyData } from '@fastgpt/web/hooks/useCopyData';
import InputDataModal from '@/pageComponents/dataset/detail/InputDataModal';
const CollectionQuoteItem = ({
index,
quoteRefs,
quoteIndex,
setQuoteIndex,
refreshList,
canEdit,
updated,
isCurrentSelected,
q,
a,
dataId,
collectionId
}: {
index: number;
quoteRefs: MutableRefObject<(HTMLDivElement | null)[]>;
quoteIndex: number;
setQuoteIndex: Dispatch<SetStateAction<number>>;
refreshList: () => void;
canEdit: boolean;
updated?: boolean;
isCurrentSelected: boolean;
q: string;
a?: string;
dataId: string;
collectionId: string;
}) => {
const { t } = useTranslation();
const { copyData } = useCopyData();
const hasBeenSearched = quoteIndex !== undefined && quoteIndex > -1;
const [editInputData, setEditInputData] = useState<{ dataId: string; collectionId: string }>();
return (
<>
<Box
ref={(el: HTMLDivElement | null) => {
quoteRefs.current[index] = el;
}}
p={2}
py={2}
cursor={hasBeenSearched ? 'pointer' : 'default'}
bg={isCurrentSelected ? '#FFF9E7' : hasBeenSearched ? '#FFFCF2' : ''}
position={'relative'}
overflow={'hidden'}
border={'1px solid '}
borderColor={isCurrentSelected ? 'yellow.200' : 'transparent'}
wordBreak={'break-all'}
fontSize={'sm'}
_hover={
hasBeenSearched
? {
'& .hover-data': { visibility: 'visible' }
}
: {
bg: 'linear-gradient(180deg, #FBFBFC 7.61%, #F0F1F6 100%)',
borderTopColor: 'myGray.50',
'& .hover-data': { visibility: 'visible' }
}
}
onClick={(e) => {
e.stopPropagation();
if (hasBeenSearched) {
setQuoteIndex(quoteIndex);
}
}}
>
{updated && (
<Flex
position={'absolute'}
top={2}
right={5}
gap={1}
bg={'yellow.50'}
color={'yellow.500'}
px={2}
py={1}
rounded={'md'}
fontSize={'12px'}
>
<MyIcon name="common/info" w={'14px'} color={'yellow.500'} />
{t('common:core.dataset.data.Updated')}
</Flex>
)}
<Markdown source={q} />
{!!a && (
<Box>
<Markdown source={a} />
</Box>
)}
<Flex
className="hover-data"
position={'absolute'}
bottom={2}
right={5}
gap={1.5}
visibility={'hidden'}
>
<MyTooltip label={t('common:core.dataset.Quote Length')}>
<Flex
alignItems={'center'}
fontSize={'10px'}
border={'1px solid'}
borderColor={'myGray.200'}
bg={'white'}
rounded={'sm'}
px={2}
py={1}
boxShadow={
'0px 1px 2px 0px rgba(19, 51, 107, 0.05), 0px 0px 1px 0px rgba(19, 51, 107, 0.08)'
}
>
<MyIcon name="common/text/t" w={'14px'} mr={1} color={'myGray.500'} />
{q.length + (a?.length || 0)}
</Flex>
</MyTooltip>
{canEdit && (
<MyTooltip label={t('common:core.dataset.data.Edit')}>
<Flex
alignItems={'center'}
fontSize={'10px'}
border={'1px solid'}
borderColor={'myGray.200'}
bg={'white'}
rounded={'sm'}
px={1}
py={1}
boxShadow={
'0px 1px 2px 0px rgba(19, 51, 107, 0.05), 0px 0px 1px 0px rgba(19, 51, 107, 0.08)'
}
cursor={'pointer'}
onClick={() =>
setEditInputData({
dataId,
collectionId
})
}
>
<MyIcon name="common/edit" w={'14px'} color={'myGray.500'} />
</Flex>
</MyTooltip>
)}
<MyTooltip label={t('common:common.Copy')}>
<Flex
alignItems={'center'}
fontSize={'10px'}
border={'1px solid'}
borderColor={'myGray.200'}
bg={'white'}
rounded={'sm'}
px={1}
py={1}
boxShadow={
'0px 1px 2px 0px rgba(19, 51, 107, 0.05), 0px 0px 1px 0px rgba(19, 51, 107, 0.08)'
}
cursor={'pointer'}
onClick={() => {
copyData(q + '\n' + a);
}}
>
<MyIcon name="copy" w={'14px'} color={'myGray.500'} />
</Flex>
</MyTooltip>
</Flex>
</Box>
{editInputData && (
<InputDataModal
onClose={() => setEditInputData(undefined)}
onSuccess={() => {
console.log('onSuccess');
refreshList();
}}
dataId={editInputData.dataId}
collectionId={editInputData.collectionId}
/>
)}
</>
);
};
export default CollectionQuoteItem;

View File

@ -0,0 +1,345 @@
import { Box, Button, Flex } from '@chakra-ui/react';
import { SearchDataResponseItemType } from '@fastgpt/global/core/dataset/type';
import { getSourceNameIcon } from '@fastgpt/global/core/dataset/utils';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { useRouter } from 'next/router';
import { useTranslation } from 'react-i18next';
import DownloadButton from './DownloadButton';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { downloadFetch } from '@/web/common/system/utils';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { getCollectionSource, getDatasetDataPermission } from '@/web/core/dataset/api';
import { useChatStore } from '@/web/core/chat/context/useChatStore';
import { useToast } from '@fastgpt/web/hooks/useToast';
import { getErrText } from '@fastgpt/global/common/error/utils';
import ScoreTag from './ScoreTag';
import { formatScore } from '@/components/core/dataset/QuoteItem';
import NavButton from './NavButton';
import { useLinkedScroll } from '@fastgpt/web/hooks/useLinkedScroll';
import CollectionQuoteItem from './CollectionQuoteItem';
import { DatasetDataListItemType } from '@/global/core/dataset/type';
import { metadataType } from '@/web/core/chat/context/chatItemContext';
import { useUserStore } from '@/web/support/user/useUserStore';
import { getCollectionQuote } from '@/web/core/chat/api';
const CollectionReader = ({
rawSearch,
metadata,
chatTime,
onClose
}: {
rawSearch: SearchDataResponseItemType[];
metadata: metadataType;
chatTime: Date;
onClose: () => void;
}) => {
const { t } = useTranslation();
const router = useRouter();
const { toast } = useToast();
const { chatId, appId, outLinkAuthData } = useChatStore();
const { userInfo } = useUserStore();
const { collectionId, datasetId, chatItemId, sourceId, sourceName } = metadata;
const [quoteIndex, setQuoteIndex] = useState(0);
const { data: permissionData, loading: isPermissionLoading } = useRequest2(
async () => await getDatasetDataPermission(datasetId),
{
manual: !userInfo && !datasetId,
refreshDeps: [datasetId, userInfo]
}
);
const filterResults = useMemo(() => {
const results = rawSearch.filter(
(item) => item.collectionId === metadata.collectionId && item.sourceId === metadata.sourceId
);
return results.sort((a, b) => (a.chunkIndex || 0) - (b.chunkIndex || 0));
}, [metadata, rawSearch]);
const currentQuoteItem = filterResults[quoteIndex];
const {
dataList: datasetDataList,
setDataList: setDatasetDataList,
isLoading,
loadData,
ScrollData,
itemRefs,
scrollToItem
} = useLinkedScroll(getCollectionQuote, {
refreshDeps: [collectionId],
params: {
collectionId,
chatTime,
chatItemId,
chatId,
appId,
...outLinkAuthData
},
initialId: currentQuoteItem?.id,
initialIndex: currentQuoteItem?.chunkIndex,
canLoadData: !!currentQuoteItem?.id && !isPermissionLoading
});
const loading = isLoading || isPermissionLoading;
const isDeleted = !datasetDataList.find((item) => item._id === currentQuoteItem?.id);
const formatedDataList = useMemo(
() =>
datasetDataList.map((item: DatasetDataListItemType) => {
const isCurrentSelected = currentQuoteItem?.id === item._id;
const quoteIndex = filterResults.findIndex((res) => res.id === item._id);
return {
...item,
isCurrentSelected,
quoteIndex
};
}),
[currentQuoteItem?.id, datasetDataList, filterResults]
);
useEffect(() => {
setQuoteIndex(0);
setDatasetDataList([]);
}, [collectionId, setDatasetDataList]);
const { runAsync: handleDownload, loading: downloadLoading } = useRequest2(async () => {
await downloadFetch({
url: '/api/core/dataset/collection/export',
filename: 'parsed_content.md',
body: {
collectionId: collectionId,
chatTime: chatTime,
chatItemId: chatItemId
}
});
});
const { runAsync: handleRead, loading: readLoading } = useRequest2(
async () => await getCollectionSource({ ...metadata, appId, chatId }),
{
onSuccess: (res) => {
if (!res.value) {
throw new Error('No file found');
}
if (res.value.startsWith('/')) {
window.open(`${location.origin}${res.value}`, '_blank');
} else {
window.open(res.value, '_blank');
}
},
onError: (err) => {
toast({
title: t(getErrText(err, t('common:error.fileNotFound'))),
status: 'error'
});
}
}
);
const handleNavigate = useCallback(
async (targetIndex: number) => {
if (targetIndex < 0 || targetIndex >= filterResults.length) return;
const targetItemId = filterResults[targetIndex].id;
const targetItemIndex = filterResults[targetIndex].chunkIndex;
setQuoteIndex(targetIndex);
const dataIndex = datasetDataList.findIndex((item) => item._id === targetItemId);
if (dataIndex !== -1) {
setTimeout(() => {
scrollToItem(dataIndex);
}, 50);
} else {
try {
await loadData({ id: targetItemId, index: targetItemIndex });
} catch (error) {
console.error('Failed to navigate:', error);
}
}
},
[filterResults, datasetDataList, scrollToItem, loadData]
);
return (
<Flex flexDirection={'column'} h={'full'}>
{/* title */}
<Flex
w={'full'}
alignItems={'center'}
px={5}
borderBottom={'1px solid'}
borderColor={'myGray.150'}
>
<Box flex={1} py={4}>
<Flex mb={1} alignItems={['flex-start', 'center']} flexDirection={['column', 'row']}>
<Flex gap={2} mr={2}>
<MyIcon
name={getSourceNameIcon({ sourceId, sourceName }) as any}
w={['1rem', '1.25rem']}
color={'primary.600'}
/>
<Box
maxW={['200px', '300px']}
className={'textEllipsis'}
wordBreak={'break-all'}
color={'myGray.900'}
fontWeight={'medium'}
>
{sourceName || t('common:common.UnKnow Source')}
</Box>
</Flex>
<Flex gap={3} mt={[2, 0]} alignItems={'center'}>
{!!userInfo && permissionData?.permission?.hasReadPer && (
<Button
variant={'primaryGhost'}
size={'xs'}
fontSize={'mini'}
border={'none'}
_hover={{
bg: 'primary.100'
}}
onClick={() => {
router.push(
`/dataset/detail?datasetId=${datasetId}&currentTab=dataCard&collectionId=${collectionId}`
);
}}
>
{t('common:core.dataset.Go Dataset')}
<MyIcon name="common/upperRight" w={4} ml={1} />
</Button>
)}
<DownloadButton
canAccessRawData={true}
onDownload={handleDownload}
onRead={handleRead}
isLoading={downloadLoading || readLoading}
/>
</Flex>
</Flex>
<Box fontSize={'mini'} color={'myGray.500'}>
{t('common:core.chat.quote.Quote Tip')}
</Box>
</Box>
<Box
cursor={'pointer'}
borderRadius={'sm'}
p={1}
_hover={{
bg: 'myGray.100'
}}
onClick={onClose}
>
<MyIcon name="common/closeLight" color={'myGray.900'} w={6} />
</Box>
</Flex>
{/* header control */}
{datasetDataList.length > 0 && (
<Flex
w={'full'}
px={4}
py={2}
alignItems={'center'}
borderBottom={'1px solid'}
borderColor={'myGray.150'}
>
{/* 引用序号 */}
<Flex fontSize={'mini'} mr={3} alignItems={'center'} gap={1}>
<Box as={'span'} color={'myGray.900'}>
{t('common:core.chat.Quote')} {quoteIndex + 1}
</Box>
<Box as={'span'} color={'myGray.500'}>
/
</Box>
<Box as={'span'} color={'myGray.500'}>
{filterResults.length}
</Box>
</Flex>
{/* 检索分数 */}
{!loading &&
(!isDeleted ? (
<ScoreTag {...formatScore(currentQuoteItem?.score)} />
) : (
<Flex
borderRadius={'sm'}
py={1}
px={2}
color={'red.600'}
bg={'red.50'}
alignItems={'center'}
fontSize={'11px'}
>
<MyIcon name="common/info" w={'14px'} mr={1} color={'red.600'} />
{t('chat:chat.quote.deleted')}
</Flex>
))}
<Box flex={1} />
{/* 检索按钮 */}
<Flex gap={1}>
<NavButton
direction="up"
isDisabled={quoteIndex === 0}
onClick={() => handleNavigate(quoteIndex - 1)}
/>
<NavButton
direction="down"
isDisabled={quoteIndex === filterResults.length - 1}
onClick={() => handleNavigate(quoteIndex + 1)}
/>
</Flex>
</Flex>
)}
{/* quote list */}
{loading || datasetDataList.length > 0 ? (
<ScrollData flex={'1 0 0'} mt={2} px={5} py={1} isLoading={loading}>
<Flex flexDir={'column'} gap={3}>
{formatedDataList.map((item, index) => (
<CollectionQuoteItem
key={item._id}
index={index}
quoteRefs={itemRefs as React.MutableRefObject<(HTMLDivElement | null)[]>}
quoteIndex={item.quoteIndex}
setQuoteIndex={setQuoteIndex}
refreshList={() =>
currentQuoteItem?.id &&
loadData({ id: currentQuoteItem.id, index: currentQuoteItem.chunkIndex })
}
updated={item.updated}
isCurrentSelected={item.isCurrentSelected}
q={item.q}
a={item.a}
dataId={item._id}
collectionId={collectionId}
canEdit={!!userInfo && !!permissionData?.permission?.hasWritePer}
/>
))}
</Flex>
</ScrollData>
) : (
<Flex
flex={'1 0 0'}
flexDirection={'column'}
gap={1}
justifyContent={'center'}
alignItems={'center'}
>
<Box border={'1px dashed'} borderColor={'myGray.400'} p={2} borderRadius={'full'}>
<MyIcon name="common/fileNotFound" />
</Box>
<Box fontSize={'sm'} color={'myGray.500'}>
{t('chat:chat.quote.No Data')}
</Box>
</Flex>
)}
</Flex>
);
};
export default CollectionReader;

View File

@ -0,0 +1,68 @@
import { Button } from '@chakra-ui/react';
import MyMenu from '@fastgpt/web/components/common/MyMenu';
import { useTranslation } from 'react-i18next';
import MyIcon from '@fastgpt/web/components/common/Icon';
const DownloadButton = ({
canAccessRawData,
onDownload,
onRead,
isLoading
}: {
canAccessRawData: boolean;
onDownload: () => void;
onRead: () => void;
isLoading: boolean;
}) => {
const { t } = useTranslation();
if (canAccessRawData) {
return (
<MyMenu
size={'xs'}
Button={
<Button
variant={'whitePrimary'}
size={'xs'}
fontSize={'mini'}
leftIcon={<MyIcon name={'common/download'} w={'4'} />}
isLoading={isLoading}
>
{t('common:Download')}
</Button>
}
menuList={[
{
children: [
{
label: t('common:core.dataset.Download the parsed content'),
type: 'grayBg',
onClick: onDownload
},
{
label: t('common:core.dataset.Get the raw data'),
type: 'grayBg',
onClick: onRead
}
]
}
]}
/>
);
}
return (
<Button
variant={'whitePrimary'}
size={'xs'}
fontSize={'mini'}
leftIcon={<MyIcon name={'common/download'} w={'4'} />}
onClick={onDownload}
isLoading={isLoading}
>
{t('common:Download')}
</Button>
);
};
export default DownloadButton;

View File

@ -0,0 +1,47 @@
import { Flex } from '@chakra-ui/react';
import MyIcon from '@fastgpt/web/components/common/Icon';
const NavButton = ({
direction,
isDisabled,
onClick
}: {
direction: 'up' | 'down';
isDisabled: boolean;
onClick: () => void;
}) => {
const isUp = direction === 'up';
const baseStyles = {
color: 'myGray.500',
border: '1px solid',
borderColor: 'myGray.150',
borderRadius: 'sm',
w: 6,
h: 6,
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.2s'
};
const stateStyles = isDisabled
? {
cursor: 'not-allowed',
opacity: 0.5,
_hover: {}
}
: {
cursor: 'pointer',
opacity: 1,
_hover: { bg: 'myGray.100' },
onClick
};
return (
<Flex {...baseStyles} {...stateStyles}>
<MyIcon name={isUp ? `common/solidChevronUp` : `common/solidChevronDown`} w={'18px'} />
</Flex>
);
};
export default NavButton;

View File

@ -0,0 +1,163 @@
import { ScoreItemType } from '@/components/core/dataset/QuoteItem';
import { Box, Flex } from '@chakra-ui/react';
import MyIcon from '@fastgpt/web/components/common/Icon';
import ScoreTag from './ScoreTag';
import Markdown from '@/components/Markdown';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import { useTranslation } from 'react-i18next';
import { useCopyData } from '@fastgpt/web/hooks/useCopyData';
const QuoteItem = ({
index,
icon,
sourceName,
score,
q,
a
}: {
index: number;
icon: string;
sourceName: string;
score: { primaryScore?: ScoreItemType; secondaryScore: ScoreItemType[] };
q: string;
a?: string;
}) => {
const { t } = useTranslation();
const { copyData } = useCopyData();
const isDeleted = !q;
return (
<Box
p={2}
position={'relative'}
overflow={'hidden'}
border={'1px solid transparent'}
borderBottomColor={'myGray.150'}
wordBreak={'break-all'}
fontSize={'sm'}
_hover={{
bg: 'linear-gradient(180deg, #FBFBFC 7.61%, #F0F1F6 100%)',
borderTopColor: 'myGray.50',
'& .hover-data': { visibility: 'visible' }
}}
>
<Flex gap={2} alignItems={'center'} mb={2}>
<Box
alignItems={'center'}
fontSize={'xs'}
border={'sm'}
borderRadius={'sm'}
_hover={{
'.controller': {
display: 'flex'
}
}}
overflow={'hidden'}
display={'inline-flex'}
height={6}
>
<Flex
color={'myGray.500'}
bg={'myGray.150'}
w={4}
justifyContent={'center'}
fontSize={'10px'}
h={'full'}
alignItems={'center'}
>
{index + 1}
</Flex>
<Flex px={1.5}>
<MyIcon name={icon as any} mr={1} flexShrink={0} w={'12px'} />
<Box
className="textEllipsis3"
wordBreak={'break-all'}
flex={'1 0 0'}
fontSize={'mini'}
color={'myGray.900'}
>
{sourceName}
</Box>
</Flex>
</Box>
{score && !isDeleted && (
<Box className="hover-data" visibility={'hidden'}>
<ScoreTag {...score} />
</Box>
)}
</Flex>
{!isDeleted ? (
<>
<Markdown source={q} />
{!!a && (
<Box>
<Markdown source={a} />
</Box>
)}
</>
) : (
<Flex
justifyContent={'center'}
alignItems={'center'}
h={'full'}
py={2}
bg={'#FAFAFA'}
color={'myGray.500'}
>
<MyIcon name="common/info" w={'14px'} mr={1} color={'myGray.500'} />
{t('chat:chat.quote.deleted')}
</Flex>
)}
<Flex
className="hover-data"
position={'absolute'}
bottom={2}
right={5}
gap={1.5}
visibility={'hidden'}
>
<MyTooltip label={t('common:core.dataset.Quote Length')}>
<Flex
alignItems={'center'}
fontSize={'10px'}
border={'1px solid'}
borderColor={'myGray.200'}
bg={'white'}
rounded={'sm'}
px={2}
py={1}
boxShadow={
'0px 1px 2px 0px rgba(19, 51, 107, 0.05), 0px 0px 1px 0px rgba(19, 51, 107, 0.08)'
}
>
<MyIcon name="common/text/t" w={'14px'} mr={1} color={'myGray.500'} />
{q.length + (a?.length || 0)}
</Flex>
</MyTooltip>
<MyTooltip label={t('common:common.Copy')}>
<Flex
alignItems={'center'}
fontSize={'10px'}
border={'1px solid'}
borderColor={'myGray.200'}
bg={'white'}
rounded={'sm'}
px={1}
py={1}
boxShadow={
'0px 1px 2px 0px rgba(19, 51, 107, 0.05), 0px 0px 1px 0px rgba(19, 51, 107, 0.08)'
}
cursor={'pointer'}
onClick={() => {
copyData(q + '\n' + a);
}}
>
<MyIcon name="copy" w={'14px'} color={'myGray.500'} />
</Flex>
</MyTooltip>
</Flex>
</Box>
);
};
export default QuoteItem;

View File

@ -0,0 +1,152 @@
import { Box, Flex } from '@chakra-ui/react';
import { SearchDataResponseItemType } from '@fastgpt/global/core/dataset/type';
import MyIcon from '@fastgpt/web/components/common/Icon';
import MyBox from '@fastgpt/web/components/common/MyBox';
import { useTranslation } from 'react-i18next';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { useChatStore } from '@/web/core/chat/context/useChatStore';
import QuoteItem from './QuoteItem';
import { useMemo } from 'react';
import { getSourceNameIcon } from '@fastgpt/global/core/dataset/utils';
import { formatScore } from '@/components/core/dataset/QuoteItem';
import { metadataType } from '@/web/core/chat/context/chatItemContext';
import { getQuoteDataList } from '@/web/core/chat/api';
const QuoteReader = ({
rawSearch,
metadata,
chatTime,
onClose
}: {
rawSearch: SearchDataResponseItemType[];
metadata: metadataType;
chatTime: Date;
onClose: () => void;
}) => {
const { t } = useTranslation();
const { chatId, appId, outLinkAuthData } = useChatStore();
const { data, loading } = useRequest2(
async () =>
await getQuoteDataList({
datasetDataIdList: rawSearch.map((item) => item.id),
chatTime,
collectionIdList: metadata.collectionIdList,
chatItemId: metadata.chatItemId,
appId,
chatId,
...outLinkAuthData
}),
{
manual: false
}
);
const filterResults = useMemo(() => {
if (!metadata.collectionId) {
return rawSearch;
}
return rawSearch.filter(
(item) => item.collectionId === metadata.collectionId && item.sourceId === metadata.sourceId
);
}, [metadata, rawSearch]);
const formatedDataList = useMemo(() => {
return filterResults
.map((item) => {
const currentFilterItem = data?.quoteList.find((res) => res._id === item.id);
return {
...item,
q: currentFilterItem?.q || '',
a: currentFilterItem?.a || '',
score: formatScore(item.score),
icon: getSourceNameIcon({
sourceId: item.sourceId,
sourceName: item.sourceName
})
};
})
.sort((a, b) => {
return (b.score.primaryScore?.value || 0) - (a.score.primaryScore?.value || 0);
});
}, [data?.quoteList, filterResults]);
return (
<Flex flexDirection={'column'} h={'full'}>
{/* title */}
<Flex
w={'full'}
alignItems={'center'}
px={5}
borderBottom={'1px solid'}
borderColor={'myGray.150'}
>
<Box flex={1} py={4}>
<Flex gap={2} mr={2} mb={1}>
<MyIcon
name={
metadata.sourceId && metadata.sourceName
? (getSourceNameIcon({
sourceId: metadata.sourceId,
sourceName: metadata.sourceName
}) as any)
: 'core/chat/quoteFill'
}
w={['1rem', '1.25rem']}
color={'primary.600'}
/>
<Box
maxW={['200px', '300px']}
className={'textEllipsis'}
wordBreak={'break-all'}
color={'myGray.900'}
fontWeight={'medium'}
>
{metadata.sourceName
? metadata.sourceName
: t('common:core.chat.Quote Amount', { amount: rawSearch.length })}
</Box>
</Flex>
<Box fontSize={'mini'} color={'myGray.500'}>
{t('common:core.chat.quote.Quote Tip')}
</Box>
</Box>
<Box
cursor={'pointer'}
borderRadius={'sm'}
p={1}
_hover={{
bg: 'myGray.100'
}}
onClick={onClose}
>
<MyIcon name="common/closeLight" color={'myGray.900'} w={6} />
</Box>
</Flex>
{/* quote list */}
<MyBox flex={'1 0 0'} mt={2} px={5} py={1} overflow={'auto'} isLoading={loading}>
{!loading && (
<Flex flexDir={'column'} gap={3}>
{formatedDataList?.map((item, index) => (
<QuoteItem
key={item.id}
index={index}
icon={item.icon}
sourceName={item.sourceName}
score={item.score}
q={item.q}
a={item.a}
/>
))}
</Flex>
)}
</MyBox>
</Flex>
);
};
export default QuoteReader;

View File

@ -0,0 +1,78 @@
import { ScoreItemType, scoreTheme } from '@/components/core/dataset/QuoteItem';
import { Box, Flex, Progress } from '@chakra-ui/react';
import { SearchScoreTypeMap } from '@fastgpt/global/core/dataset/constants';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import { useTranslation } from 'react-i18next';
const ScoreTag = (score: { primaryScore?: ScoreItemType; secondaryScore: ScoreItemType[] }) => {
const { t } = useTranslation();
return (
<Flex alignItems={'center'} flexWrap={'wrap'} gap={3}>
{score?.primaryScore && (
<MyTooltip
label={
score.secondaryScore.length ? (
<Box>
{score.secondaryScore.map((item, i) => (
<Box fontSize={'xs'} key={i}>
<Flex alignItems={'flex-start'} lineHeight={1.2} mb={1}>
<Box
px={'5px'}
borderWidth={'1px'}
borderRadius={'sm'}
mr={'2px'}
{...(scoreTheme[i] && scoreTheme[i])}
>
<Box transform={'scale(0.9)'}>#{item.index + 1}</Box>
</Box>
<Box transform={'scale(0.9)'}>
{t(SearchScoreTypeMap[item.type]?.label as any)}: {item.value.toFixed(4)}
</Box>
</Flex>
<Box h={'4px'}>
{SearchScoreTypeMap[item.type]?.showScore && (
<Progress
value={item.value * 100}
h={'4px'}
w={'100%'}
size="sm"
borderRadius={'20px'}
{...(scoreTheme[i] && {
colorScheme: scoreTheme[i].colorScheme
})}
bg="#E8EBF0"
/>
)}
</Box>
</Box>
))}
</Box>
) : (
t(SearchScoreTypeMap[score.primaryScore.type]?.desc as any)
)
}
>
<Flex
borderRadius={'sm'}
py={1}
px={2}
color={'green.600'}
bg={'green.50'}
alignItems={'center'}
fontSize={'11px'}
>
<Box>
{t(SearchScoreTypeMap[score.primaryScore.type]?.label as any)}
{SearchScoreTypeMap[score.primaryScore.type]?.showScore
? ` ${score.primaryScore.value.toFixed(4)}`
: `: ${score.primaryScore.index + 1}`}
</Box>
</Flex>
</MyTooltip>
)}
</Flex>
);
};
export default ScoreTag;

View File

@ -0,0 +1,42 @@
import React from 'react';
import { SearchDataResponseItemType } from '@fastgpt/global/core/dataset/type';
import { useContextSelector } from 'use-context-selector';
import { ChatItemContext, metadataType } from '@/web/core/chat/context/chatItemContext';
import CollectionQuoteReader from './CollectionQuoteReader';
import QuoteReader from './QuoteReader';
const ChatQuoteList = ({
chatTime,
rawSearch = [],
metadata,
onClose
}: {
chatTime: Date;
rawSearch: SearchDataResponseItemType[];
metadata: metadataType;
onClose: () => void;
}) => {
const isShowReadRawSource = useContextSelector(ChatItemContext, (v) => v.isShowReadRawSource);
return (
<>
{metadata.collectionId && isShowReadRawSource ? (
<CollectionQuoteReader
rawSearch={rawSearch}
metadata={metadata}
chatTime={chatTime}
onClose={onClose}
/>
) : (
<QuoteReader
rawSearch={rawSearch}
metadata={metadata}
chatTime={chatTime}
onClose={onClose}
/>
)}
</>
);
};
export default ChatQuoteList;

View File

@ -5,7 +5,6 @@ import {
delOneDatasetDataById,
getDatasetCollectionById
} from '@/web/core/dataset/api';
import { useQuery } from '@tanstack/react-query';
import { useToast } from '@fastgpt/web/hooks/useToast';
import { getErrText } from '@fastgpt/global/common/error/utils';
import { useConfirm } from '@fastgpt/web/hooks/useConfirm';

View File

@ -178,7 +178,6 @@ const InputDataModal = ({
async (e: InputDataType) => {
if (!dataId) return Promise.reject(t('common:common.error.unKnow'));
// not exactly same
await putDatasetDataById({
dataId,
q: e.q,

View File

@ -0,0 +1,250 @@
import { NextAPI } from '@/service/middleware/entry';
import { authChatCrud, authCollectionInChat } from '@/service/support/permission/auth/chat';
import { DatasetDataSchemaType } from '@fastgpt/global/core/dataset/type';
import { MongoDatasetData } from '@fastgpt/service/core/dataset/data/schema';
import { ApiRequestProps } from '@fastgpt/service/type/next';
import { LinkedListResponse, LinkedPaginationProps } from '@fastgpt/web/common/fetch/type';
import { FilterQuery, Types } from 'mongoose';
import { dataFieldSelector, processChatTimeFilter } from './getQuote';
export type GetCollectionQuoteProps = LinkedPaginationProps & {
chatTime: Date;
isInitialLoad: boolean;
collectionId: string;
chatItemId: string;
appId: string;
chatId: string;
shareId?: string;
outLinkUid?: string;
teamId?: string;
teamToken?: string;
};
export type GetCollectionQuoteRes = LinkedListResponse<DatasetDataSchemaType>;
type BaseMatchType = FilterQuery<DatasetDataSchemaType>;
async function handler(
req: ApiRequestProps<GetCollectionQuoteProps>
): Promise<GetCollectionQuoteRes> {
const {
initialId,
initialIndex,
prevId,
prevIndex,
nextId,
nextIndex,
chatTime,
isInitialLoad,
collectionId,
chatItemId,
appId,
chatId,
shareId,
outLinkUid,
teamId,
teamToken,
pageSize = 15
} = req.body;
const limitedPageSize = Math.min(pageSize, 30);
await Promise.all([
authChatCrud({
req,
authToken: true,
appId,
chatId,
shareId,
outLinkUid,
teamId,
teamToken
}),
authCollectionInChat({ appId, chatId, chatItemId, collectionId })
]);
const baseMatch: BaseMatchType = {
collectionId,
$or: [
{ updateTime: { $lt: new Date(chatTime) } },
{ history: { $elemMatch: { updateTime: { $lt: new Date(chatTime) } } } }
]
};
if (initialId && initialIndex !== undefined) {
return await handleInitialLoad(
initialId,
initialIndex,
limitedPageSize,
chatTime,
chatItemId,
isInitialLoad,
baseMatch
);
}
if ((prevId && prevIndex !== undefined) || (nextId && nextIndex !== undefined)) {
return await handlePaginatedLoad(
prevId,
prevIndex,
nextId,
nextIndex,
limitedPageSize,
chatTime,
chatItemId,
baseMatch
);
}
return { list: [], hasMorePrev: false, hasMoreNext: false };
}
export default NextAPI(handler);
async function handleInitialLoad(
initialId: string,
initialIndex: number,
pageSize: number,
chatTime: Date,
chatItemId: string,
isInitialLoad: boolean,
baseMatch: BaseMatchType
): Promise<GetCollectionQuoteRes> {
const centerNode = await MongoDatasetData.findOne(
{
_id: new Types.ObjectId(initialId)
},
dataFieldSelector
).lean();
if (!centerNode) {
if (isInitialLoad) {
const list = await MongoDatasetData.find(baseMatch, dataFieldSelector)
.sort({ chunkIndex: 1, _id: -1 })
.limit(pageSize)
.lean();
const listRes = list.map((item, index) => ({
...item,
index: item.chunkIndex
}));
const hasMoreNext = list.length === pageSize;
return {
list: listRes,
hasMorePrev: false,
hasMoreNext
};
}
return Promise.reject('centerNode not found');
}
const prevHalfSize = Math.floor(pageSize / 2);
const nextHalfSize = pageSize - prevHalfSize - 1;
const { list: prevList, hasMore: hasMorePrev } = await getPrevNodes(
initialId,
initialIndex,
prevHalfSize,
baseMatch
);
const { list: nextList, hasMore: hasMoreNext } = await getNextNodes(
initialId,
initialIndex,
nextHalfSize,
baseMatch
);
const resultList = [...prevList, centerNode, ...nextList];
const list = processChatTimeFilter(resultList, chatTime);
return {
list: list.map((item) => ({
...item,
index: item.chunkIndex
})),
hasMorePrev,
hasMoreNext
};
}
async function handlePaginatedLoad(
prevId: string | undefined,
prevIndex: number | undefined,
nextId: string | undefined,
nextIndex: number | undefined,
pageSize: number,
chatTime: Date,
chatItemId: string,
baseMatch: BaseMatchType
): Promise<GetCollectionQuoteRes> {
const { list, hasMore } =
prevId && prevIndex !== undefined
? await getPrevNodes(prevId, prevIndex, pageSize, baseMatch)
: await getNextNodes(nextId!, nextIndex!, pageSize, baseMatch);
const processedList = processChatTimeFilter(list, chatTime);
return {
list: processedList.map((item) => ({
...item,
index: item.chunkIndex
})),
hasMorePrev: !!prevId && hasMore,
hasMoreNext: !!nextId && hasMore
};
}
async function getPrevNodes(
initialId: string,
initialIndex: number,
limit: number,
baseMatch: BaseMatchType
) {
const match: BaseMatchType = {
...baseMatch,
$or: [
{ chunkIndex: { $lte: initialIndex } },
{ chunkIndex: initialIndex, _id: { $lte: new Types.ObjectId(initialId) } }
]
};
const list = await MongoDatasetData.find(match, dataFieldSelector)
.sort({ chunkIndex: -1, _id: 1 })
.limit(limit)
.lean();
return {
list: list.filter((item) => String(item._id) !== initialId).reverse(),
hasMore: list.length === limit
};
}
async function getNextNodes(
initialId: string,
initialIndex: number,
limit: number,
baseMatch: BaseMatchType
) {
const match: BaseMatchType = {
...baseMatch,
$or: [
{ chunkIndex: { $gte: initialIndex } },
{ chunkIndex: initialIndex, _id: { $gte: new Types.ObjectId(initialId) } }
]
};
const list = await MongoDatasetData.find(match, dataFieldSelector)
.sort({ chunkIndex: 1, _id: -1 })
.limit(limit)
.lean();
return {
list: list.filter((item) => String(item._id) !== initialId),
hasMore: list.length === limit
};
}

View File

@ -0,0 +1,102 @@
import { NextAPI } from '@/service/middleware/entry';
import { authChatCrud, authCollectionInChat } from '@/service/support/permission/auth/chat';
import { DatasetDataSchemaType } from '@fastgpt/global/core/dataset/type';
import { MongoDatasetData } from '@fastgpt/service/core/dataset/data/schema';
import { ApiRequestProps } from '@fastgpt/service/type/next';
export type GetQuoteDataProps = {
datasetDataIdList: string[];
chatTime: Date;
collectionIdList: string[];
chatItemId: string;
appId: string;
chatId: string;
shareId?: string;
outLinkUid?: string;
teamId?: string;
teamToken?: string;
};
export type GetQuoteDataRes = {
quoteList: DatasetDataSchemaType[];
};
export const dataFieldSelector =
'_id datasetId collectionId q a chunkIndex history updateTime currentChatItemId prevId';
async function handler(req: ApiRequestProps<GetQuoteDataProps>): Promise<GetQuoteDataRes> {
const {
datasetDataIdList,
chatTime,
collectionIdList,
chatItemId,
chatId,
appId,
shareId,
outLinkUid,
teamId,
teamToken
} = req.body;
await authChatCrud({
req,
authToken: true,
appId,
chatId,
shareId,
outLinkUid,
teamId,
teamToken
});
await Promise.all(
collectionIdList.map(async (collectionId) => {
await authCollectionInChat({ appId, chatId, chatItemId, collectionId });
})
);
const list = await MongoDatasetData.find(
{ _id: { $in: datasetDataIdList } },
dataFieldSelector
).lean();
const quoteList = processChatTimeFilter(list, chatTime);
return {
quoteList
};
}
export default NextAPI(handler);
export function processChatTimeFilter(list: DatasetDataSchemaType[], chatTime?: Date) {
if (!chatTime) return list;
return list.map((item) => {
if (!item.history) return item;
const { history, ...rest } = item;
const formatedChatTime = new Date(chatTime);
if (item.updateTime <= formatedChatTime) {
return rest;
}
const latestHistoryIndex = history.findIndex(
(historyItem: any) => historyItem.updateTime <= formatedChatTime
);
if (latestHistoryIndex === -1) return rest;
const latestHistory = history[latestHistoryIndex];
return {
...rest,
q: latestHistory?.q || item.q,
a: latestHistory?.a || item.a,
updated: true
};
});
}

View File

@ -0,0 +1,78 @@
import { NextAPI } from '@/service/middleware/entry';
import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant';
import { useIPFrequencyLimit } from '@fastgpt/service/common/middle/reqFrequencyLimit';
import { readFromSecondary } from '@fastgpt/service/common/mongo/utils';
import { responseWriteController } from '@fastgpt/service/common/response';
import { addLog } from '@fastgpt/service/common/system/log';
import { MongoDatasetData } from '@fastgpt/service/core/dataset/data/schema';
import { authDatasetCollection } from '@fastgpt/service/support/permission/dataset/auth';
import { ApiRequestProps } from '@fastgpt/service/type/next';
import { NextApiResponse } from 'next';
export type ExportCollectionBody = {
collectionId: string;
chatTime: Date;
};
async function handler(req: ApiRequestProps<ExportCollectionBody, {}>, res: NextApiResponse) {
let { collectionId, chatTime } = req.body;
const { teamId, collection } = await authDatasetCollection({
req,
authToken: true,
collectionId,
per: ReadPermissionVal
});
const where = {
teamId,
datasetId: collection.datasetId,
collectionId,
...(chatTime
? {
$or: [
{ updateTime: { $lt: new Date(chatTime) } },
{ history: { $elemMatch: { updateTime: { $lt: new Date(chatTime) } } } }
]
}
: {})
};
res.setHeader('Content-Type', 'text/csv; charset=utf-8;');
res.setHeader('Content-Disposition', 'attachment; filename=usage.csv; ');
const cursor = MongoDatasetData.find(where, 'q a', {
...readFromSecondary,
batchSize: 1000
})
.sort({ chunkIndex: 1 })
.limit(50000)
.cursor();
const write = responseWriteController({
res,
readStream: cursor
});
cursor.on('data', (doc) => {
const res = doc.a ? `\n${doc.q}\n${doc.a}` : `\n${doc.q}`;
write(res);
});
cursor.on('end', () => {
cursor.close();
res.end();
});
cursor.on('error', (err) => {
addLog.error(`export usage error`, err);
res.status(500);
res.end();
});
}
export default NextAPI(
useIPFrequencyLimit({ id: 'export-usage', seconds: 60, limit: 1, force: true }),
handler
);

View File

@ -7,9 +7,7 @@ import { BucketNameEnum, ReadFileBaseUrl } from '@fastgpt/global/common/file/con
import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant';
import { OutLinkChatAuthProps } from '@fastgpt/global/support/permission/chat';
import { DatasetErrEnum } from '@fastgpt/global/common/error/code/dataset';
import { MongoChatItem } from '@fastgpt/service/core/chat/chatItemSchema';
import { AIChatItemType, ChatHistoryItemResType } from '@fastgpt/global/core/chat/type';
import { authChatCrud } from '@/service/support/permission/auth/chat';
import { authChatCrud, authCollectionInChat } from '@/service/support/permission/auth/chat';
import { getCollectionWithDataset } from '@fastgpt/service/core/dataset/controller';
import { useApiDatasetRequest } from '@fastgpt/service/core/dataset/apiDataset/api';
import { POST } from '@fastgpt/service/common/api/plusRequest';
@ -29,57 +27,6 @@ export type readCollectionSourceResponse = {
value: string;
};
const authCollectionInChat = async ({
collectionId,
appId,
chatId,
chatItemId
}: {
collectionId: string;
appId: string;
chatId: string;
chatItemId: string;
}) => {
try {
const chatItem = (await MongoChatItem.findOne(
{
appId,
chatId,
dataId: chatItemId
},
'responseData'
).lean()) as AIChatItemType;
if (!chatItem) return Promise.reject(DatasetErrEnum.unAuthDatasetFile);
// 找 responseData 里,是否有该文档 id
const responseData = chatItem.responseData || [];
const flatResData: ChatHistoryItemResType[] =
responseData
?.map((item) => {
return [
item,
...(item.pluginDetail || []),
...(item.toolDetail || []),
...(item.loopDetail || [])
];
})
.flat() || [];
if (
flatResData.some((item) => {
if (item.quoteList) {
return item.quoteList.some((quote) => quote.collectionId === collectionId);
}
return false;
})
) {
return true;
}
} catch (error) {}
return Promise.reject(DatasetErrEnum.unAuthDatasetFile);
};
async function handler(
req: ApiRequestProps<readCollectionSourceBody, readCollectionSourceQuery>
): Promise<readCollectionSourceResponse> {

View File

@ -0,0 +1,53 @@
import type { NextApiRequest } from 'next';
import { NextAPI } from '@/service/middleware/entry';
import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant';
import { authDataset } from '@fastgpt/service/support/permission/dataset/auth';
import { DatasetErrEnum } from '@fastgpt/global/common/error/code/dataset';
export type GetQuotePermissionResponse =
| {
permission: {
hasWritePer: boolean;
hasReadPer: boolean;
};
}
| undefined;
async function handler(req: NextApiRequest): Promise<GetQuotePermissionResponse> {
const { id: datasetId } = req.query as {
id?: string;
};
if (!datasetId) {
return Promise.reject('datasetId is required');
}
try {
const { permission } = await authDataset({
req,
authToken: true,
authApiKey: true,
datasetId,
per: ReadPermissionVal
});
return {
permission: {
hasReadPer: permission.hasReadPer,
hasWritePer: permission.hasWritePer
}
};
} catch (error) {
if (error === DatasetErrEnum.unAuthDataset) {
return {
permission: {
hasWritePer: false,
hasReadPer: false
}
};
}
return Promise.reject(error);
}
}
export default NextAPI(handler);

View File

@ -45,7 +45,7 @@ async function handler(
const [list, total] = await Promise.all([
MongoDatasetData.find(match, '_id datasetId collectionId q a chunkIndex')
.sort({ chunkIndex: 1, updateTime: -1 })
.sort({ chunkIndex: 1, _id: -1 })
.skip(offset)
.limit(pageSize)
.lean(),

View File

@ -37,6 +37,7 @@ async function handler(
responseDetail,
showRawSource,
showNodeStatus,
// showFullText,
limit,
app
});

View File

@ -59,6 +59,7 @@ import { getWorkflowResponseWrite } from '@fastgpt/service/core/workflow/dispatc
import { WORKFLOW_MAX_RUN_TIMES } from '@fastgpt/service/core/workflow/constants';
import { getPluginInputsFromStoreNodes } from '@fastgpt/global/core/app/plugin/utils';
import { ExternalProviderType } from '@fastgpt/global/core/workflow/runtime/type';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
type FastGptWebChatProps = {
chatId?: string; // undefined: get histories from messages, '': new chat, 'xxxxx': get histories from db

View File

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo } from 'react';
import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import NextHead from '@/components/common/NextHead';
import { useRouter } from 'next/router';
import { getInitChatInfo } from '@/web/core/chat/api';
@ -36,6 +36,7 @@ import ChatItemContextProvider, { ChatItemContext } from '@/web/core/chat/contex
import ChatRecordContextProvider, {
ChatRecordContext
} from '@/web/core/chat/context/chatRecordContext';
import ChatQuoteList from '@/pageComponents/chat/ChatQuoteList';
const CustomPluginRunBox = dynamic(() => import('@/pageComponents/chat/CustomPluginRunBox'));
@ -58,6 +59,8 @@ const Chat = ({ myApps }: { myApps: AppListItemType[] }) => {
const isPlugin = useContextSelector(ChatItemContext, (v) => v.isPlugin);
const chatBoxData = useContextSelector(ChatItemContext, (v) => v.chatBoxData);
const setChatBoxData = useContextSelector(ChatItemContext, (v) => v.setChatBoxData);
const quoteData = useContextSelector(ChatItemContext, (v) => v.quoteData);
const setQuoteData = useContextSelector(ChatItemContext, (v) => v.setQuoteData);
const chatRecords = useContextSelector(ChatRecordContext, (v) => v.chatRecords);
const totalRecordsCount = useContextSelector(ChatRecordContext, (v) => v.totalRecordsCount);
@ -138,13 +141,14 @@ const Chat = ({ myApps }: { myApps: AppListItemType[] }) => {
},
[appId, chatId, onUpdateHistoryTitle, setChatBoxData, forbidLoadChat]
);
const RenderHistorySlider = useMemo(() => {
const Children = (
<ChatHistorySlider confirmClearText={t('common:core.chat.Confirm to clear history')} />
);
return isPc || !appId ? (
<SideBar>{Children}</SideBar>
<SideBar externalTrigger={!!quoteData}>{Children}</SideBar>
) : (
<Drawer
isOpen={isOpenSlider}
@ -157,7 +161,7 @@ const Chat = ({ myApps }: { myApps: AppListItemType[] }) => {
<DrawerContent maxWidth={'75vw'}>{Children}</DrawerContent>
</Drawer>
);
}, [appId, isOpenSlider, isPc, onCloseSlider, t]);
}, [t, isPc, appId, isOpenSlider, onCloseSlider, quoteData]);
return (
<Flex h={'100%'}>
@ -169,7 +173,14 @@ const Chat = ({ myApps }: { myApps: AppListItemType[] }) => {
</Box>
)}
<PageContainer isLoading={loading} flex={'1 0 0'} w={0} p={[0, '16px']} position={'relative'}>
<PageContainer
isLoading={loading}
flex={'1 0 0'}
w={0}
p={[0, '16px']}
pr={quoteData ? '8px !important' : '16px'}
position={'relative'}
>
<Flex h={'100%'} flexDirection={['column', 'row']}>
{/* pc always show history. */}
{RenderHistorySlider}
@ -215,6 +226,16 @@ const Chat = ({ myApps }: { myApps: AppListItemType[] }) => {
</Flex>
</Flex>
</PageContainer>
{quoteData && (
<PageContainer w={['full', '588px']} insertProps={{ bg: 'white' }}>
<ChatQuoteList
chatTime={quoteData.chatTime}
rawSearch={quoteData.rawSearch}
metadata={quoteData.metadata}
onClose={() => setQuoteData(undefined)}
/>
</PageContainer>
)}
</Flex>
);
};
@ -278,6 +299,7 @@ const Render = (props: { appId: string; isStandalone?: string }) => {
showRouteToAppDetail={isStandalone !== '1'}
showRouteToDatasetDetail={isStandalone !== '1'}
isShowReadRawSource={true}
// isShowFullText={true}
showNodeStatus
>
<ChatRecordContextProvider params={chatRecordProviderParams}>

View File

@ -37,6 +37,7 @@ import { useChatStore } from '@/web/core/chat/context/useChatStore';
import { ChatSourceEnum } from '@fastgpt/global/core/chat/constants';
import { useI18nLng } from '@fastgpt/web/hooks/useI18n';
import { AppSchema } from '@fastgpt/global/core/app/type';
import ChatQuoteList from '@/pageComponents/chat/ChatQuoteList';
const CustomPluginRunBox = dynamic(() => import('@/pageComponents/chat/CustomPluginRunBox'));
@ -49,6 +50,7 @@ type Props = {
authToken: string;
customUid: string;
showRawSource: boolean;
// showFullText: boolean;
showNodeStatus: boolean;
};
@ -81,6 +83,8 @@ const OutLink = (props: Props) => {
const resetVariables = useContextSelector(ChatItemContext, (v) => v.resetVariables);
const isPlugin = useContextSelector(ChatItemContext, (v) => v.isPlugin);
const setChatBoxData = useContextSelector(ChatItemContext, (v) => v.setChatBoxData);
const quoteData = useContextSelector(ChatItemContext, (v) => v.quoteData);
const setQuoteData = useContextSelector(ChatItemContext, (v) => v.setQuoteData);
const chatRecords = useContextSelector(ChatRecordContext, (v) => v.chatRecords);
const totalRecordsCount = useContextSelector(ChatRecordContext, (v) => v.totalRecordsCount);
@ -217,7 +221,7 @@ const OutLink = (props: Props) => {
if (showHistory !== '1') return null;
return isPc ? (
<SideBar>{Children}</SideBar>
<SideBar externalTrigger={!!quoteData}>{Children}</SideBar>
) : (
<Drawer
isOpen={isOpenSlider}
@ -232,10 +236,10 @@ const OutLink = (props: Props) => {
</DrawerContent>
</Drawer>
);
}, [isOpenSlider, isPc, onCloseSlider, showHistory, t]);
}, [isOpenSlider, isPc, onCloseSlider, quoteData, showHistory, t]);
return (
<>
<Box h={'full'} display={quoteData ? 'flex' : ''}>
<NextHead
title={props.appName || data?.app?.name || 'AI'}
desc={props.appIntro || data?.app?.intro}
@ -291,7 +295,17 @@ const OutLink = (props: Props) => {
</Flex>
</Flex>
</PageContainer>
</>
{quoteData && (
<PageContainer w={['full', '800px']} py={5}>
<ChatQuoteList
chatTime={quoteData.chatTime}
rawSearch={quoteData.rawSearch}
metadata={quoteData.metadata}
onClose={() => setQuoteData(undefined)}
/>
</PageContainer>
)}
</Box>
);
};
@ -340,6 +354,7 @@ const Render = (props: Props) => {
showRouteToAppDetail={false}
showRouteToDatasetDetail={false}
isShowReadRawSource={props.showRawSource}
// isShowFullText={props.showFullText}
showNodeStatus={props.showNodeStatus}
>
<ChatRecordContextProvider params={chatRecordProviderParams}>
@ -383,6 +398,7 @@ export async function getServerSideProps(context: any) {
appAvatar: app?.associatedApp?.avatar ?? '',
appIntro: app?.associatedApp?.intro ?? 'AI',
showRawSource: app?.showRawSource ?? false,
// showFullText: app?.showFullText ?? false,
showNodeStatus: app?.showNodeStatus ?? false,
shareId: shareId ?? '',
authToken: authToken ?? '',

View File

@ -33,6 +33,7 @@ import ChatRecordContextProvider, {
import { useChatStore } from '@/web/core/chat/context/useChatStore';
import { useMount } from 'ahooks';
import { ChatSourceEnum } from '@fastgpt/global/core/chat/constants';
import ChatQuoteList from '@/pageComponents/chat/ChatQuoteList';
const CustomPluginRunBox = dynamic(() => import('@/pageComponents/chat/CustomPluginRunBox'));
type Props = { appId: string; chatId: string; teamId: string; teamToken: string };
@ -63,6 +64,8 @@ const Chat = ({ myApps }: { myApps: AppListItemType[] }) => {
const resetVariables = useContextSelector(ChatItemContext, (v) => v.resetVariables);
const chatBoxData = useContextSelector(ChatItemContext, (v) => v.chatBoxData);
const setChatBoxData = useContextSelector(ChatItemContext, (v) => v.setChatBoxData);
const quoteData = useContextSelector(ChatItemContext, (v) => v.quoteData);
const setQuoteData = useContextSelector(ChatItemContext, (v) => v.setQuoteData);
const chatRecords = useContextSelector(ChatRecordContext, (v) => v.chatRecords);
const totalRecordsCount = useContextSelector(ChatRecordContext, (v) => v.totalRecordsCount);
@ -163,7 +166,7 @@ const Chat = ({ myApps }: { myApps: AppListItemType[] }) => {
);
return isPc || !appId ? (
<SideBar>{Children}</SideBar>
<SideBar externalTrigger={!!quoteData}>{Children}</SideBar>
) : (
<Drawer
isOpen={isOpenSlider}
@ -176,7 +179,7 @@ const Chat = ({ myApps }: { myApps: AppListItemType[] }) => {
<DrawerContent maxWidth={'75vw'}>{Children}</DrawerContent>
</Drawer>
);
}, [appId, isOpenSlider, isPc, onCloseSlider, t]);
}, [appId, isOpenSlider, isPc, onCloseSlider, quoteData, t]);
return (
<Flex h={'100%'}>
@ -231,6 +234,17 @@ const Chat = ({ myApps }: { myApps: AppListItemType[] }) => {
</Flex>
</Flex>
</PageContainer>
{quoteData && (
<PageContainer w={['full', '800px']} py={5}>
<ChatQuoteList
chatTime={quoteData.chatTime}
rawSearch={quoteData.rawSearch}
metadata={quoteData.metadata}
onClose={() => setQuoteData(undefined)}
/>
</PageContainer>
)}
</Flex>
);
};
@ -300,6 +314,7 @@ const Render = (props: Props) => {
showRouteToAppDetail={false}
showRouteToDatasetDetail={false}
isShowReadRawSource={true}
// isShowFullText={true}
showNodeStatus
>
<ChatRecordContextProvider params={chatRecordProviderParams}>

View File

@ -220,11 +220,7 @@ export async function updateData2Dataset({
}
}
// 4. Update mongo updateTime(便于脏数据检查器识别)
mongoData.updateTime = new Date();
await mongoData.save();
// 5. Insert vector
// insert vector
const insertResult = await Promise.all(
patchResult
.filter((item) => item.type === 'create' || item.type === 'update')
@ -249,9 +245,19 @@ export async function updateData2Dataset({
.filter((item) => item.type !== 'delete')
.map((item) => item.index) as DatasetDataIndexItemType[];
// console.log(clonePatchResult2Insert);
await mongoSessionRun(async (session) => {
// Update MongoData
// update mongo data
mongoData.history =
q !== mongoData.q || a !== mongoData.a
? [
{
q: mongoData.q,
a: mongoData.a,
updateTime: mongoData.updateTime
},
...(mongoData.history?.slice(0, 9) || [])
]
: mongoData.history;
mongoData.q = q || mongoData.q;
mongoData.a = a ?? mongoData.a;
mongoData.indexes = newIndexes;
@ -277,6 +283,10 @@ export async function updateData2Dataset({
}
});
// Update mongo updateTime(便于脏数据检查器识别)
mongoData.updateTime = new Date();
await mongoData.save();
return {
tokens
};

View File

@ -1,4 +1,4 @@
import { ChatSchema } from '@fastgpt/global/core/chat/type';
import { AIChatItemType, ChatHistoryItemResType, ChatSchema } from '@fastgpt/global/core/chat/type';
import { MongoChat } from '@fastgpt/service/core/chat/chatSchema';
import { AuthModeType } from '@fastgpt/service/support/permission/type';
import { authOutLink } from './outLink';
@ -6,6 +6,8 @@ import { ChatErrEnum } from '@fastgpt/global/common/error/code/chat';
import { authTeamSpaceToken } from './team';
import { AuthUserTypeEnum, ReadPermissionVal } from '@fastgpt/global/support/permission/constant';
import { authApp } from '@fastgpt/service/support/permission/app/auth';
import { MongoChatItem } from '@fastgpt/service/core/chat/chatItemSchema';
import { DatasetErrEnum } from '@fastgpt/global/common/error/code/dataset';
/*
chat的权限
@ -184,3 +186,54 @@ export async function authChatCrud({
return Promise.reject(ChatErrEnum.unAuthChat);
}
export const authCollectionInChat = async ({
collectionId,
appId,
chatId,
chatItemId
}: {
collectionId: string;
appId: string;
chatId: string;
chatItemId: string;
}) => {
try {
const chatItem = (await MongoChatItem.findOne(
{
appId,
chatId,
dataId: chatItemId
},
'responseData'
).lean()) as AIChatItemType;
if (!chatItem) return Promise.reject(DatasetErrEnum.unAuthDatasetFile);
// 找 responseData 里,是否有该文档 id
const responseData = chatItem.responseData || [];
const flatResData: ChatHistoryItemResType[] =
responseData
?.map((item) => {
return [
item,
...(item.pluginDetail || []),
...(item.toolDetail || []),
...(item.loopDetail || [])
];
})
.flat() || [];
if (
flatResData.some((item) => {
if (item.quoteList) {
return item.quoteList.some((quote) => quote.collectionId === collectionId);
}
return false;
})
) {
return true;
}
} catch (error) {}
return Promise.reject(DatasetErrEnum.unAuthDatasetFile);
};

View File

@ -28,6 +28,7 @@ export const defaultOutLinkForm: OutLinkEditType = {
name: '',
showNodeStatus: true,
responseDetail: false,
// showFullText: false,
showRawSource: false,
limit: {
QPM: 100,

View File

@ -30,6 +30,11 @@ import type {
getPaginationRecordsBody,
getPaginationRecordsResponse
} from '@/pages/api/core/chat/getPaginationRecords';
import { GetQuoteDataProps, GetQuoteDataRes } from '@/pages/api/core/chat/quote/getQuote';
import {
GetCollectionQuoteProps,
GetCollectionQuoteRes
} from '@/pages/api/core/chat/quote/getCollectionQuote';
/**
*
@ -100,3 +105,9 @@ export const getMyTokensApps = (data: AuthTeamTagTokenProps) =>
*/
export const getinitTeamChat = (data: { teamId: string; authToken: string; appId: string }) =>
GET(`/proApi/core/chat/initTeamChat`, data);
export const getQuoteDataList = (data: GetQuoteDataProps) =>
POST<GetQuoteDataRes>(`/core/chat/quote/getQuote`, data);
export const getCollectionQuote = (data: GetCollectionQuoteProps) =>
POST<GetCollectionQuoteRes>(`/core/chat/quote/getCollectionQuote`, data);

View File

@ -1,6 +1,6 @@
import { ChatBoxInputFormType } from '@/components/core/chat/ChatContainer/ChatBox/type';
import { PluginRunBoxTabEnum } from '@/components/core/chat/ChatContainer/PluginRunBox/constants';
import React, { ReactNode, useCallback, useMemo, useRef, useState } from 'react';
import React, { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createContext } from 'use-context-selector';
import { ComponentRef as ChatComponentRef } from '@/components/core/chat/ChatContainer/ChatBox/type';
import { useForm, UseFormReturn } from 'react-hook-form';
@ -8,11 +8,13 @@ import { defaultChatData } from '@/global/core/chat/constants';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { AppChatConfigType, VariableItemType } from '@fastgpt/global/core/app/type';
import { FlowNodeInputItemType } from '@fastgpt/global/core/workflow/type/io';
import { SearchDataResponseItemType } from '@fastgpt/global/core/dataset/type';
type ContextProps = {
showRouteToAppDetail: boolean;
showRouteToDatasetDetail: boolean;
isShowReadRawSource: boolean;
// isShowFullText: boolean;
showNodeStatus: boolean;
};
type ChatBoxDataType = {
@ -30,6 +32,21 @@ type ChatBoxDataType = {
};
};
export type metadataType = {
collectionId: string;
collectionIdList: string[];
chatItemId: string;
sourceId: string;
sourceName: string;
datasetId: string;
};
export type QuoteDataType = {
rawSearch: SearchDataResponseItemType[];
metadata: metadataType;
chatTime: Date;
};
type ChatItemContextType = {
ChatBoxRef: React.RefObject<ChatComponentRef> | null;
variablesForm: UseFormReturn<ChatBoxInputFormType, any>;
@ -43,6 +60,9 @@ type ChatItemContextType = {
chatBoxData: ChatBoxDataType;
setChatBoxData: React.Dispatch<React.SetStateAction<ChatBoxDataType>>;
isPlugin: boolean;
quoteData?: QuoteDataType;
setQuoteData: React.Dispatch<React.SetStateAction<QuoteDataType | undefined>>;
} & ContextProps;
export const ChatItemContext = createContext<ChatItemContextType>({
@ -61,6 +81,11 @@ export const ChatItemContext = createContext<ChatItemContextType>({
},
clearChatRecords: function (): void {
throw new Error('Function not implemented.');
},
quoteData: undefined,
setQuoteData: function (value: React.SetStateAction<QuoteDataType | undefined>): void {
throw new Error('Function not implemented.');
}
});
@ -72,13 +97,14 @@ const ChatItemContextProvider = ({
showRouteToAppDetail,
showRouteToDatasetDetail,
isShowReadRawSource,
// isShowFullText,
showNodeStatus
}: {
children: ReactNode;
} & ContextProps) => {
const ChatBoxRef = useRef<ChatComponentRef>(null);
const variablesForm = useForm<ChatBoxInputFormType>();
const [quoteData, setQuoteData] = useState<QuoteDataType>();
const [chatBoxData, setChatBoxData] = useState<ChatBoxDataType>({
...defaultChatData
});
@ -131,7 +157,11 @@ const ChatItemContextProvider = ({
showRouteToAppDetail,
showRouteToDatasetDetail,
isShowReadRawSource,
showNodeStatus
// isShowFullText,
showNodeStatus,
quoteData,
setQuoteData
};
}, [
chatBoxData,
@ -143,7 +173,10 @@ const ChatItemContextProvider = ({
showRouteToAppDetail,
showRouteToDatasetDetail,
isShowReadRawSource,
showNodeStatus
// isShowFullText,
showNodeStatus,
quoteData,
setQuoteData
]);
return <ChatItemContext.Provider value={contextValue}>{children}</ChatItemContext.Provider>;

View File

@ -65,6 +65,7 @@ import type {
listExistIdResponse
} from '@/pages/api/core/dataset/apiDataset/listExistId';
import { GetQuoteDataResponse } from '@/pages/api/core/dataset/data/getQuoteData';
import { GetQuotePermissionResponse } from '@/pages/api/core/dataset/data/getPermission';
/* ======================== dataset ======================= */
export const getDatasets = (data: GetDatasetListBody) =>
@ -179,6 +180,9 @@ export const getAllTags = (datasetId: string) =>
export const getDatasetDataList = (data: GetDatasetDataListProps) =>
POST<GetDatasetDataListRes>(`/core/dataset/data/v2/list`, data);
export const getDatasetDataPermission = (id?: string) =>
GET<GetQuotePermissionResponse>(`/core/dataset/data/getPermission`, { id });
export const getDatasetDataItemById = (id: string) =>
GET<DatasetDataItemType>(`/core/dataset/data/detail`, { id });