Skip to content

Commit 9d076e4

Browse files
committed
stricter upload file check, only allow image if server has mtmd
1 parent f994a30 commit 9d076e4

File tree

8 files changed

+202
-20
lines changed

8 files changed

+202
-20
lines changed

tools/server/webui/src/components/ChatScreen.tsx

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@ import {
1111
ArrowUpIcon,
1212
StopIcon,
1313
PaperClipIcon,
14-
DocumentTextIcon,
15-
XMarkIcon,
1614
} from '@heroicons/react/24/solid';
1715
import {
1816
ChatExtraContextApi,
@@ -249,9 +247,17 @@ export default function ChatScreen() {
249247
>
250248
{/* chat messages */}
251249
<div id="messages-list" className="grow">
252-
<div className="mt-auto flex justify-center">
250+
<div className="mt-auto flex flex-col items-center">
253251
{/* placeholder to shift the message to the bottom */}
254-
{viewingChat ? '' : 'Send a message to start'}
252+
{viewingChat ? (
253+
''
254+
) : (
255+
<>
256+
<ServerInfo />
257+
<br />
258+
Send a message to start
259+
</>
260+
)}
255261
</div>
256262
{[...messages, ...pendingMsgDisplay].map((msg) => (
257263
<ChatMessage
@@ -285,6 +291,23 @@ export default function ChatScreen() {
285291
);
286292
}
287293

294+
function ServerInfo() {
295+
const { serverProps } = useAppContext();
296+
return (
297+
<div className="card card-sm shadow-sm border-1 border-base-content/20 text-base-content/70 mb-6">
298+
<div className="card-body">
299+
<b>Server Info</b>
300+
<p>
301+
<b>Model</b>: {serverProps?.model_path?.split(/(\\|\/)/).pop()}
302+
<br />
303+
<b>Build</b>: {serverProps?.build_info}
304+
<br />
305+
</p>
306+
</div>
307+
</div>
308+
);
309+
}
310+
288311
function ChatInput({
289312
textarea,
290313
extraContext,

tools/server/webui/src/components/Header.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import { useAppContext } from '../utils/app.context';
44
import { classNames } from '../utils/misc';
55
import daisyuiThemes from 'daisyui/theme/object';
66
import { THEMES } from '../Config';
7-
import { Cog8ToothIcon, MoonIcon, Bars3Icon } from '@heroicons/react/24/outline';
7+
import {
8+
Cog8ToothIcon,
9+
MoonIcon,
10+
Bars3Icon,
11+
} from '@heroicons/react/24/outline';
812

913
export default function Header() {
1014
const [selectedTheme, setSelectedTheme] = useState(StorageUtils.getTheme());

tools/server/webui/src/components/Sidebar.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,9 +146,12 @@ function ConversationItem({
146146
>
147147
{conv.name}
148148
</div>
149-
<div className="dropdown dropdown-end h-5 opacity-0 group-hover:opacity-100">
149+
<div className="dropdown dropdown-end h-5">
150150
<BtnWithTooltips
151-
className="cursor-pointer block group-hover:block"
151+
// on mobile, we always show the ellipsis icon
152+
// on desktop, we only show it when the user hovers over the conversation item
153+
// we use opacity instead of hidden to avoid layout shift
154+
className="cursor-pointer opacity-100 md:opacity-0 group-hover:opacity-100"
152155
onClick={() => {}}
153156
tooltipsContent="More"
154157
>

tools/server/webui/src/components/useChatExtraContext.tsx

Lines changed: 104 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useState } from 'react';
22
import { MessageExtra } from '../utils/types';
33
import toast from 'react-hot-toast';
4+
import { useAppContext } from '../utils/app.context';
45

56
// Interface describing the API returned by the hook
67
export interface ChatExtraContextApi {
@@ -12,6 +13,7 @@ export interface ChatExtraContextApi {
1213
}
1314

1415
export function useChatExtraContext(): ChatExtraContextApi {
16+
const { serverProps } = useAppContext();
1517
const [items, setItems] = useState<MessageExtra[]>([]);
1618

1719
const addItems = (newItems: MessageExtra[]) => {
@@ -34,38 +36,55 @@ export function useChatExtraContext(): ChatExtraContextApi {
3436
toast.error('File is too large. Maximum size is 10MB.');
3537
break;
3638
}
37-
if (mimeType.startsWith('text/')) {
39+
40+
if (mimeType.startsWith('image/') && mimeType !== 'image/svg+xml') {
41+
if (!serverProps?.has_multimodal) {
42+
toast.error('Multimodal is not supported by this server or model.');
43+
break;
44+
}
3845
const reader = new FileReader();
3946
reader.onload = (event) => {
4047
if (event.target?.result) {
4148
addItems([
4249
{
43-
type: 'textFile',
50+
type: 'imageFile',
4451
name: file.name,
45-
content: event.target.result as string,
52+
base64Url: event.target.result as string,
4653
},
4754
]);
4855
}
4956
};
50-
reader.readAsText(file);
51-
} else if (mimeType.startsWith('image/')) {
52-
// TODO @ngxson : throw an error if the server does not support image input
57+
reader.readAsDataURL(file);
58+
} else if (
59+
mimeType.startsWith('video/') ||
60+
mimeType.startsWith('audio/')
61+
) {
62+
toast.error('Video files are not supported yet.');
63+
break;
64+
} else if (mimeType.startsWith('application/pdf')) {
65+
toast.error('PDF files are not supported yet.');
66+
break;
67+
} else {
68+
// Because there can be many text file types (like code file), we will not check the mime type
69+
// and will just check if the file is not binary.
5370
const reader = new FileReader();
5471
reader.onload = (event) => {
5572
if (event.target?.result) {
73+
const content = event.target.result as string;
74+
if (!isLikelyNotBinary(content)) {
75+
toast.error('File is binary. Please upload a text file.');
76+
return;
77+
}
5678
addItems([
5779
{
58-
type: 'imageFile',
80+
type: 'textFile',
5981
name: file.name,
60-
base64Url: event.target.result as string,
82+
content,
6183
},
6284
]);
6385
}
6486
};
65-
reader.readAsDataURL(file);
66-
} else {
67-
// TODO @ngxson : support all other file formats like .pdf, .py, .bat, .c, etc
68-
toast.error('Unsupported file type.');
87+
reader.readAsText(file);
6988
}
7089
}
7190
};
@@ -78,3 +97,76 @@ export function useChatExtraContext(): ChatExtraContextApi {
7897
onFileAdded,
7998
};
8099
}
100+
101+
// WARN: vibe code below
102+
// This code is a heuristic to determine if a string is likely not binary.
103+
export function isLikelyNotBinary(str: string): boolean {
104+
const options = {
105+
prefixLength: 1024 * 10, // Check the first 10KB of the string
106+
suspiciousCharThresholdRatio: 0.15, // Allow up to 15% suspicious chars
107+
maxAbsoluteNullBytes: 2,
108+
};
109+
110+
if (!str) {
111+
return true; // Empty string is considered "not binary" or trivially text.
112+
}
113+
114+
const sampleLength = Math.min(str.length, options.prefixLength);
115+
if (sampleLength === 0) {
116+
return true; // Effectively an empty string after considering prefixLength.
117+
}
118+
119+
let suspiciousCharCount = 0;
120+
let nullByteCount = 0;
121+
122+
for (let i = 0; i < sampleLength; i++) {
123+
const charCode = str.charCodeAt(i);
124+
125+
// 1. Check for Unicode Replacement Character (U+FFFD)
126+
// This is a strong indicator if the string was created from decoding bytes as UTF-8.
127+
if (charCode === 0xfffd) {
128+
suspiciousCharCount++;
129+
continue;
130+
}
131+
132+
// 2. Check for Null Bytes (U+0000)
133+
if (charCode === 0x0000) {
134+
nullByteCount++;
135+
// We also count nulls towards the general suspicious character count,
136+
// as they are less common in typical text files.
137+
suspiciousCharCount++;
138+
continue;
139+
}
140+
141+
// 3. Check for C0 Control Characters (U+0001 to U+001F)
142+
// Exclude common text control characters: TAB (9), LF (10), CR (13).
143+
// We can also be a bit lenient with BEL (7) and BS (8) which sometimes appear in logs.
144+
if (charCode < 32) {
145+
if (
146+
charCode !== 9 && // TAB
147+
charCode !== 10 && // LF
148+
charCode !== 13 && // CR
149+
charCode !== 7 && // BEL (Bell) - sometimes in logs
150+
charCode !== 8 // BS (Backspace) - less common, but possible
151+
) {
152+
suspiciousCharCount++;
153+
}
154+
}
155+
// Characters from 32 (space) up to 126 (~) are printable ASCII.
156+
// Characters 127 (DEL) is a control character.
157+
// Characters >= 128 are extended ASCII / multi-byte Unicode.
158+
// If they resulted in U+FFFD, we caught it. Otherwise, they are valid
159+
// (though perhaps unusual) Unicode characters from JS's perspective.
160+
// The main concern is if those higher characters came from misinterpreting
161+
// a single-byte encoding as UTF-8, which again, U+FFFD would usually flag.
162+
}
163+
164+
// Check absolute null byte count
165+
if (nullByteCount > options.maxAbsoluteNullBytes) {
166+
return false; // Too many null bytes is a strong binary indicator
167+
}
168+
169+
// Check ratio of suspicious characters
170+
const ratio = suspiciousCharCount / sampleLength;
171+
return ratio <= options.suspiciousCharThresholdRatio;
172+
}

tools/server/webui/src/utils/app.context.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
APIMessage,
44
CanvasData,
55
Conversation,
6+
LlamaCppServerProps,
67
Message,
78
PendingMessage,
89
ViewingChat,
@@ -12,6 +13,7 @@ import {
1213
filterThoughtFromMsgs,
1314
normalizeMsgsForAPI,
1415
getSSEStreamAsync,
16+
getServerProps,
1517
} from './misc';
1618
import { BASE_URL, CONFIG_DEFAULT, isDev } from '../Config';
1719
import { matchPath, useLocation, useNavigate } from 'react-router';
@@ -47,6 +49,9 @@ interface AppContextValue {
4749
saveConfig: (config: typeof CONFIG_DEFAULT) => void;
4850
showSettings: boolean;
4951
setShowSettings: (show: boolean) => void;
52+
53+
// props
54+
serverProps: LlamaCppServerProps | null;
5055
}
5156

5257
// this callback is used for scrolling to the bottom of the chat and switching to the last node
@@ -75,6 +80,9 @@ export const AppContextProvider = ({
7580
const params = matchPath('/chat/:convId', pathname);
7681
const convId = params?.params?.convId;
7782

83+
const [serverProps, setServerProps] = useState<LlamaCppServerProps | null>(
84+
null
85+
);
7886
const [viewingChat, setViewingChat] = useState<ViewingChat | null>(null);
7987
const [pendingMessages, setPendingMessages] = useState<
8088
Record<Conversation['id'], PendingMessage>
@@ -86,6 +94,20 @@ export const AppContextProvider = ({
8694
const [canvasData, setCanvasData] = useState<CanvasData | null>(null);
8795
const [showSettings, setShowSettings] = useState(false);
8896

97+
// get server props
98+
useEffect(() => {
99+
getServerProps(BASE_URL, config.apiKey)
100+
.then((props) => {
101+
console.debug('Server props:', props);
102+
setServerProps(props);
103+
})
104+
.catch((err) => {
105+
console.error(err);
106+
toast.error('Failed to fetch server props');
107+
});
108+
// eslint-disable-next-line
109+
}, []);
110+
89111
// handle change when the convId from URL is changed
90112
useEffect(() => {
91113
// also reset the canvas data
@@ -378,6 +400,7 @@ export const AppContextProvider = ({
378400
saveConfig,
379401
showSettings,
380402
setShowSettings,
403+
serverProps,
381404
}}
382405
>
383406
{children}

tools/server/webui/src/utils/misc.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
// @ts-expect-error this package does not have typing
22
import TextLineStream from 'textlinestream';
3-
import { APIMessage, APIMessageContentPart, Message } from './types';
3+
import {
4+
APIMessage,
5+
APIMessageContentPart,
6+
LlamaCppServerProps,
7+
Message,
8+
} from './types';
49

510
// ponyfill for missing ReadableStream asyncIterator on Safari
611
import { asyncIterator } from '@sec-ant/readable-stream/ponyfill/asyncIterator';
@@ -157,3 +162,25 @@ export const cleanCurrentUrl = (removeQueryParams: string[]) => {
157162
});
158163
window.history.replaceState({}, '', url.toString());
159164
};
165+
166+
export const getServerProps = async (
167+
baseUrl: string,
168+
apiKey?: string
169+
): Promise<LlamaCppServerProps> => {
170+
try {
171+
const response = await fetch(`${baseUrl}/props`, {
172+
headers: {
173+
'Content-Type': 'application/json',
174+
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
175+
},
176+
});
177+
if (!response.ok) {
178+
throw new Error('Failed to fetch server props');
179+
}
180+
const data = await response.json();
181+
return data as LlamaCppServerProps;
182+
} catch (error) {
183+
console.error('Error fetching server props:', error);
184+
throw error;
185+
}
186+
};

tools/server/webui/src/utils/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,12 @@ export interface CanvasPyInterpreter {
112112
}
113113

114114
export type CanvasData = CanvasPyInterpreter;
115+
116+
// a non-complete list of props, only contains the ones we need
117+
export interface LlamaCppServerProps {
118+
build_info: string;
119+
model_path: string;
120+
n_ctx: number;
121+
has_multimodal: boolean;
122+
// TODO: support params
123+
}

tools/server/webui/vite.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export default defineConfig({
7171
server: {
7272
proxy: {
7373
'/v1': 'http://localhost:8080',
74+
'/props': 'http://localhost:8080',
7475
},
7576
headers: {
7677
'Cross-Origin-Embedder-Policy': 'require-corp',

0 commit comments

Comments
 (0)