Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add thinking bubble #176

Merged
merged 3 commits into from
Jan 22, 2025
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
39 changes: 36 additions & 3 deletions src/components/MarkdownView/MarkdownView.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import {View} from 'react-native';
import {View, Text} from 'react-native';
import React, {useMemo} from 'react';

import {marked} from 'marked';
import RenderHtml, {defaultSystemFonts} from 'react-native-render-html';
import RenderHtml, {
defaultSystemFonts,
HTMLContentModel,
HTMLElementModel,
} from 'react-native-render-html';

import {useTheme} from '../../hooks';

import {createTagsStyles} from './styles';
import {createTagsStyles, createStyles} from './styles';

marked.use({
langPrefix: 'language-',
Expand All @@ -21,12 +25,39 @@ interface MarkdownViewProps {
selectable?: boolean;
}

const ThinkRenderer = ({TDefaultRenderer, ...props}, styles) => (
<View style={styles.thinkContainer}>
<View style={styles.thinkTextContainer}>
<Text style={styles.thinkText}>💭 Thinking...</Text>
</View>
<TDefaultRenderer {...props} />
</View>
);

export const MarkdownView: React.FC<MarkdownViewProps> = React.memo(
({markdownText, maxMessageWidth, selectable = false}) => {
const _maxWidth = maxMessageWidth;

const theme = useTheme();
const tagsStyles = useMemo(() => createTagsStyles(theme), [theme]);
const styles = createStyles(theme);

const customHTMLElementModels = useMemo(
() => ({
think: HTMLElementModel.fromCustomModel({
tagName: 'think',
contentModel: HTMLContentModel.block,
}),
}),
[],
);

const renderers = useMemo(
() => ({
think: props => ThinkRenderer(props, styles),
}),
[styles],
);

const defaultTextProps = useMemo(
() => ({
Expand All @@ -50,6 +81,8 @@ export const MarkdownView: React.FC<MarkdownViewProps> = React.memo(
tagsStyles={tagsStyles}
defaultTextProps={defaultTextProps}
systemFonts={systemFonts}
customHTMLElementModels={customHTMLElementModels}
renderers={renderers}
/>
</View>
);
Expand Down
30 changes: 25 additions & 5 deletions src/components/MarkdownView/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,28 @@ export const createTagsStyles = (theme: Theme) => ({
},
});

export const styles = StyleSheet.create({
container: {
flex: 1,
},
});
export const createStyles = (theme: Theme) =>
StyleSheet.create({
container: {
flex: 1,
},
thinkContainer: {
backgroundColor: theme.colors.surface,
borderRadius: 8,
padding: 12,
marginVertical: 8,
borderLeftWidth: 4,
borderLeftColor: theme.colors.primary,
opacity: 0.8,
},
thinkText: {
color: theme.colors.primary,
fontWeight: 'bold',
marginRight: 8,
},
thinkTextContainer: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 4,
},
});
40 changes: 30 additions & 10 deletions src/store/ModelStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,16 +423,15 @@ class ModelStore {

// Don't mark as downloaded if currently downloading
if (exists && !this.downloadJobs.has(model.id)) {
// Only calculate hash if it's not already stored. Hash calculation is expensive.
if (!model.hash) {
const hash = await getSHA256Hash(filePath);
if (!model.isDownloaded) {
console.log(
'checkFileExists: marking as downloaded - this should not happen:',
model.id,
);
runInAction(() => {
model.hash = hash;
model.isDownloaded = true;
});
}
runInAction(() => {
model.isDownloaded = true;
});
} else {
runInAction(() => {
model.isDownloaded = false;
Expand Down Expand Up @@ -561,12 +560,14 @@ class ModelStore {

const result = await ret.promise;
if (result.statusCode === 200) {
// Calculate hash after successful download
const hash = await getSHA256Hash(downloadDest);
// We removed hash check for now, as it's not reliable and expensive..
// It is done only if file size match fails.
// // Calculate hash after successful download
// const hash = await getSHA256Hash(downloadDest);

runInAction(() => {
model.progress = 100;
model.hash = hash;
//model.hash = hash;
model.isDownloaded = true; // Only mark as downloaded here
});

Expand Down Expand Up @@ -1073,6 +1074,25 @@ class ModelStore {
console.error('Failed to fetch model file details:', error);
}
}

// Expensive operation.
// It will be calculating hash if hash is not set, unless force is true.
updateModelHash = async (modelId: string, force: boolean = false) => {
const model = this.models.find(m => m.id === modelId);

// We only update hash if the model is downloaded and not currently being downloaded.
if (model?.isDownloaded && !this.downloadJobs.has(modelId)) {
// If not forced, we only update hash if it's not already set.
if (model.hash && !force) {
return;
}
const filePath = await this.getModelFullPath(model);
const hash = await getSHA256Hash(filePath);
runInAction(() => {
model.hash = hash;
});
}
};
}

export const modelStore = new ModelStore();
93 changes: 57 additions & 36 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,7 @@ export function hfAsModel(
}
export const randId = () => Math.random().toString(36).substring(2, 11);

// There is a an issue with RNFS.hash: https://github.com/birdofpreyru/react-native-fs/issues/99
export const getSHA256Hash = async (filePath: string): Promise<string> => {
try {
const hash = await RNFS.hash(filePath, 'sha256');
Expand All @@ -506,9 +507,8 @@ export const getSHA256Hash = async (filePath: string): Promise<string> => {
};

/**
* Checks if a model's file integrity is valid by comparing its hash with the expected hash from HuggingFace.
* For HF models, it will automatically fetch missing file details if needed.
* We assume lfs.oid is the hash of the file.
* Checks if a model's file integrity is valid by comparing file size. Hash doesn't seem to be reliable, and expensive.
* see: https://github.com/birdofpreyru/react-native-fs/issues/99
* @param model - The model to check integrity for
* @param modelStore - The model store instance for updating model details
* @returns An object containing the integrity check result and any error message
Expand All @@ -520,45 +520,66 @@ export const checkModelFileIntegrity = async (
isValid: boolean;
errorMessage: string | null;
}> => {
if (!model.hash) {
// Unsure if this is needed. As modelstore will fetch the details if needed.
return {
isValid: true,
errorMessage: null,
};
}
try {
// For HF models, if we don't have lfs details, fetch them
if (model.origin === ModelOrigin.HF && !model.hfModelFile?.lfs?.size) {
await modelStore.fetchAndUpdateModelFileDetails(model);
}

// For HF models, if we don't have lfs.oid, fetch it
if (model.origin === ModelOrigin.HF && !model.hfModelFile?.lfs?.oid) {
await modelStore.fetchAndUpdateModelFileDetails(model);
}
const filePath = await modelStore.getModelFullPath(model);
const fileStats = await RNFS.stat(filePath);

// If we have expected file size from HF, compare it
if (model.hfModelFile?.lfs?.size) {
const expectedSize = model.hfModelFile.lfs.size;
const actualSize = fileStats.size;

// Calculate size difference ratio
const sizeDiffPercentage =
Math.abs(actualSize - expectedSize) / expectedSize;

// If size difference is more than 0.1% and hash doesn't match, consider it corrupted
if (sizeDiffPercentage > 0.001) {
modelStore.updateModelHash(model.id, false);

// If hash matches, consider it valid
if (model.hash && model.hfModelFile?.lfs?.oid) {
if (model.hash === model.hfModelFile.lfs.oid) {
return {
isValid: true,
errorMessage: null,
};
}
}

if (model.hash && model.hfModelFile?.lfs?.oid) {
if (model.hash !== model.hfModelFile.lfs.oid) {
try {
const filePath = await modelStore.getModelFullPath(model);
const fileStats = await RNFS.stat(filePath);
const actualSize = formatBytes(fileStats.size, 2);
const expectedSize = formatBytes(model.hfModelFile.lfs.size, 2);
return {
isValid: false,
errorMessage:
`Model file corrupted (${actualSize} vs ${expectedSize}). ` +
'Please delete and redownload.',
};
} catch (error) {
console.error('Error getting file size:', error);
// If hash doesn't match and file size doesn't match, consider it corrupted
return {
isValid: false,
errorMessage:
'Model file corrupted. Please delete and redownload the model.',
errorMessage: `Model file size mismatch (${formatBytes(
actualSize,
)} vs ${formatBytes(expectedSize)}). Please delete and redownload.`,
};
}

// File size matches within tolerance, consider it valid
return {
isValid: true,
errorMessage: null,
};
}
}

return {
isValid: true,
errorMessage: null,
};
// If we reach here, either:
// 1. We don't have size/hash info to verify against
// 2. The file passed all available integrity checks
return {
isValid: true,
errorMessage: null,
};
} catch (error) {
console.error('Error checking file integrity:', error);
return {
isValid: false,
errorMessage: 'Error checking file integrity. Please try again.',
};
}
};
Loading