first commit

This commit is contained in:
Harivansh Rathi 2024-12-14 00:37:18 -05:00
commit 1cdbffff09
200 changed files with 30007 additions and 0 deletions

6
app/lib/hooks/index.ts Normal file
View file

@ -0,0 +1,6 @@
export * from './useMessageParser';
export * from './usePromptEnhancer';
export * from './useShortcuts';
export * from './useSnapScroll';
export * from './useEditChatDescription';
export { default } from './useViewport';

View file

@ -0,0 +1,163 @@
import { useStore } from '@nanostores/react';
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import {
chatId as chatIdStore,
description as descriptionStore,
db,
updateChatDescription,
getMessages,
} from '~/lib/persistence';
interface EditChatDescriptionOptions {
initialDescription?: string;
customChatId?: string;
syncWithGlobalStore?: boolean;
}
type EditChatDescriptionHook = {
editing: boolean;
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
handleBlur: () => Promise<void>;
handleSubmit: (event: React.FormEvent) => Promise<void>;
handleKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => Promise<void>;
currentDescription: string;
toggleEditMode: () => void;
};
/**
* Hook to manage the state and behavior for editing chat descriptions.
*
* Offers functions to:
* - Switch between edit and view modes.
* - Manage input changes, blur, and form submission events.
* - Save updates to IndexedDB and optionally to the global application state.
*
* @param {Object} options
* @param {string} options.initialDescription - The current chat description.
* @param {string} options.customChatId - Optional ID for updating the description via the sidebar.
* @param {boolean} options.syncWithGlobalStore - Flag to indicate global description store synchronization.
* @returns {EditChatDescriptionHook} Methods and state for managing description edits.
*/
export function useEditChatDescription({
initialDescription = descriptionStore.get()!,
customChatId,
syncWithGlobalStore,
}: EditChatDescriptionOptions): EditChatDescriptionHook {
const chatIdFromStore = useStore(chatIdStore);
const [editing, setEditing] = useState(false);
const [currentDescription, setCurrentDescription] = useState(initialDescription);
const [chatId, setChatId] = useState<string>();
useEffect(() => {
setChatId(customChatId || chatIdFromStore);
}, [customChatId, chatIdFromStore]);
useEffect(() => {
setCurrentDescription(initialDescription);
}, [initialDescription]);
const toggleEditMode = useCallback(() => setEditing((prev) => !prev), []);
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setCurrentDescription(e.target.value);
}, []);
const fetchLatestDescription = useCallback(async () => {
if (!db || !chatId) {
return initialDescription;
}
try {
const chat = await getMessages(db, chatId);
return chat?.description || initialDescription;
} catch (error) {
console.error('Failed to fetch latest description:', error);
return initialDescription;
}
}, [db, chatId, initialDescription]);
const handleBlur = useCallback(async () => {
const latestDescription = await fetchLatestDescription();
setCurrentDescription(latestDescription);
toggleEditMode();
}, [fetchLatestDescription, toggleEditMode]);
const isValidDescription = useCallback((desc: string): boolean => {
const trimmedDesc = desc.trim();
if (trimmedDesc === initialDescription) {
toggleEditMode();
return false; // No change, skip validation
}
const lengthValid = trimmedDesc.length > 0 && trimmedDesc.length <= 100;
const characterValid = /^[a-zA-Z0-9\s]+$/.test(trimmedDesc);
if (!lengthValid) {
toast.error('Description must be between 1 and 100 characters.');
return false;
}
if (!characterValid) {
toast.error('Description can only contain alphanumeric characters and spaces.');
return false;
}
return true;
}, []);
const handleSubmit = useCallback(
async (event: React.FormEvent) => {
event.preventDefault();
if (!isValidDescription(currentDescription)) {
return;
}
try {
if (!db) {
toast.error('Chat persistence is not available');
return;
}
if (!chatId) {
toast.error('Chat Id is not available');
return;
}
await updateChatDescription(db, chatId, currentDescription);
if (syncWithGlobalStore) {
descriptionStore.set(currentDescription);
}
toast.success('Chat description updated successfully');
} catch (error) {
toast.error('Failed to update chat description: ' + (error as Error).message);
}
toggleEditMode();
},
[currentDescription, db, chatId, initialDescription, customChatId],
);
const handleKeyDown = useCallback(
async (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Escape') {
await handleBlur();
}
},
[handleBlur],
);
return {
editing,
handleChange,
handleBlur,
handleSubmit,
handleKeyDown,
currentDescription,
toggleEditMode,
};
}

