Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions i18n/mobile/chat/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,13 @@
},
"MESSAGE_FOLLOW_UPS": {
"TEXT_FOLLOW_UP": "Follow up"
},
"AI_MESSAGE": {
"TOOL_OUTPUT_SHEET": {
"TEXT_VIEW_RESULT_PREFIX": "View result from",
"TEXT_INPUT": "Input",
"TEXT_OUTPUT": "Output"
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ import { Message } from '@open-webui-react-native/shared/data-access/api';
import { FileType } from '@open-webui-react-native/shared/data-access/common';
import { getApiUrl } from '@open-webui-react-native/shared/utils/config';
import { formatDateTime } from '@open-webui-react-native/shared/utils/date';
import { parseResponseMessageContent } from '../../utils';
import { ChatImagesGroup } from '../images';
import { SkeletonMessage } from '../skeleton-message';
import { ToolOutputBottomSheet } from '../tool-output-bottom-sheet';

interface ChatAiMessageProps {
message: Message;
Expand Down Expand Up @@ -65,13 +67,14 @@ export function ChatAiMessage({
file.type === FileType.IMAGE ? [...acc, { type: file.type, url: `${apiUrl}${file.url}`, index }] : acc,
[] as Array<AttachedImageWithIndex>,
),
[files],
[apiUrl, files],
);

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

const textWithCitations = prepareTextWithCitations(text, citations);
const { toolsData, messageContent } = parseResponseMessageContent(text);
const textWithCitations = prepareTextWithCitations(messageContent, citations);
const hasFollowUps = Array.isArray(followUps) && followUps.length > 0;

return (
Expand All @@ -85,6 +88,18 @@ export function ChatAiMessage({
{socketStatusData && <AppText className='mt-4 text-text-secondary'>{socketStatusData.description}</AppText>}
{text ? (
<Fragment>
{toolsData.length > 0 && (
<View className='mt-8 gap-8'>
{toolsData.map((tool, index) => (
<ToolOutputBottomSheet
key={tool.id ?? `${tool.toolName}-${index}`}
toolName={tool.toolName}
input={tool.input}
output={tool.output}
/>
))}
</View>
)}
<ChatImagesGroup
images={attachedImages}
onImagePress={handleImagePress}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { BottomSheetModal, BottomSheetScrollView } from '@gorhom/bottom-sheet';
import { useTranslation } from '@ronas-it/react-native-common-modules/i18n';
import { Fragment, ReactElement, ReactNode, useRef } from 'react';
import {
AppBottomSheet,
AppPressable,
AppSafeAreaView,
AppText,
Icon,
SheetHeader,
View,
} from '@open-webui-react-native/mobile/shared/ui/ui-kit';

export interface ToolOutputBottomSheetProps {
toolName: string;
input?: string;
output: string;
}

export function ToolOutputBottomSheet({ toolName, input, output }: ToolOutputBottomSheetProps): ReactElement {
const translate = useTranslation('CHAT.AI_MESSAGE.TOOL_OUTPUT_SHEET');
const sheetRef = useRef<BottomSheetModal>(null);

const renderTrigger = ({ onPress }: { onPress: () => void }): ReactNode => (
<AppPressable
onPress={onPress}
className='flex-row items-center gap-8 rounded-xl bg-background-secondary px-12 py-10 active:opacity-70'>
<Icon name='tick' className='size-20 shrink-0 color-emerald-500' />
<View className='min-w-0 flex-1 flex-row flex-wrap items-center'>
<AppText className='text-sm-sm sm:text-sm text-text-secondary'>{translate('TEXT_VIEW_RESULT_PREFIX')} </AppText>
<AppText className='text-sm-sm sm:text-sm font-mono font-semibold text-text-primary'>{toolName}</AppText>
</View>
<Icon name='chevronDown' className='size-16 shrink-0 color-text-secondary' />
</AppPressable>
);

return (
<AppBottomSheet
ref={sheetRef}
isModal={true}
isScrollable={true}
snapPoints={['100%']}
renderTrigger={renderTrigger}
content={
<View className='flex-1 bg-background-primary'>
<SheetHeader title={toolName} onGoBack={() => sheetRef.current?.close()} />
<BottomSheetScrollView className='flex-1' contentContainerClassName='pb-safe pt-8 android:pb-24'>
<AppSafeAreaView edges={['bottom']}>
{!!input && (
<Fragment>
<AppText className='mb-8 text-xs font-medium uppercase tracking-wide text-text-secondary'>
{translate('TEXT_INPUT')}
</AppText>
<AppText selectable className='mb-16 text-sm-sm sm:text-sm font-mono text-text-primary'>
{input}
</AppText>
</Fragment>
)}
<AppText className='mb-8 text-xs font-medium uppercase tracking-wide text-text-secondary'>
{translate('TEXT_OUTPUT')}
</AppText>
<AppText selectable className='text-sm-sm sm:text-sm font-mono text-text-primary'>
{output}
</AppText>
</AppSafeAreaView>
</BottomSheetScrollView>
</View>
}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './component';
1 change: 1 addition & 0 deletions libs/mobile/chat/features/chat/src/lib/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './get-siblings-ids';
export * from './patch-new-chat';
export * from './parse-response-message-content';
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { decode } from 'html-entities';
import { parseObjectToString } from '@open-webui-react-native/shared/utils/strings';

type PayloadContentType = 'json' | 'text';

export type ToolData = {
id?: string;
toolName: string;
input: string | undefined;
output: string;
outputContentType: PayloadContentType;
};

export type ParseResponseMessageContentResult = {
toolsData: Array<ToolData>;
messageContent: string;
};

const unescapeAttributeValue = (value: string): string =>
value.replace(/\\(u[0-9a-fA-F]{4}|["'\\ntr])/g, (_, esc: string) => {
switch (esc) {
case 'n':
return '\n';
case 't':
return '\t';
case 'r':
return '\r';
case '"':
return '"';
case '\'':
return '\'';
case '\\':
return '\\';
default:
return esc.startsWith('u') ? String.fromCharCode(parseInt(esc.slice(1), 16)) : esc;
}
});

const parseTagAttributes = (tag: string): Record<string, string> => {
const attrs: Record<string, string> = {};
const attrRe = /([^\s=/>]+)\s*=\s*(?:"((?:\\.|[^"])*)"|'((?:\\.|[^'])*)')/g;

let match: RegExpExecArray | null;

while ((match = attrRe.exec(tag)) !== null) {
const [, rawName, doubleQuoted, singleQuoted] = match;
const rawValue = doubleQuoted ?? singleQuoted ?? '';
attrs[rawName.toLowerCase()] = unescapeAttributeValue(rawValue);
}

return attrs;
};

const indexAfterOpenDetailsTag = (s: string): number => {
const open = s.match(/^<\s*details\b/i);

if (!open) {
return -1;
}
let i = open[0].length;
let inDouble = false;
let escape = false;

while (i < s.length) {
const c = s[i];

if (escape) {
escape = false;
i++;
continue;
}

if (c === '\\') {
escape = true;
i++;
continue;
}

if (c === '"') {
inDouble = !inDouble;
i++;
continue;
}

if (c === '>' && !inDouble) {
return i + 1;
}
i++;
}

return -1;
};

const parseJsonRecursive = (str: string): string => {
let cur = str.trim();

for (let depth = 0; depth < 32; depth++) {
if (typeof cur !== 'string') {
return cur;
}

try {
cur = JSON.parse(cur);
} catch {
return cur;
}
}

return cur;
};

const classifyAndNormalizePayload = (raw: string): { contentType: PayloadContentType; normalized: string } => {
const decoded = decode(raw).trim();
const parsed = parseJsonRecursive(decoded);

if (typeof parsed === 'object' && parsed !== null) {
return { contentType: 'json', normalized: JSON.stringify(parsed, null, 2) };
}

return { contentType: 'text', normalized: String(parsed) };
};

const tryParseLeadingToolCallsDetails = (content: string): { tool: ToolData; rest: string } | null => {
const leadingWs = content.match(/^\s*/)?.[0] ?? '';
const fromDetails = content.slice(leadingWs.length);

if (!fromDetails.toLowerCase().startsWith('<details')) {
return null;
}

const openEnd = indexAfterOpenDetailsTag(fromDetails);

if (openEnd === -1) {
return null;
}

const openTag = fromDetails.slice(0, openEnd);
const attrs = parseTagAttributes(openTag);

if ((attrs.type ?? '').toLowerCase() !== 'tool_calls') {
return null;
}

const closeMatch = fromDetails.slice(openEnd).match(/<\s*\/\s*details\s*>/i);

if (!closeMatch || closeMatch.index === undefined) {
return null;
}

const id = attrs.id ?? undefined;
const toolName = attrs.name ?? '';
const argsRaw = attrs.arguments ?? '';
const resultRaw = attrs.result ?? '';

const outputPayload = classifyAndNormalizePayload(resultRaw);
const input = parseObjectToString(parseJsonRecursive(decode(argsRaw).trim()));
const blockEnd = leadingWs.length + openEnd + closeMatch.index + closeMatch[0].length;
const rest = content.slice(blockEnd).trimStart();

return {
tool: {
id,
toolName,
input,
output: outputPayload.normalized,
outputContentType: outputPayload.contentType,
},
rest,
};
};

export const parseResponseMessageContent = (content: string): ParseResponseMessageContentResult => {
const toolsData: Array<ToolData> = [];
let rest = content;

for (;;) {
const next = tryParseLeadingToolCallsDetails(rest);

if (!next) {
break;
}
toolsData.push(next.tool);
rest = next.rest;
}

return { toolsData, messageContent: rest };
};
1 change: 1 addition & 0 deletions libs/shared/utils/strings/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './get-initials';
export * from './get-line-count';
export * from './parse-object-to-string';
22 changes: 22 additions & 0 deletions libs/shared/utils/strings/src/parse-object-to-string.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { isEmpty } from 'lodash-es';

export const parseObjectToString = (parsed: string): string | undefined => {
if (typeof parsed === 'object' && parsed !== null && isEmpty(parsed)) {
return undefined;
}

if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
return Object.entries(parsed)
.map(
([key, value]) =>
`${key}\n${typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value)}`,
)
.join('\n\n');
}

if (typeof parsed === 'object' && parsed !== null) {
return JSON.stringify(parsed, null, 2);
}

return String(parsed);
};
Loading
Loading