Skip to content

Commit 78ec123

Browse files
authored
Merge pull request #220 from import-ai/feat/msg_ctrl
feat(chat): Support message branch
2 parents f74be3c + f2f933b commit 78ec123

File tree

20 files changed

+745
-82
lines changed

20 files changed

+745
-82
lines changed

.env.example

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,5 @@ VITE_API_PATH=http://127.0.0.1:8000
77

88
# WebSocket Configuration
99
# Set to 'true' to use WebSocket instead of Server-Sent Events (SSE)
10-
# Default: false (uses SSE)
11-
VITE_USE_WEBSOCKET=false
10+
# Default: true (uses WebSocket)
11+
VITE_USE_WEBSOCKET=true

src/components/copy/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export default function CopyMain(props: IProps) {
5050
)}
5151
</TooltipTrigger>
5252
<TooltipContent>
53-
<p>{t('copy.title')}</p>
53+
<p>{t('chat.messages.actions.copy')}</p>
5454
</TooltipContent>
5555
</Tooltip>
5656
);

src/i18n/locales/en.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,16 @@
317317
"citations_results": "Search Results",
318318
"searching": "Searching...",
319319
"disclaimer": "OmniBox can make mistakes. Check important info.",
320-
"save_to_private": "Save to private space",
320+
"messages": {
321+
"actions": {
322+
"regenerate": "Regenerate",
323+
"save": "Save to private space",
324+
"copy": "Copy Markdown",
325+
"edit": "Edit message",
326+
"save_edit": "Save",
327+
"cancel_edit": "Cancel"
328+
}
329+
},
321330
"conversations": {
322331
"new": "New conversation",
323332
"new_chat": "New Chat",

src/i18n/locales/zh.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,16 @@
317317
"citations_results": "搜索结果",
318318
"searching": "搜索中...",
319319
"disclaimer": "OmniBox 也可能会犯错。请核查重要信息。",
320-
"save_to_private": "保存至个人空间",
320+
"messages": {
321+
"actions": {
322+
"regenerate": "重新生成",
323+
"save": "保存至个人空间",
324+
"copy": "复制 Markdown",
325+
"edit": "编辑消息",
326+
"save_edit": "保存",
327+
"cancel_edit": "取消"
328+
}
329+
},
321330
"conversations": {
322331
"new": "新对话",
323332
"new_chat": "新建对话",

src/page/chat/components/save.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export default function SaveMain(props: IProps) {
5555
</Button>
5656
</TooltipTrigger>
5757
<TooltipContent>
58-
<p>{t('chat.save_to_private')}</p>
58+
<p>{t('chat.messages.actions.save')}</p>
5959
</TooltipContent>
6060
</Tooltip>
6161
);

src/page/chat/conversation/index.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ export default function ChatConversationPage() {
2525
onContextChange,
2626
namespaceId,
2727
conversation,
28+
messageOperator,
29+
onRegenerate,
30+
onEdit,
2831
} = useContext();
2932

3033
return (
@@ -40,6 +43,9 @@ export default function ChatConversationPage() {
4043
<Messages
4144
conversation={conversation}
4245
messages={normalizeChatData(messages)}
46+
messageOperator={messageOperator}
47+
onRegenerate={onRegenerate}
48+
onEdit={onEdit}
4349
/>
4450
)}
4551
</Scrollbar>

src/page/chat/conversation/message-operator.tsx

Lines changed: 85 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
ChatBOSResponse,
55
ChatDeltaResponse,
66
MessageStatus,
7+
OpenAIMessageRole,
78
} from '@/page/chat/types/chat-response';
89
import {
910
ConversationDetail,
@@ -19,9 +20,48 @@ export interface MessageOperator {
1920
add: (chatResponse: ChatBOSResponse) => string;
2021
done: (id?: string) => void;
2122
activate: (id: string) => void;
23+
getSiblings: (id: string) => string[];
24+
getParent: (id: string) => string;
25+
}
26+
27+
function getChildren(
28+
conversation: ConversationDetail,
29+
id: string,
30+
targetRole: OpenAIMessageRole
31+
): string[] {
32+
if (targetRole === OpenAIMessageRole.ASSISTANT) {
33+
const currentNode = conversation.mapping[id];
34+
if (currentNode) {
35+
if (
36+
currentNode.message.role === OpenAIMessageRole.ASSISTANT &&
37+
!currentNode.message.tool_calls
38+
) {
39+
return [id];
40+
}
41+
const targetChildren: string[] = [];
42+
for (const childId of currentNode.children || []) {
43+
targetChildren.push(...getChildren(conversation, childId, targetRole));
44+
}
45+
return targetChildren;
46+
}
47+
} else if (targetRole === OpenAIMessageRole.USER) {
48+
const currentNode = conversation.mapping[id];
49+
if (currentNode) {
50+
return currentNode.children;
51+
}
52+
const children: string[] = [];
53+
for (const node of Object.values(conversation.mapping)) {
54+
if (node.parent_id === id) {
55+
children.push(node.id);
56+
}
57+
}
58+
return children;
59+
}
60+
return [];
2261
}
2362

2463
export function createMessageOperator(
64+
conversation: ConversationDetail,
2565
setConversation: Dispatch<SetStateAction<ConversationDetail>>
2666
): MessageOperator {
2767
return {
@@ -82,11 +122,8 @@ export function createMessageOperator(
82122

83123
setConversation(prev => {
84124
const newMapping = { ...prev.mapping, [message.id]: message };
85-
let currentNode = prev.current_node;
86-
if (message.parent_id === currentNode) {
87-
currentNode = message.id;
88-
}
89-
if (message.parent_id) {
125+
126+
if (message.parent_id && prev.current_node !== undefined) {
90127
const parentMessage = prev.mapping[message.parent_id];
91128
if (parentMessage) {
92129
if (!parentMessage.children.includes(message.id)) {
@@ -101,7 +138,7 @@ export function createMessageOperator(
101138
return {
102139
...prev,
103140
mapping: newMapping,
104-
current_node: currentNode,
141+
current_node: message.id,
105142
};
106143
});
107144
return chatResponse.id;
@@ -124,9 +161,50 @@ export function createMessageOperator(
124161
});
125162
},
126163

164+
/**
165+
* Get siblings of a message.
166+
* @param id
167+
*/
168+
getSiblings: (id: string): string[] => {
169+
const currentNode = conversation.mapping[id];
170+
if (currentNode.message.tool_calls) {
171+
return [id];
172+
}
173+
if (currentNode) {
174+
const currentRole = currentNode.message.role;
175+
if (currentNode.message.role === OpenAIMessageRole.USER) {
176+
return getChildren(conversation, currentNode.parent_id, currentRole);
177+
}
178+
let parentNode = currentNode;
179+
while (parentNode.message.role !== OpenAIMessageRole.USER) {
180+
parentNode = conversation.mapping[parentNode.parent_id];
181+
}
182+
return getChildren(conversation, parentNode.id, currentRole);
183+
}
184+
return [];
185+
},
186+
187+
/**
188+
* Get non-tool parent of a message.
189+
* @param id
190+
*/
191+
getParent: (id: string): string => {
192+
let currentNode = conversation.mapping[id];
193+
if (currentNode) {
194+
const targetRoles =
195+
currentNode.message.role === OpenAIMessageRole.ASSISTANT
196+
? [OpenAIMessageRole.USER]
197+
: [OpenAIMessageRole.ASSISTANT, OpenAIMessageRole.SYSTEM];
198+
while (!targetRoles.includes(currentNode.message.role)) {
199+
currentNode = conversation.mapping[currentNode.parent_id];
200+
}
201+
return currentNode.id;
202+
}
203+
return '';
204+
},
205+
127206
/**
128207
* When there is multi message, activate one of them.
129-
* Designed for future, now enabled for now.
130208
* @param id
131209
*/
132210
activate: (id: string) => {

src/page/chat/conversation/types.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export interface Reasoning extends IChatTool {
2626
name: ToolType.REASONING;
2727
}
2828

29-
type ChatTool = WebSearch | PrivateSearch | Reasoning;
29+
export type ChatTool = WebSearch | PrivateSearch | Reasoning;
3030

3131
export interface ChatRequestBody {
3232
conversation_id: string;

src/page/chat/conversation/useContext.tsx

Lines changed: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@ import {
1515
createMessageOperator,
1616
MessageOperator,
1717
} from '@/page/chat/conversation/message-operator';
18-
import { ask } from '@/page/chat/conversation/utils';
18+
import {
19+
ask,
20+
extractOriginalMessageSettings,
21+
} from '@/page/chat/conversation/utils';
1922
import {
2023
ConversationDetail,
2124
MessageDetail,
@@ -70,8 +73,8 @@ export default function useContext() {
7073
return result;
7174
}, [conversation]);
7275
const messageOperator = useMemo((): MessageOperator => {
73-
return createMessageOperator(setConversation);
74-
}, [setConversation]);
76+
return createMessageOperator(conversation, setConversation);
77+
}, [conversation, setConversation]);
7578
const onAction = async (action?: ChatActionType) => {
7679
if (action === 'stop') {
7780
isFunction(askAbortRef.current) && askAbortRef.current();
@@ -96,7 +99,7 @@ export default function useContext() {
9699
query,
97100
tools,
98101
context,
99-
messages,
102+
messages[messages.length - 1]?.id,
100103
messageOperator,
101104
`/api/v1/namespaces/${namespaceId}/wizard/${mode}`,
102105
getWizardLang(i18n),
@@ -127,6 +130,86 @@ export default function useContext() {
127130
submit(routeQuery);
128131
}, []);
129132

133+
const onRegenerate = async (messageId: string) => {
134+
const parentId = messageOperator.getParent(messageId);
135+
const parentMessage = conversation.mapping[parentId];
136+
if (!parentMessage || !parentMessage.message.content) {
137+
console.error('Cannot find parent user message to regenerate from');
138+
return;
139+
}
140+
141+
const {
142+
originalTools,
143+
originalContext,
144+
originalLang,
145+
originalEnableThinking,
146+
} = extractOriginalMessageSettings(parentMessage, {
147+
tools,
148+
context,
149+
lang: getWizardLang(i18n),
150+
});
151+
152+
setLoading(true);
153+
try {
154+
const askFN = ask(
155+
conversationId,
156+
parentMessage.message.content,
157+
originalTools,
158+
originalContext,
159+
parentId,
160+
messageOperator,
161+
`/api/v1/namespaces/${namespaceId}/wizard/${mode}`,
162+
originalLang,
163+
namespaceId,
164+
undefined,
165+
undefined,
166+
originalEnableThinking
167+
);
168+
askAbortRef.current = askFN.destroy;
169+
await askFN.start();
170+
} finally {
171+
setLoading(false);
172+
}
173+
};
174+
175+
const onEdit = async (messageId: string, newContent: string) => {
176+
const parentId = conversation.mapping[messageId].parent_id;
177+
const editedMessage = conversation.mapping[messageId];
178+
179+
const {
180+
originalTools,
181+
originalContext,
182+
originalLang,
183+
originalEnableThinking,
184+
} = extractOriginalMessageSettings(editedMessage, {
185+
tools,
186+
context,
187+
lang: getWizardLang(i18n),
188+
});
189+
190+
setLoading(true);
191+
try {
192+
const askFN = ask(
193+
conversationId,
194+
newContent,
195+
originalTools,
196+
originalContext,
197+
parentId,
198+
messageOperator,
199+
`/api/v1/namespaces/${namespaceId}/wizard/${mode}`,
200+
originalLang,
201+
namespaceId,
202+
undefined,
203+
undefined,
204+
originalEnableThinking
205+
);
206+
askAbortRef.current = askFN.destroy;
207+
await askFN.start();
208+
} finally {
209+
setLoading(false);
210+
}
211+
};
212+
130213
return {
131214
mode,
132215
value,
@@ -141,5 +224,8 @@ export default function useContext() {
141224
onContextChange,
142225
namespaceId,
143226
conversation,
227+
messageOperator,
228+
onRegenerate,
229+
onEdit,
144230
};
145231
}

0 commit comments

Comments
 (0)