287
app/lib/hooks/useGit.ts Normal file
View file

@ -0,0 +1,287 @@
import type { WebContainer } from '@webcontainer/api';
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react';
import { webcontainer as webcontainerPromise } from '~/lib/webcontainer';
import git, { type GitAuth, type PromiseFsClient } from 'isomorphic-git';
import http from 'isomorphic-git/http/web';
import Cookies from 'js-cookie';
import { toast } from 'react-toastify';
const lookupSavedPassword = (url: string) => {
const domain = url.split('/')[2];
const gitCreds = Cookies.get(`git:${domain}`);
if (!gitCreds) {
return null;
}
try {
const { username, password } = JSON.parse(gitCreds || '{}');
return { username, password };
} catch (error) {
console.log(`Failed to parse Git Cookie ${error}`);
return null;
}
};
const saveGitAuth = (url: string, auth: GitAuth) => {
const domain = url.split('/')[2];
Cookies.set(`git:${domain}`, JSON.stringify(auth));
};
export function useGit() {
const [ready, setReady] = useState(false);
const [webcontainer, setWebcontainer] = useState<WebContainer>();
const [fs, setFs] = useState<PromiseFsClient>();
const fileData = useRef<Record<string, { data: any; encoding?: string }>>({});
useEffect(() => {
webcontainerPromise.then((container) => {
fileData.current = {};
setWebcontainer(container);
setFs(getFs(container, fileData));
setReady(true);
});
}, []);
const gitClone = useCallback(
async (url: string) => {
if (!webcontainer || !fs || !ready) {
throw 'Webcontainer not initialized';
}
fileData.current = {};
await git.clone({
fs,
http,
dir: webcontainer.workdir,
url,
depth: 1,
singleBranch: true,
corsProxy: 'https://cors.isomorphic-git.org',
onAuth: (url) => {
// let domain=url.split("/")[2]
let auth = lookupSavedPassword(url);
if (auth) {
return auth;
}
if (confirm('This repo is password protected. Ready to enter a username & password?')) {
auth = {
username: prompt('Enter username'),
password: prompt('Enter password'),
};
return auth;
} else {
return { cancel: true };
}
},
onAuthFailure: (url, _auth) => {
toast.error(`Error Authenticating with ${url.split('/')[2]}`);
},
onAuthSuccess: (url, auth) => {
saveGitAuth(url, auth);
},
});
const data: Record<string, { data: any; encoding?: string }> = {};
for (const [key, value] of Object.entries(fileData.current)) {
data[key] = value;
}
return { workdir: webcontainer.workdir, data };
},
[webcontainer],
);
return { ready, gitClone };
}
const getFs = (
webcontainer: WebContainer,
record: MutableRefObject<Record<string, { data: any; encoding?: string }>>,
) => ({
promises: {
readFile: async (path: string, options: any) => {
const encoding = options.encoding;
const relativePath = pathUtils.relative(webcontainer.workdir, path);
console.log('readFile', relativePath, encoding);
return await webcontainer.fs.readFile(relativePath, encoding);
},
writeFile: async (path: string, data: any, options: any) => {
const encoding = options.encoding;
const relativePath = pathUtils.relative(webcontainer.workdir, path);
console.log('writeFile', { relativePath, data, encoding });
if (record.current) {
record.current[relativePath] = { data, encoding };
}
return await webcontainer.fs.writeFile(relativePath, data, { ...options, encoding });
},
mkdir: async (path: string, options: any) => {
const relativePath = pathUtils.relative(webcontainer.workdir, path);
console.log('mkdir', relativePath, options);
return await webcontainer.fs.mkdir(relativePath, { ...options, recursive: true });
},
readdir: async (path: string, options: any) => {
const relativePath = pathUtils.relative(webcontainer.workdir, path);
console.log('readdir', relativePath, options);
return await webcontainer.fs.readdir(relativePath, options);
},
rm: async (path: string, options: any) => {
const relativePath = pathUtils.relative(webcontainer.workdir, path);
console.log('rm', relativePath, options);
return await webcontainer.fs.rm(relativePath, { ...(options || {}) });
},
rmdir: async (path: string, options: any) => {
const relativePath = pathUtils.relative(webcontainer.workdir, path);
console.log('rmdir', relativePath, options);
return await webcontainer.fs.rm(relativePath, { recursive: true, ...options });
},
// Mock implementations for missing functions
unlink: async (path: string) => {
// unlink is just removing a single file
const relativePath = pathUtils.relative(webcontainer.workdir, path);
return await webcontainer.fs.rm(relativePath, { recursive: false });
},
stat: async (path: string) => {
try {
const relativePath = pathUtils.relative(webcontainer.workdir, path);
const resp = await webcontainer.fs.readdir(pathUtils.dirname(relativePath), { withFileTypes: true });
const name = pathUtils.basename(relativePath);
const fileInfo = resp.find((x) => x.name == name);
if (!fileInfo) {
throw new Error(`ENOENT: no such file or directory, stat '${path}'`);
}
return {
isFile: () => fileInfo.isFile(),
isDirectory: () => fileInfo.isDirectory(),
isSymbolicLink: () => false,
size: 1,
mode: 0o666, // Default permissions
mtimeMs: Date.now(),
uid: 1000,
gid: 1000,
};
} catch (error: any) {
console.log(error?.message);
const err = new Error(`ENOENT: no such file or directory, stat '${path}'`) as NodeJS.ErrnoException;
err.code = 'ENOENT';
err.errno = -2;
err.syscall = 'stat';
err.path = path;
throw err;
}
},
lstat: async (path: string) => {
/*
* For basic usage, lstat can return the same as stat
* since we're not handling symbolic links
*/
return await getFs(webcontainer, record).promises.stat(path);
},
readlink: async (path: string) => {
/*
* Since WebContainer doesn't support symlinks,
* we'll throw a "not a symbolic link" error
*/
throw new Error(`EINVAL: invalid argument, readlink '${path}'`);
},
symlink: async (target: string, path: string) => {
/*
* Since WebContainer doesn't support symlinks,
* we'll throw a "operation not supported" error
*/
throw new Error(`EPERM: operation not permitted, symlink '${target}' -> '${path}'`);
},
chmod: async (_path: string, _mode: number) => {
/*
* WebContainer doesn't support changing permissions,
* but we can pretend it succeeded for compatibility
*/
return await Promise.resolve();
},
},
});
const pathUtils = {
dirname: (path: string) => {
// Handle empty or just filename cases
if (!path || !path.includes('/')) {
return '.';
}
// Remove trailing slashes
path = path.replace(/\/+$/, '');
// Get directory part
return path.split('/').slice(0, -1).join('/') || '/';
},
basename: (path: string, ext?: string) => {
// Remove trailing slashes
path = path.replace(/\/+$/, '');
// Get the last part of the path
const base = path.split('/').pop() || '';
// If extension is provided, remove it from the result
if (ext && base.endsWith(ext)) {
return base.slice(0, -ext.length);
}
return base;
},
relative: (from: string, to: string): string => {
// Handle empty inputs
if (!from || !to) {
return '.';
}
// Normalize paths by removing trailing slashes and splitting
const normalizePathParts = (p: string) => p.replace(/\/+$/, '').split('/').filter(Boolean);
const fromParts = normalizePathParts(from);
const toParts = normalizePathParts(to);
// Find common parts at the start of both paths
let commonLength = 0;
const minLength = Math.min(fromParts.length, toParts.length);
for (let i = 0; i < minLength; i++) {
if (fromParts[i] !== toParts[i]) {
break;
}
commonLength++;
}
// Calculate the number of "../" needed
const upCount = fromParts.length - commonLength;
// Get the remaining path parts we need to append
const remainingPath = toParts.slice(commonLength);
// Construct the relative path
const relativeParts = [...Array(upCount).fill('..'), ...remainingPath];
// Handle empty result case
return relativeParts.length === 0 ? '.' : relativeParts.join('/');
},
};

