Skip to content

Commit d55be5d

Browse files
authored
Merge pull request #53 from RonasIT/PRD-2378-fix-fetch-url-block
PRD-2378: Fix fetch_url block in AI response message
2 parents 907f020 + 04194db commit d55be5d

10 files changed

Lines changed: 410 additions & 131 deletions

File tree

i18n/mobile/chat/en.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,13 @@
156156
},
157157
"MESSAGE_FOLLOW_UPS": {
158158
"TEXT_FOLLOW_UP": "Follow up"
159+
},
160+
"AI_MESSAGE": {
161+
"TOOL_OUTPUT_SHEET": {
162+
"TEXT_VIEW_RESULT_PREFIX": "View result from",
163+
"TEXT_INPUT": "Input",
164+
"TEXT_OUTPUT": "Output"
165+
}
159166
}
160167
}
161168
}

libs/mobile/chat/features/chat/src/lib/components/ai-message/component.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ import { Message } from '@open-webui-react-native/shared/data-access/api';
1717
import { FileType } from '@open-webui-react-native/shared/data-access/common';
1818
import { getApiUrl } from '@open-webui-react-native/shared/utils/config';
1919
import { formatDateTime } from '@open-webui-react-native/shared/utils/date';
20+
import { parseResponseMessageContent } from '../../utils';
2021
import { ChatImagesGroup } from '../images';
2122
import { SkeletonMessage } from '../skeleton-message';
23+
import { ToolOutputBottomSheet } from '../tool-output-bottom-sheet';
2224

