mirror of
https://github.com/csunny/DB-GPT.git
synced 2026-07-17 18:28:42 +00:00
# Conflicts: # packages/dbgpt-ext/src/dbgpt_ext/storage/knowledge_graph/community/neo4j_store_adapter.py # uv.lock # web/locales/en/common.ts # web/locales/zh/common.ts # web/package-lock.json # web/yarn.lock
277 lines
9.2 KiB
TypeScript
277 lines
9.2 KiB
TypeScript
/**
|
|
* OpenCode Chat Completion Component
|
|
*
|
|
* Renders chat messages in OpenCode style for all scenes.
|
|
* Uses OpenCodeSessionTurn for each conversation turn.
|
|
* Integrates ReAct Agent API for agent mode with real-time streaming.
|
|
*/
|
|
|
|
import { useAsyncEffect } from 'ahooks';
|
|
import { Modal, message } from 'antd';
|
|
import { cloneDeep } from 'lodash';
|
|
import { useSearchParams } from 'next/navigation';
|
|
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { v4 as uuid } from 'uuid';
|
|
|
|
import { ChatContext } from '@/app/chat-context';
|
|
import { apiInterceptors, getAppInfo } from '@/client/api';
|
|
import MonacoEditor from '@/components/chat/monaco-editor';
|
|
import { ChatContentContext } from '@/pages/chat';
|
|
import { IApp } from '@/types/app';
|
|
import { IChatDialogueMessageSchema } from '@/types/chat';
|
|
import { STORAGE_INIT_MESSAGE_KET, getInitMessage } from '@/utils';
|
|
|
|
import { parseReActText } from '@/hooks/use-react-agent';
|
|
import useReActAgentChat from '@/hooks/use-react-agent-chat';
|
|
import OpenCodeSessionTurn, { MessagePart } from './OpenCodeSessionTurn';
|
|
|
|
interface GroupedTurn {
|
|
human?: IChatDialogueMessageSchema;
|
|
view?: IChatDialogueMessageSchema;
|
|
key: string;
|
|
}
|
|
|
|
const OpenCodeChatCompletion: React.FC = () => {
|
|
const searchParams = useSearchParams();
|
|
const chatId = searchParams?.get('id') ?? '';
|
|
const scene = searchParams?.get('scene') ?? '';
|
|
|
|
const { currentDialogInfo, model } = useContext(ChatContext);
|
|
const {
|
|
history,
|
|
handleChat: originalHandleChat,
|
|
refreshDialogList,
|
|
setAppInfo,
|
|
setModelValue,
|
|
setTemperatureValue,
|
|
setMaxNewTokensValue,
|
|
setResourceValue,
|
|
replyLoading,
|
|
modelValue,
|
|
setHistory,
|
|
setReplyLoading,
|
|
} = useContext(ChatContentContext);
|
|
|
|
const [jsonModalOpen, setJsonModalOpen] = useState(false);
|
|
const [jsonValue, setJsonValue] = useState<string>('');
|
|
|
|
// Track if we should use ReAct API for agent scene
|
|
const useReActAPI = scene === 'chat_agent';
|
|
const orderRef = useRef<number>(1);
|
|
|
|
// ReAct Agent Chat hook for streaming
|
|
const {
|
|
streamingTurn,
|
|
isStreaming,
|
|
sendMessage: sendReActMessage,
|
|
cancel: cancelReAct,
|
|
} = useReActAgentChat({
|
|
baseUrl: '/api/v1/chat/react-agent',
|
|
onHistoryUpdate: newHistory => {
|
|
setHistory(newHistory);
|
|
},
|
|
onError: error => {
|
|
message.error(error);
|
|
setReplyLoading(false);
|
|
},
|
|
onComplete: () => {
|
|
setReplyLoading(false);
|
|
},
|
|
});
|
|
|
|
// Update order ref when history changes
|
|
useEffect(() => {
|
|
if (history && history.length > 0) {
|
|
const viewList = history.filter(item => item.role === 'view');
|
|
const humanList = history.filter(item => item.role === 'human');
|
|
const lastOrder = Math.max(
|
|
viewList[viewList.length - 1]?.order || 0,
|
|
humanList[humanList.length - 1]?.order || 0,
|
|
);
|
|
orderRef.current = lastOrder + 1;
|
|
}
|
|
}, [history]);
|
|
|
|
// Cleanup on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
cancelReAct();
|
|
};
|
|
}, [cancelReAct]);
|
|
|
|
// Group messages into turns
|
|
const groupedTurns = useMemo<GroupedTurn[]>(() => {
|
|
const tempMessages = cloneDeep(history).filter(item => ['view', 'human'].includes(item.role));
|
|
const groups: GroupedTurn[] = [];
|
|
let currentGroup: Partial<GroupedTurn> = {};
|
|
|
|
for (const msg of tempMessages) {
|
|
if (msg.role === 'human') {
|
|
if (currentGroup.human || currentGroup.view) {
|
|
groups.push({ ...currentGroup, key: uuid() } as GroupedTurn);
|
|
}
|
|
currentGroup = { human: msg };
|
|
} else if (msg.role === 'view') {
|
|
currentGroup.view = msg;
|
|
groups.push({ ...currentGroup, key: uuid() } as GroupedTurn);
|
|
currentGroup = {};
|
|
}
|
|
}
|
|
|
|
if (currentGroup.human || currentGroup.view) {
|
|
groups.push({ ...currentGroup, key: uuid() } as GroupedTurn);
|
|
}
|
|
|
|
return groups;
|
|
}, [history]);
|
|
|
|
// Handle initial message from storage
|
|
useAsyncEffect(async () => {
|
|
const initMessage = getInitMessage();
|
|
if (initMessage && initMessage.id === chatId) {
|
|
const [, res] = await apiInterceptors(
|
|
getAppInfo({
|
|
...currentDialogInfo,
|
|
}),
|
|
);
|
|
if (res) {
|
|
const paramKey: string[] = res?.param_need?.map(i => i.type) || [];
|
|
const resModel = res?.param_need?.filter(item => item.type === 'model')[0]?.value || model;
|
|
const temperature = res?.param_need?.filter(item => item.type === 'temperature')[0]?.value || 0.6;
|
|
const maxNewTokens = res?.param_need?.filter(item => item.type === 'max_new_tokens')[0]?.value || 4000;
|
|
const resource = res?.param_need?.filter(item => item.type === 'resource')[0]?.bind_value;
|
|
setAppInfo(res || ({} as IApp));
|
|
setTemperatureValue(temperature || 0.6);
|
|
setMaxNewTokensValue(maxNewTokens || 4000);
|
|
setModelValue(resModel);
|
|
setResourceValue(resource);
|
|
await originalHandleChat(initMessage.message, {
|
|
app_code: res?.app_code,
|
|
model_name: resModel,
|
|
...(paramKey?.includes('temperature') && { temperature }),
|
|
...(paramKey?.includes('max_new_tokens') && { max_new_tokens: maxNewTokens }),
|
|
...(paramKey.includes('resource') && {
|
|
select_param: typeof resource === 'string' ? resource : JSON.stringify(resource),
|
|
}),
|
|
});
|
|
await refreshDialogList();
|
|
localStorage.removeItem(STORAGE_INIT_MESSAGE_KET);
|
|
}
|
|
}
|
|
}, [chatId, currentDialogInfo]);
|
|
|
|
// Render a single turn from history
|
|
const renderTurn = useCallback(
|
|
(turn: GroupedTurn, index: number, isLast: boolean) => {
|
|
const userMessage = turn.human?.context
|
|
? typeof turn.human.context === 'string'
|
|
? turn.human.context
|
|
: JSON.stringify(turn.human.context)
|
|
: '';
|
|
|
|
let assistantMessage = '';
|
|
let parts: MessagePart[] = [];
|
|
const isThinking = turn.view?.thinking && !turn.view?.context;
|
|
|
|
if (turn.view?.context) {
|
|
const contextStr =
|
|
typeof turn.view.context === 'string' ? turn.view.context : JSON.stringify(turn.view.context);
|
|
|
|
// Parse ReAct format for agent mode, plain text for others
|
|
if (useReActAPI || contextStr.includes('Thought:') || contextStr.includes('Action:')) {
|
|
const parsed = parseReActText(contextStr);
|
|
parts = parsed.parts;
|
|
// Use final content if available, otherwise use the original context
|
|
assistantMessage = parsed.finalContent || extractFinalAnswer(contextStr) || contextStr;
|
|
} else {
|
|
assistantMessage = contextStr;
|
|
}
|
|
}
|
|
|
|
// Show loading only if this is the last turn and we're in loading state but not streaming
|
|
const showLoading = isLast && replyLoading && !isStreaming && !streamingTurn;
|
|
const isWorking = showLoading || isThinking;
|
|
|
|
return (
|
|
<OpenCodeSessionTurn
|
|
key={turn.key}
|
|
userMessage={userMessage}
|
|
assistantMessage={assistantMessage}
|
|
parts={parts}
|
|
isWorking={isWorking}
|
|
showSteps={parts.length > 0}
|
|
defaultStepsExpanded={isLast}
|
|
modelName={turn.view?.model_name || modelValue || model}
|
|
startTime={turn.human?.time_stamp ? Number(turn.human.time_stamp) : undefined}
|
|
endTime={turn.view?.time_stamp ? Number(turn.view.time_stamp) : undefined}
|
|
className='w-full'
|
|
/>
|
|
);
|
|
},
|
|
[useReActAPI, replyLoading, isStreaming, streamingTurn, modelValue, model],
|
|
);
|
|
|
|
// Render the streaming turn (real-time updates)
|
|
const renderStreamingTurn = () => {
|
|
if (!streamingTurn) return null;
|
|
|
|
return (
|
|
<OpenCodeSessionTurn
|
|
key='streaming-turn'
|
|
userMessage={streamingTurn.userMessage}
|
|
assistantMessage={streamingTurn.finalContent}
|
|
parts={streamingTurn.parts}
|
|
isWorking={streamingTurn.isWorking}
|
|
startTime={streamingTurn.startTime}
|
|
endTime={streamingTurn.endTime}
|
|
showSteps={true}
|
|
defaultStepsExpanded={true}
|
|
modelName={modelValue || model}
|
|
thinkingContent={streamingTurn.thinkingContent}
|
|
currentStatus={streamingTurn.currentStatus}
|
|
className='w-full'
|
|
/>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className='flex flex-col w-5/6 mx-auto space-y-2 py-4'>
|
|
{groupedTurns.map((turn, index) => renderTurn(turn, index, index === groupedTurns.length - 1 && !streamingTurn))}
|
|
|
|
{renderStreamingTurn()}
|
|
|
|
<Modal
|
|
title='JSON Editor'
|
|
open={jsonModalOpen}
|
|
width='60%'
|
|
cancelButtonProps={{ hidden: true }}
|
|
onOk={() => setJsonModalOpen(false)}
|
|
onCancel={() => setJsonModalOpen(false)}
|
|
>
|
|
<MonacoEditor className='w-full h-[500px]' language='json' value={jsonValue} />
|
|
</Modal>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Extract final answer from ReAct format text
|
|
*/
|
|
function extractFinalAnswer(text: string): string | null {
|
|
// Look for "Final Answer:" pattern
|
|
const finalAnswerMatch = text.match(/Final Answer:\s*([\s\S]*?)$/i);
|
|
if (finalAnswerMatch) {
|
|
return finalAnswerMatch[1].trim();
|
|
}
|
|
|
|
// Look for terminate action with output
|
|
const terminateMatch = text.match(/Action:\s*terminate[\s\S]*?Action Input:\s*\{[\s\S]*?"output":\s*"([^"]+)"/i);
|
|
if (terminateMatch) {
|
|
return terminateMatch[1];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export default OpenCodeChatCompletion;
|