View file

@ -0,0 +1,70 @@
import type { Message } from 'ai';
import { useCallback, useState } from 'react';
import { StreamingMessageParser } from '~/lib/runtime/message-parser';
import { workbenchStore } from '~/lib/stores/workbench';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('useMessageParser');
const messageParser = new StreamingMessageParser({
callbacks: {
onArtifactOpen: (data) => {
logger.trace('onArtifactOpen', data);
workbenchStore.showWorkbench.set(true);
workbenchStore.addArtifact(data);
},
onArtifactClose: (data) => {
logger.trace('onArtifactClose');
workbenchStore.updateArtifact(data, { closed: true });
},
onActionOpen: (data) => {
logger.trace('onActionOpen', data.action);
// we only add shell actions when when the close tag got parsed because only then we have the content
if (data.action.type !== 'shell') {
workbenchStore.addAction(data);
}
},
onActionClose: (data) => {
logger.trace('onActionClose', data.action);
if (data.action.type === 'shell') {
workbenchStore.addAction(data);
}
workbenchStore.runAction(data);
},
onActionStream: (data) => {
logger.trace('onActionStream', data.action);
workbenchStore.runAction(data, true);
},
},
});
export function useMessageParser() {
const [parsedMessages, setParsedMessages] = useState<{ [key: number]: string }>({});
const parseMessages = useCallback((messages: Message[], isLoading: boolean) => {
let reset = false;
if (import.meta.env.DEV && !isLoading) {
reset = true;
messageParser.reset();
}
for (const [index, message] of messages.entries()) {
if (message.role === 'assistant') {
const newParsedContent = messageParser.parse(message.id, message.content);
setParsedMessages((prevParsed) => ({
...prevParsed,
[index]: !reset ? (prevParsed[index] || '') + newParsedContent : newParsedContent,
}));
}
}
}, []);
return { parsedMessages, parseMessages };
}