2325
interface ChatAiMessageProps {
2426
message: Message;
@@ -65,13 +67,14 @@ export function ChatAiMessage({
6567
file.type === FileType.IMAGE ? [...acc, { type: file.type, url: `${apiUrl}${file.url}`, index }] : acc,
6668
[] as Array<AttachedImageWithIndex>,
6769
),
68-
[files],
70+
[apiUrl, files],
6971
);
7072

7173
const { handleImagePress, handleAllPhotosPress, selectedImageIndex, isPreviewVisible, handleCloseImagePress } =
7274
useImagePreview();
7375

74-
const textWithCitations = prepareTextWithCitations(text, citations);
76+
const { toolsData, messageContent } = parseResponseMessageContent(text);
77+
const textWithCitations = prepareTextWithCitations(messageContent, citations);
7578
const hasFollowUps = Array.isArray(followUps) && followUps.length > 0;
7679

7780
return (
@@ -85,6 +88,18 @@ export function ChatAiMessage({
8588
{socketStatusData && <AppText className='mt-4 text-text-secondary'>{socketStatusData.description}</AppText>}
8689
{text ? (
8790
<Fragment>
91+
{toolsData.length > 0 && (
92+
<View className='mt-8 gap-8'>
93+
{toolsData.map((tool, index) => (
94+
<ToolOutputBottomSheet
95+
key={tool.id ?? `${tool.toolName}-${index}`}
96+
toolName={tool.toolName}
97+
input={tool.input}
98+
output={tool.output}
99+
/>
100+
))}
101+
</View>
102+
)}
88103
<ChatImagesGroup
89104
images={attachedImages}
90105
onImagePress={handleImagePress}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { BottomSheetModal, BottomSheetScrollView } from '@gorhom/bottom-sheet';
2+
import { useTranslation } from '@ronas-it/react-native-common-modules/i18n';
3+
import { Fragment, ReactElement, ReactNode, useRef } from 'react';
4+
import {
5+
AppBottomSheet,
6+
AppPressable,
7+
AppSafeAreaView,
8+
AppText,
9+
Icon,
10+
SheetHeader,
11+
View,
12+
} from '@open-webui-react-native/mobile/shared/ui/ui-kit';
13+
14+
export interface ToolOutputBottomSheetProps {
15+
toolName: string;
16+
input?: string;
17+
output: string;
18+
}
19+
20+
export function ToolOutputBottomSheet({ toolName, input, output }: ToolOutputBottomSheetProps): ReactElement {
21+
const translate = useTranslation('CHAT.AI_MESSAGE.TOOL_OUTPUT_SHEET');
22+
const sheetRef = useRef<BottomSheetModal>(null);
23+
24+
const renderTrigger = ({ onPress }: { onPress: () => void }): ReactNode => (
25+
<AppPressable
26+
onPress={onPress}
27+
className='flex-row items-center gap-8 rounded-xl bg-background-secondary px-12 py-10 active:opacity-70'>
28+
<Icon name='tick' className='size-20 shrink-0 color-emerald-500' />
29+
<View className='min-w-0 flex-1 flex-row flex-wrap items-center'>
30+
<AppText className='text-sm-sm sm:text-sm text-text-secondary'>{translate('TEXT_VIEW_RESULT_PREFIX')} </AppText>
31+
<AppText className='text-sm-sm sm:text-sm font-mono font-semibold text-text-primary'>{toolName}</AppText>
32+
</View>
33+
<Icon name='chevronDown' className='size-16 shrink-0 color-text-secondary' />
34+
</AppPressable>
35+
);
36+
37+
return (
38+
<AppBottomSheet
39+
ref={sheetRef}
40+
isModal={true}
41+
isScrollable={true}
42+
snapPoints={['100%']}
43+
renderTrigger={renderTrigger}
44+
content={
45+
<View className='flex-1 bg-background-primary'>
46+
<SheetHeader title={toolName} onGoBack={() => sheetRef.current?.close()} />
47+
<BottomSheetScrollView className='flex-1' contentContainerClassName='pb-safe pt-8 android:pb-24'>
48+
<AppSafeAreaView edges={['bottom']}>
49+
{!!input && (
50+
<Fragment>
51+
<AppText className='mb-8 text-xs font-medium uppercase tracking-wide text-text-secondary'>
52+
{translate('TEXT_INPUT')}
53+
</AppText>
54+
<AppText selectable className='mb-16 text-sm-sm sm:text-sm font-mono text-text-primary'>
55+
{input}
56+
</AppText>
57+
</Fragment>
58+
)}
59+
<AppText className='mb-8 text-xs font-medium uppercase tracking-wide text-text-secondary'>
60+
{translate('TEXT_OUTPUT')}
61+
</AppText>
62+
<AppText selectable className='text-sm-sm sm:text-sm font-mono text-text-primary'>
63+
{output}
64+
</AppText>
65+
</AppSafeAreaView>
66+
</BottomSheetScrollView>
67+
</View>
68+
}
69+
/>
70+
);
71+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './component';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './get-siblings-ids';
22
export * from './patch-new-chat';
3+
export * from './parse-response-message-content';
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { decode } from 'html-entities';
2+
import { parseObjectToString } from '@open-webui-react-native/shared/utils/strings';
3+
4+
type PayloadContentType = 'json' | 'text';
5+
6+
export type ToolData = {
7+
id?: string;
8+
toolName: string;
9+
input: string | undefined;
10+
output: string;
11+
outputContentType: PayloadContentType;
12+
};
13+
14+
export type ParseResponseMessageContentResult = {
15+
toolsData: Array<ToolData>;
16+
messageContent: string;
17+
};
18+
19+
const unescapeAttributeValue = (value: string): string =>
20+
value.replace(/\\(u[0-9a-fA-F]{4}|["'\\ntr])/g, (_, esc: string) => {
21+
switch (esc) {
22+
case 'n':
23+
return '\n';
24+
case 't':
25+
return '\t';
26+
case 'r':
27+
return '\r';
28+
case '"':
29+
return '"';
30+
case '\'':
31+
return '\'';
32+
case '\\':
33+
return '\\';
34+
default:
35+
return esc.startsWith('u') ? String.fromCharCode(parseInt(esc.slice(1), 16)) : esc;
36+
}
37+
});
38+
39+
const parseTagAttributes = (tag: string): Record<string, string> => {
40+
const attrs: Record<string, string> = {};
41+
const attrRe = /([^\s=/>]+)\s*=\s*(?:"((?:\\.|[^"])*)"|'((?:\\.|[^'])*)')/g;
42+
43+
let match: RegExpExecArray | null;
44+
45+
while ((match = attrRe.exec(tag)) !== null) {
46+
const [, rawName, doubleQuoted, singleQuoted] = match;
47+
const rawValue = doubleQuoted ?? singleQuoted ?? '';
48+
attrs[rawName.toLowerCase()] = unescapeAttributeValue(rawValue);
49+
}
50+
51+
return attrs;
52+
};
53+
54+
const indexAfterOpenDetailsTag = (s: string): number => {
55+
const open = s.match(/^<\s*details\b/i);
56+
57+
if (!open) {
58+
return -1;
59+
}
60+
let i = open[0].length;
61+
let inDouble = false;
62+
let escape = false;
63+
64+
while (i < s.length) {
65+
const c = s[i];
66+
67+
if (escape) {
68+
escape = false;
69+
i++;
70+
continue;
71+
}
72+
73+
if (c === '\\') {
74+
escape = true;
75+
i++;
76+
continue;
77+
}
78+
79+
if (c === '"') {
80+
inDouble = !inDouble;
81+
i++;
82+
continue;
83+
}
84+
85+
if (c === '>' && !inDouble) {
86+
return i + 1;
87+
}
88+
i++;
89+
}
90+
91+
return -1;
92+
};
93+
94+
const parseJsonRecursive = (str: string): string => {
95+
let cur = str.trim();
96+
97+
for (let depth = 0; depth < 32; depth++) {
98+
if (typeof cur !== 'string') {
99+
return cur;
100+
}
101+
102+
try {
103+
cur = JSON.parse(cur);
104+
} catch {
105+
return cur;
106+
}
107+
}
108+
109+
return cur;
110+
};
111+
112+
const classifyAndNormalizePayload = (raw: string): { contentType: PayloadContentType; normalized: string } => {
113+
const decoded = decode(raw).trim();
114+
const parsed = parseJsonRecursive(decoded);
115+
116+
if (typeof parsed === 'object' && parsed !== null) {
117+
return { contentType: 'json', normalized: JSON.stringify(parsed, null, 2) };
118+
}
119+
120+
return { contentType: 'text', normalized: String(parsed) };
121+
};
122+
123+
const tryParseLeadingToolCallsDetails = (content: string): { tool: ToolData; rest: string } | null => {
124+
const leadingWs = content.match(/^\s*/)?.[0] ?? '';
125+
const fromDetails = content.slice(leadingWs.length);
126+
127+
if (!fromDetails.toLowerCase().startsWith('<details')) {
128+
return null;
129+
}
130+
131+
const openEnd = indexAfterOpenDetailsTag(fromDetails);
132+
133+
if (openEnd === -1) {
134+
return null;
135+
}
136+
137+
const openTag = fromDetails.slice(0, openEnd);
138+
const attrs = parseTagAttributes(openTag);
139+
140+
if ((attrs.type ?? '').toLowerCase() !== 'tool_calls') {
141+
return null;
142+
}
143+
144+
const closeMatch = fromDetails.slice(openEnd).match(/<\s*\/\s*details\s*>/i);
145+
146+
if (!closeMatch || closeMatch.index === undefined) {
147+
return null;
148+
}
149+
150+
const id = attrs.id ?? undefined;
151+
const toolName = attrs.name ?? '';
152+
const argsRaw = attrs.arguments ?? '';
153+
const resultRaw = attrs.result ?? '';
154+
155+
const outputPayload = classifyAndNormalizePayload(resultRaw);
156+
const input = parseObjectToString(parseJsonRecursive(decode(argsRaw).trim()));
157+
const blockEnd = leadingWs.length + openEnd + closeMatch.index + closeMatch[0].length;
158+
const rest = content.slice(blockEnd).trimStart();
159+
160+
return {
161+
tool: {
162+
id,
163+
toolName,
164+
input,
165+
output: outputPayload.normalized,
166+
outputContentType: outputPayload.contentType,
167+
},
168+
rest,
169+
};
170+
};
171+
172+
export const parseResponseMessageContent = (content: string): ParseResponseMessageContentResult => {
173+
const toolsData: Array<ToolData> = [];
174+
let rest = content;
175+
176+
for (;;) {
177+
const next = tryParseLeadingToolCallsDetails(rest);
178+
179+
if (!next) {
180+
break;
181+
}
182+
toolsData.push(next.tool);
183+
rest = next.rest;
184+
}
185+
186+
return { toolsData, messageContent: rest };
187+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './get-initials';
22
export * from './get-line-count';
3+
export * from './parse-object-to-string';
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { isEmpty } from 'lodash-es';
2+
3+
export const parseObjectToString = (parsed: string): string | undefined => {
4+
if (typeof parsed === 'object' && parsed !== null && isEmpty(parsed)) {
5+
return undefined;
6+
}
7+
8+
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
9+
return Object.entries(parsed)
10+
.map(
11+
([key, value]) =>
12+
`${key}\n${typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value)}`,
13+
)
14+
.join('\n\n');
15+
}
16+
17+
if (typeof parsed === 'object' && parsed !== null) {
18+
return JSON.stringify(parsed, null, 2);
19+
}
20+
21+
return String(parsed);
22+
};

0 commit comments

Comments
 (0)