Files
DB-GPT/web/new-components/chat/content/OpenCodeChatCompletion.tsx

255 lines
8.8 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 } from 'antd';
import { cloneDeep } from 'lodash';
import { useSearchParams } from 'next/navigation';
import React, { useCallback, useContext, useMemo, 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 ContextUsageBar from './ContextUsageBar';
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,
// Context management status from use-chat hook
contextStatus,
} = useContext(ChatContentContext);
const [jsonModalOpen, setJsonModalOpen] = useState(false);
const [jsonValue, setJsonValue] = useState<string>('');
// ReAct streaming turn is not used when routing through completions API
const streamingTurn: any = null;
const isStreaming = false;
// Track if we should use ReAct API for agent scene
const useReActAPI = scene === 'chat_agent';
// 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'>
{/* Context usage floating bar — persists after streaming ends */}
{contextStatus && (
<div className='sticky top-0 z-10 flex justify-center py-1'>
<ContextUsageBar
used={contextStatus.used_tokens}
budget={contextStatus.max_tokens}
ratio={contextStatus.usage_percent / 100}
state={contextStatus.state}
compactLayer={contextStatus.layer ?? null}
/>
</div>
)}
{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;