View file

@ -0,0 +1,86 @@
import { useState } from 'react';
import type { ProviderInfo } from '~/types/model';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('usePromptEnhancement');
export function usePromptEnhancer() {
const [enhancingPrompt, setEnhancingPrompt] = useState(false);
const [promptEnhanced, setPromptEnhanced] = useState(false);
const resetEnhancer = () => {
setEnhancingPrompt(false);
setPromptEnhanced(false);
};
const enhancePrompt = async (
input: string,
setInput: (value: string) => void,
model: string,
provider: ProviderInfo,
apiKeys?: Record<string, string>,
) => {
setEnhancingPrompt(true);
setPromptEnhanced(false);
const requestBody: any = {
message: input,
model,
provider,
};
if (apiKeys) {
requestBody.apiKeys = apiKeys;
}
const response = await fetch('/api/enhancer', {
method: 'POST',
body: JSON.stringify(requestBody),
});
const reader = response.body?.getReader();
const originalInput = input;
if (reader) {
const decoder = new TextDecoder();
let _input = '';
let _error;
try {
setInput('');
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
_input += decoder.decode(value);
logger.trace('Set input', _input);
setInput(_input);
}
} catch (error) {
_error = error;
setInput(originalInput);
} finally {
if (_error) {
logger.error(_error);
}
setEnhancingPrompt(false);
setPromptEnhanced(true);
setTimeout(() => {
setInput(_input);
});
}
}
};
return { enhancingPrompt, promptEnhanced, enhancePrompt, resetEnhancer };
}

