Files
DB-GPT/web/new-components/chat/content/OpenCodeChatCompletion.tsx
aries_ckt bbf270699a Merge branch 'feature/support-neo4j' into feat/dbgpt_skill
# 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
2026-02-12 17:43:17 +08:00

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;