View file

@ -0,0 +1,52 @@
import { useState, useMemo, useCallback } from 'react';
import { debounce } from '~/utils/debounce';
import type { ChatHistoryItem } from '~/lib/persistence';
interface UseSearchFilterOptions {
items: ChatHistoryItem[];
searchFields?: (keyof ChatHistoryItem)[];
debounceMs?: number;
}
export function useSearchFilter({
items = [],
searchFields = ['description'],
debounceMs = 300,
}: UseSearchFilterOptions) {
const [searchQuery, setSearchQuery] = useState('');
const debouncedSetSearch = useCallback(debounce(setSearchQuery, debounceMs), []);
const handleSearchChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
debouncedSetSearch(event.target.value);
},
[debouncedSetSearch],
);
const filteredItems = useMemo(() => {
if (!searchQuery.trim()) {
return items;
}
const query = searchQuery.toLowerCase();
return items.filter((item) =>
searchFields.some((field) => {
const value = item[field];
if (typeof value === 'string') {
return value.toLowerCase().includes(query);
}
return false;
}),
);
}, [items, searchQuery, searchFields]);
return {
searchQuery,
filteredItems,
handleSearchChange,
};
}

View file

@ -0,0 +1,100 @@
import { useStore } from '@nanostores/react';
import { isDebugMode, isLocalModelsEnabled, LOCAL_PROVIDERS, providersStore } from '~/lib/stores/settings';
import { useCallback, useEffect, useState } from 'react';
import Cookies from 'js-cookie';
import type { IProviderSetting, ProviderInfo } from '~/types/model';
export function useSettings() {
const providers = useStore(providersStore);
const debug = useStore(isDebugMode);
const isLocalModel = useStore(isLocalModelsEnabled);
const [activeProviders, setActiveProviders] = useState<ProviderInfo[]>([]);
// reading values from cookies on mount
useEffect(() => {
const savedProviders = Cookies.get('providers');
if (savedProviders) {
try {
const parsedProviders: Record<string, IProviderSetting> = JSON.parse(savedProviders);
Object.keys(parsedProviders).forEach((provider) => {
const currentProvider = providers[provider];
providersStore.setKey(provider, {
...currentProvider,
settings: {
...parsedProviders[provider],
enabled: parsedProviders[provider].enabled ?? true,
},
});
});
} catch (error) {
console.error('Failed to parse providers from cookies:', error);
}
}
// load debug mode from cookies
const savedDebugMode = Cookies.get('isDebugEnabled');
if (savedDebugMode) {
isDebugMode.set(savedDebugMode === 'true');
}
// load local models from cookies
const savedLocalModels = Cookies.get('isLocalModelsEnabled');
if (savedLocalModels) {
isLocalModelsEnabled.set(savedLocalModels === 'true');
}
}, []);
// writing values to cookies on change
useEffect(() => {
const providers = providersStore.get();
const providerSetting: Record<string, IProviderSetting> = {};
Object.keys(providers).forEach((provider) => {
providerSetting[provider] = providers[provider].settings;
});
Cookies.set('providers', JSON.stringify(providerSetting));
}, [providers]);
useEffect(() => {
let active = Object.entries(providers)
.filter(([_key, provider]) => provider.settings.enabled)
.map(([_k, p]) => p);
if (!isLocalModel) {
active = active.filter((p) => !LOCAL_PROVIDERS.includes(p.name));
}
setActiveProviders(active);
}, [providers, isLocalModel]);
// helper function to update settings
const updateProviderSettings = useCallback(
(provider: string, config: IProviderSetting) => {
const settings = providers[provider].settings;
providersStore.setKey(provider, { ...providers[provider], settings: { ...settings, ...config } });
},
[providers],
);
const enableDebugMode = useCallback((enabled: boolean) => {
isDebugMode.set(enabled);
Cookies.set('isDebugEnabled', String(enabled));
}, []);
const enableLocalModels = useCallback((enabled: boolean) => {
isLocalModelsEnabled.set(enabled);
Cookies.set('isLocalModelsEnabled', String(enabled));
}, []);
return {
providers,
activeProviders,
updateProviderSettings,
debug,
enableDebugMode,
isLocalModel,
enableLocalModels,
};
}

View file

@ -0,0 +1,59 @@
import { useStore } from '@nanostores/react';
import { useEffect } from 'react';
import { shortcutsStore, type Shortcuts } from '~/lib/stores/settings';
class ShortcutEventEmitter {
#emitter = new EventTarget();
dispatch(type: keyof Shortcuts) {
this.#emitter.dispatchEvent(new Event(type));
}
on(type: keyof Shortcuts, cb: VoidFunction) {
this.#emitter.addEventListener(type, cb);
return () => {
this.#emitter.removeEventListener(type, cb);
};
}
}
export const shortcutEventEmitter = new ShortcutEventEmitter();
export function useShortcuts(): void {
const shortcuts = useStore(shortcutsStore);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent): void => {
const { key, ctrlKey, shiftKey, altKey, metaKey } = event;
for (const name in shortcuts) {
const shortcut = shortcuts[name as keyof Shortcuts];
if (
shortcut.key.toLowerCase() === key.toLowerCase() &&
(shortcut.ctrlOrMetaKey
? ctrlKey || metaKey
: (shortcut.ctrlKey === undefined || shortcut.ctrlKey === ctrlKey) &&
(shortcut.metaKey === undefined || shortcut.metaKey === metaKey)) &&
(shortcut.shiftKey === undefined || shortcut.shiftKey === shiftKey) &&
(shortcut.altKey === undefined || shortcut.altKey === altKey)
) {
shortcutEventEmitter.dispatch(name as keyof Shortcuts);
event.preventDefault();
event.stopPropagation();
shortcut.action();
break;
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [shortcuts]);
}

View file

@ -0,0 +1,52 @@
import { useRef, useCallback } from 'react';
export function useSnapScroll() {
const autoScrollRef = useRef(true);
const scrollNodeRef = useRef<HTMLDivElement>();
const onScrollRef = useRef<() => void>();
const observerRef = useRef<ResizeObserver>();
const messageRef = useCallback((node: HTMLDivElement | null) => {
if (node) {
const observer = new ResizeObserver(() => {
if (autoScrollRef.current && scrollNodeRef.current) {
const { scrollHeight, clientHeight } = scrollNodeRef.current;
const scrollTarget = scrollHeight - clientHeight;
scrollNodeRef.current.scrollTo({
top: scrollTarget,
});
}
});
observer.observe(node);
} else {
observerRef.current?.disconnect();
observerRef.current = undefined;
}
}, []);
const scrollRef = useCallback((node: HTMLDivElement | null) => {
if (node) {
onScrollRef.current = () => {
const { scrollTop, scrollHeight, clientHeight } = node;
const scrollTarget = scrollHeight - clientHeight;
autoScrollRef.current = Math.abs(scrollTop - scrollTarget) <= 10;
};
node.addEventListener('scroll', onScrollRef.current);
scrollNodeRef.current = node;
} else {
if (onScrollRef.current) {
scrollNodeRef.current?.removeEventListener('scroll', onScrollRef.current);
}
scrollNodeRef.current = undefined;
onScrollRef.current = undefined;
}
}, []);
return [messageRef, scrollRef];
}

View file

@ -0,0 +1,18 @@
import { useState, useEffect } from 'react';
const useViewport = (threshold = 1024) => {
const [isSmallViewport, setIsSmallViewport] = useState(window.innerWidth < threshold);
useEffect(() => {
const handleResize = () => setIsSmallViewport(window.innerWidth < threshold);
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, [threshold]);
return isSmallViewport;
};
export default useViewport;