Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
608fb37413 | ||
|
|
ae59c2731d | ||
|
|
6b6df4247f | ||
|
|
83c52a06dd | ||
|
|
ac165a2db4 | ||
|
|
89228719b5 |
No files matched your search
@@ -0,0 +1,23 @@
|
||||
---
|
||||
description: Run ESLint with warnings as errors before pushing a merge request
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# ESLint before push
|
||||
|
||||
Beta deploy runs `npm run build:beta` with `CI=true`. Create React App turns ESLint warnings into build errors. Unit tests do not catch this. The failure shows up only on the deploy runner (`ai-server-4080`), where `CI=true` is set.
|
||||
|
||||
Before `git push` or opening a pull request that changes `llm-fe`:
|
||||
|
||||
1. From `llm-fe`, lint every changed file under `src` with warnings as errors:
|
||||
|
||||
```bash
|
||||
CI=true ./node_modules/.bin/eslint --max-warnings 0 path/to/ChangedFile.tsx
|
||||
```
|
||||
|
||||
2. Fix every warning in those files. Do not push while that command exits non-zero.
|
||||
3. Do not set `DISABLE_ESLINT_PLUGIN` or `CI=false` to get the deploy through.
|
||||
|
||||
A full `eslint src` run reports warnings in files the production build does not compile. Those do not fail deploy. The gate is warnings in files the build compiles.
|
||||
|
||||
`react-hooks/exhaustive-deps` is the usual failure. Wrap the function in `useCallback` and list it in the dependency array. An empty dependency array that calls a component function fails the beta build.
|
||||
Generated
-37
@@ -64,11 +64,9 @@
|
||||
"@types/lodash": "~4.17.13",
|
||||
"@types/react": "^18.3.16",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@types/react-google-recaptcha": "^2.1.9",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-google-recaptcha": "^3.1.0",
|
||||
"typescript": "^4.9.5"
|
||||
}
|
||||
},
|
||||
@@ -6540,15 +6538,6 @@
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-google-recaptcha": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-google-recaptcha/-/react-google-recaptcha-2.1.9.tgz",
|
||||
"integrity": "sha512-nT31LrBDuoSZJN4QuwtQSF3O89FVHC4jLhM+NtKEmVF5R1e8OY0Jo4//x2Yapn2aNHguwgX5doAq8Zo+Ehd0ug==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-syntax-highlighter": {
|
||||
"version": "15.5.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz",
|
||||
@@ -19090,19 +19079,6 @@
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="
|
||||
},
|
||||
"node_modules/react-async-script": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-async-script/-/react-async-script-1.2.0.tgz",
|
||||
"integrity": "sha512-bCpkbm9JiAuMGhkqoAiC0lLkb40DJ0HOEJIku+9JDjxX3Rcs+ztEOG13wbrOskt3n2DTrjshhaQ/iay+SnGg5Q==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"hoist-non-react-statics": "^3.3.0",
|
||||
"prop-types": "^15.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-bootstrap": {
|
||||
"version": "2.10.9",
|
||||
"resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.10.9.tgz",
|
||||
@@ -19301,19 +19277,6 @@
|
||||
"react": ">=16.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-google-recaptcha": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-google-recaptcha/-/react-google-recaptcha-3.1.0.tgz",
|
||||
"integrity": "sha512-cYW2/DWas8nEKZGD7SCu9BSuVz8iOcOLHChHyi7upUuVhkpkhYG/6N3KDiTQ3XAiZ2UAZkfvYKMfAHOzBOcGEg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"prop-types": "^15.5.0",
|
||||
"react-async-script": "^1.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "19.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz",
|
||||
|
||||
@@ -92,11 +92,9 @@
|
||||
"@types/lodash": "~4.17.13",
|
||||
"@types/react": "^18.3.16",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@types/react-google-recaptcha": "^2.1.9",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-google-recaptcha": "^3.1.0",
|
||||
"typescript": "^4.9.5"
|
||||
},
|
||||
"overrides": {
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"><h1>Hesychia is a calm AI assistant.</h1><div id="crawler-prose"><p>Quiet, focused AI chat — private by default — is how this Hesychia product is built.</p><p>It remembers what you told it and your documents, so you never start over, and conversation memory is a setting you control.</p><p>We store your account, your conversations, and the documents you upload.</p><p>We do not sell your data, and we do not use advertising cookies.</p><p>Canceling does not delete documents, and they stay until you delete them or delete the account, and account deletion is a soft-delete.</p><p>It remembers what you told it and your documents, so you never start over.</p><p>Your documents stay stored and searchable, and answers are grounded in your own files.</p><p>Business accounts are simple management for your team, and company managers can manage team accounts.</p><p>Early adopters pay $10 per month, locked for the lifetime of the account, and that price never goes up.</p><p>Image generation is on the roadmap, so a prompt can create a picture inside Hesychia.</p><p>Vision is on the roadmap, so you can ask questions about an image you upload.</p></div><nav aria-label="Related pages"><a href="/pricing/">Hesychia pricing — $10/month, no ads</a><a href="/privacy/">What Hesychia stores and does not sell</a><a href="/changelog/">Dated Hesychia product updates</a><a href="/terms_of_service/">Hesychia terms of service</a><a href="/account-deletion/">How to request Hesychia account deletion</a></nav></div>
|
||||
<div id="root"><h1>Hesychia is a calm AI assistant.</h1><div id="crawler-prose"><p>Quiet, focused AI chat — private by default — is how this Hesychia product is built.</p><p>It remembers what you told it and your documents, so you never start over, and conversation memory is a setting you control.</p><p>We store your account, your conversations, and the documents you upload.</p><p>We do not sell your data, and we do not use advertising cookies.</p><p>Canceling does not delete documents, and they stay until you delete them or delete the account, and account deletion is a soft-delete.</p><p>It remembers what you told it and your documents, so you never start over.</p><p>Your documents stay stored and searchable, and answers are grounded in your own files.</p><p>Business accounts are simple management for your team, and company managers can manage team accounts.</p><p>Early adopters pay $10 per month, locked for the lifetime of the account, and that price never goes up.</p><p>Image generation is on the roadmap, so a prompt can create a picture inside Hesychia.</p><p>Upload an image with your prompt and Hesychia reads the picture, so you can ask about what is in it.</p></div><nav aria-label="Related pages"><a href="/pricing/">Hesychia pricing — $10/month, no ads</a><a href="/privacy/">What Hesychia stores and does not sell</a><a href="/changelog/">Dated Hesychia product updates</a><a href="/terms_of_service/">Hesychia terms of service</a><a href="/account-deletion/">How to request Hesychia account deletion</a></nav></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> Hesychia is a calm AI assistant with quiet, focused AI chat that stays private by default.
|
||||
|
||||
Hesychia is a $10/month AI chat product from AI ML Operations, LLC. Early adopters pay $10 per month, locked for the lifetime of the account, and that price never goes up. It stores your documents so answers can use your files, and company managers can manage team accounts. Conversation memory is a setting you control, and canceling a subscription does not delete documents. Account deletion is a soft-delete, and stored account data may remain. Image generation and vision are on the roadmap.
|
||||
Hesychia is a $10/month AI chat product from AI ML Operations, LLC. Early adopters pay $10 per month, locked for the lifetime of the account, and that price never goes up. It stores your documents so answers can use your files, and company managers can manage team accounts. Conversation memory is a setting you control, and canceling a subscription does not delete documents. Account deletion is a soft-delete, and stored account data may remain. Image generation is on the roadmap. Upload an image with a prompt and Hesychia reads the picture.
|
||||
|
||||
## Pages
|
||||
|
||||
|
||||
@@ -170,6 +170,7 @@ type ConversationDetailCardProps = {
|
||||
activityDetail?: string | null;
|
||||
activityHistory?: ActivityHistoryEntry[];
|
||||
activityInterrupted?: boolean;
|
||||
imageSrc?: string | null;
|
||||
};
|
||||
|
||||
const MyPlot = ({ format, image }: { format: string; image: string }) => {
|
||||
@@ -207,6 +208,7 @@ const ConversationDetailCard = ({
|
||||
activityDetail = null,
|
||||
activityHistory = [],
|
||||
activityInterrupted = false,
|
||||
imageSrc = null,
|
||||
}: ConversationDetailCardProps): JSX.Element => {
|
||||
const CitationMark = useCallback(
|
||||
({ indices }: { indices?: string }) => {
|
||||
@@ -316,6 +318,19 @@ const ConversationDetailCard = ({
|
||||
return (
|
||||
<MessageContainer $isUser={user_created}>
|
||||
<Bubble $isUser={user_created}>
|
||||
{imageSrc && (
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt="Attachment"
|
||||
style={{
|
||||
display: 'block',
|
||||
maxWidth: '100%',
|
||||
maxHeight: 240,
|
||||
borderRadius: 8,
|
||||
marginBottom: '0.75rem',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Markdown
|
||||
className="display-linebreak"
|
||||
style={{ whiteSpace: 'pre-line' }}
|
||||
|
||||
@@ -139,7 +139,7 @@ const ErrorText = styled.p`
|
||||
`;
|
||||
|
||||
const CONTEXT_TOOLTIP =
|
||||
'This will use previous conversations to better customize the experience.';
|
||||
'Follow-ups use earlier messages in this chat. Turn off to answer each message on its own.';
|
||||
|
||||
function prefsErrorMessage(error: unknown, fallback: string): string {
|
||||
const axiosError = error as {
|
||||
@@ -162,7 +162,7 @@ const ThemeSettingsCard = (): JSX.Element => {
|
||||
const themeColors = Object.keys(palettes);
|
||||
|
||||
const [order, setOrder] = useState<boolean>(true);
|
||||
const [useConversationContext, setUseConversationContext] = useState<boolean>(false);
|
||||
const [useConversationContext, setUseConversationContext] = useState<boolean>(true);
|
||||
const [prefsLoading, setPrefsLoading] = useState<boolean>(true);
|
||||
const [contextSaving, setContextSaving] = useState<boolean>(false);
|
||||
const [prefsError, setPrefsError] = useState<string>('');
|
||||
@@ -276,7 +276,7 @@ const ThemeSettingsCard = (): JSX.Element => {
|
||||
|
||||
<SettingRow>
|
||||
<SettingLabel>
|
||||
Use previous conversations
|
||||
Remember this chat
|
||||
<Tooltip title={CONTEXT_TOOLTIP}>
|
||||
<span>
|
||||
<InfoIcon aria-label={CONTEXT_TOOLTIP} />
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { MessageContext, MessageProvider } from './MessageContext';
|
||||
import { WebSocketContext } from './WebSocketContext';
|
||||
import { AccountContext } from './AccountContext';
|
||||
import { ConversationContext } from './ConversationContext';
|
||||
import { ConversationPrompt } from '../data';
|
||||
|
||||
jest.mock('../../axiosApi', () => ({
|
||||
axiosInstance: {
|
||||
get: jest.fn().mockResolvedValue({ data: [] }),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../utils/analytics', () => ({
|
||||
AnalyticsEvents: {
|
||||
CONVERSATION_CREATED: 'Conversation Created',
|
||||
ACTIVITY_STAGE_COMPLETED: 'Activity Stage Completed',
|
||||
},
|
||||
trackEvent: jest.fn(),
|
||||
}));
|
||||
|
||||
const OrderProbe = () => {
|
||||
const ctx = useContext(MessageContext);
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="order">
|
||||
{ctx.conversationDetails.map((detail) => detail.message).join('|')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
ctx.setConversationDetails([
|
||||
new ConversationPrompt({
|
||||
id: 4,
|
||||
message: 'I dont know',
|
||||
user_created: true,
|
||||
created_timestamp: new Date('2020-01-01T00:03:00Z'),
|
||||
}),
|
||||
new ConversationPrompt({
|
||||
id: 2,
|
||||
message: 'Tell me a joke',
|
||||
user_created: true,
|
||||
created_timestamp: new Date('2020-01-01T00:01:00Z'),
|
||||
}),
|
||||
new ConversationPrompt({
|
||||
message: '',
|
||||
user_created: false,
|
||||
created_timestamp: new Date('2020-01-01T00:04:00Z'),
|
||||
}),
|
||||
new ConversationPrompt({
|
||||
id: 3,
|
||||
message: 'meditation ladder',
|
||||
user_created: false,
|
||||
created_timestamp: new Date('2020-01-01T00:02:00Z'),
|
||||
}),
|
||||
])
|
||||
}
|
||||
>
|
||||
seed
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
describe('MessageContext turn order (#136)', () => {
|
||||
it('sorts a scrambled transcript and keeps that order when the reply finishes', async () => {
|
||||
let callback: ((message: string) => void) | null = null;
|
||||
render(
|
||||
<AccountContext.Provider
|
||||
value={{ account: { email: 'test@example.com' } as never, setAccount: () => {} }}
|
||||
>
|
||||
<ConversationContext.Provider
|
||||
value={{
|
||||
conversations: [],
|
||||
setConversations: () => {},
|
||||
selectedConversation: 7,
|
||||
setSelectedConversation: () => {},
|
||||
deleteConversation: () => {},
|
||||
}}
|
||||
>
|
||||
<WebSocketContext.Provider
|
||||
value={
|
||||
[
|
||||
(_channel: string, cb: (message: string) => void) => {
|
||||
callback = cb;
|
||||
},
|
||||
jest.fn(),
|
||||
null,
|
||||
jest.fn(),
|
||||
true,
|
||||
'CONNECTED',
|
||||
0,
|
||||
] as never
|
||||
}
|
||||
>
|
||||
<MessageProvider>
|
||||
<OrderProbe />
|
||||
</MessageProvider>
|
||||
</WebSocketContext.Provider>
|
||||
</ConversationContext.Provider>
|
||||
</AccountContext.Provider>,
|
||||
);
|
||||
await act(async () => {});
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'seed' }).click();
|
||||
});
|
||||
expect(screen.getByTestId('order')).toHaveTextContent(
|
||||
'Tell me a joke|meditation ladder|I dont know|',
|
||||
);
|
||||
|
||||
const send = (message: string) => {
|
||||
act(() => {
|
||||
callback?.(message);
|
||||
});
|
||||
};
|
||||
send('CONVERSATION_ID');
|
||||
send('7');
|
||||
send('START_OF_THE_STREAM_ENDER_GAME_42');
|
||||
send('To help everyone step');
|
||||
send('END_OF_THE_STREAM_ENDER_GAME_42');
|
||||
|
||||
expect(screen.getByTestId('order').textContent).toBe(
|
||||
'Tell me a joke|meditation ladder|I dont know|To help everyone step',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
parseStatusData,
|
||||
parseVersionedFrame,
|
||||
} from "../utils/wsFrames";
|
||||
import { parseChatErrorPayload } from "../utils/chatErrors";
|
||||
import { sortPrompts } from "../utils/promptOrder";
|
||||
|
||||
type MessageProviderProps ={
|
||||
children? : ReactNode;
|
||||
@@ -60,7 +62,7 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
const {selectedConversation, setSelectedConversation} = useContext(ConversationContext);
|
||||
|
||||
const [stateMessage, setStateMessage] = useState<string>('')
|
||||
const [conversationDetails, setConversationDetails] = useState<ConversationPrompt[]>([])
|
||||
const [conversationDetails, setConversationDetailsState] = useState<ConversationPrompt[]>([])
|
||||
const [isGeneratingMessage, setIsGeneratingMessage] = useState<boolean>(false)
|
||||
const [streamInterrupted, setStreamInterrupted] = useState<boolean>(false)
|
||||
const [activityStage, setActivityStage] = useState<string | null>(null)
|
||||
@@ -71,6 +73,11 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
const messageRef = useRef('')
|
||||
const messageResponsePart = useRef(0);
|
||||
const conversationRef = useRef(conversationDetails)
|
||||
const setConversationDetails = useCallback((next: ConversationPrompt[]) => {
|
||||
const ordered = sortPrompts(next)
|
||||
conversationRef.current = ordered
|
||||
setConversationDetailsState(ordered)
|
||||
}, [])
|
||||
const selectedConversationRef = useRef<undefined | number>(undefined)
|
||||
const isGeneratingRef = useRef(false)
|
||||
const prevReconnectGenerationRef = useRef(0)
|
||||
@@ -88,7 +95,7 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
setActivityHistory([])
|
||||
}
|
||||
|
||||
async function GetConversationDetails(conversationId: number | undefined) {
|
||||
const GetConversationDetails = useCallback(async (conversationId: number | undefined) => {
|
||||
if (!conversationId) {
|
||||
setConversationDetails([])
|
||||
return
|
||||
@@ -113,6 +120,8 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
tokens_out: item.tokens_out ?? null,
|
||||
citations: item.citations ?? [],
|
||||
feedback: item.feedback ?? null,
|
||||
file_type: item.file_type ?? null,
|
||||
image: item.image ?? null,
|
||||
}),
|
||||
)
|
||||
if (tempConversations.length === 1) {
|
||||
@@ -121,12 +130,11 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
new ConversationPrompt({ message: '', user_created: false }),
|
||||
)
|
||||
}
|
||||
conversationRef.current = tempConversations
|
||||
setConversationDetails(tempConversations)
|
||||
} catch (err) {
|
||||
console.log('Failed to refetch conversation after reconnect', err)
|
||||
}
|
||||
}
|
||||
}, [setConversationDetails])
|
||||
|
||||
const schedulePostStreamRefetch = useCallback(() => {
|
||||
if (refetchTimerRef.current) clearTimeout(refetchTimerRef.current)
|
||||
@@ -137,11 +145,11 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
refetchTimerRef.current = setTimeout(() => {
|
||||
void GetConversationDetails(conversationId)
|
||||
}, 800)
|
||||
}, [])
|
||||
}, [GetConversationDetails])
|
||||
|
||||
useEffect(() => {
|
||||
GetConversationDetails(selectedConversation)
|
||||
}, [selectedConversation])
|
||||
void GetConversationDetails(selectedConversation)
|
||||
}, [selectedConversation, GetConversationDetails])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -190,9 +198,9 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
}
|
||||
|
||||
if (selectedConversationRef.current) {
|
||||
GetConversationDetails(selectedConversationRef.current)
|
||||
void GetConversationDetails(selectedConversationRef.current)
|
||||
}
|
||||
}, [reconnectGeneration])
|
||||
}, [reconnectGeneration, GetConversationDetails, setConversationDetails])
|
||||
|
||||
useEffect(() => {
|
||||
/* register a consistent channel name for identifing this chat messages */
|
||||
@@ -258,15 +266,14 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
|
||||
if (message === 'END_OF_THE_STREAM_ENDER_GAME_42'){
|
||||
messageResponsePart.current = 0
|
||||
|
||||
conversationRef.current.pop()
|
||||
|
||||
|
||||
const details = [...conversationRef.current]
|
||||
details.pop()
|
||||
const finalized = new ConversationPrompt({
|
||||
message: `${messageRef.current}`,
|
||||
user_created: false,
|
||||
})
|
||||
conversationRef.current = [...conversationRef.current, finalized]
|
||||
setConversationDetails([...conversationRef.current])
|
||||
setConversationDetails([...details, finalized])
|
||||
messageRef.current = ''
|
||||
setStateMessage('')
|
||||
isGeneratingRef.current = false
|
||||
@@ -276,7 +283,9 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
schedulePostStreamRefetch()
|
||||
}
|
||||
else if (message === 'START_OF_THE_STREAM_ENDER_GAME_42'){
|
||||
conversationRef.current = conversationDetails
|
||||
// Do not copy conversationDetails from this closure. A START frame
|
||||
// can arrive on the subscription from before the optimistic turn,
|
||||
// and that copy wiped conversationRef (#136).
|
||||
isGeneratingRef.current = true
|
||||
setIsGeneratingMessage(true)
|
||||
setStreamInterrupted(false)
|
||||
@@ -288,6 +297,22 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
setStreamInterrupted(false)
|
||||
messageResponsePart.current = 1
|
||||
}else{
|
||||
if (messageResponsePart.current === 0) {
|
||||
const err = parseChatErrorPayload(message)
|
||||
if (err?.content) {
|
||||
const details = [...conversationRef.current]
|
||||
const last = details[details.length - 1]
|
||||
if (last && !last.user_created) {
|
||||
details[details.length - 1] = new ConversationPrompt({
|
||||
...last,
|
||||
message,
|
||||
})
|
||||
conversationRef.current = details
|
||||
setConversationDetails(details)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (messageResponsePart.current === 1){
|
||||
isGeneratingRef.current = true
|
||||
setIsGeneratingMessage(true)
|
||||
@@ -316,7 +341,7 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
/* unsubscribe from channel during cleanup */
|
||||
unsubscribe(channelName)
|
||||
}
|
||||
}, [account, subscribe, unsubscribe, conversationDetails, selectedConversation, setSelectedConversation, schedulePostStreamRefetch])
|
||||
}, [account, subscribe, unsubscribe, selectedConversation, setSelectedConversation, schedulePostStreamRefetch, setConversationDetails])
|
||||
|
||||
|
||||
return(
|
||||
|
||||
@@ -29,6 +29,7 @@ describe('ConversationPrompt', () => {
|
||||
expect(prompt.message).toBe('hello');
|
||||
expect(prompt.user_created).toBe(true);
|
||||
expect(prompt.created_timestamp).toEqual(created);
|
||||
expect(prompt.localKey).toBe('id-7');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -18,10 +18,16 @@ export interface ConversationPromptType {
|
||||
tokens_out?: number | null,
|
||||
citations?: Citation[],
|
||||
feedback?: PromptFeedbackState | null,
|
||||
file_type?: string | null,
|
||||
image?: string | null,
|
||||
}
|
||||
|
||||
let localPromptKey = 0;
|
||||
|
||||
export class ConversationPrompt{
|
||||
id: number | undefined;
|
||||
/** Stable React key. Optimistic turns have no server id yet (#136). */
|
||||
localKey: string = '';
|
||||
message: string = '';
|
||||
user_created: boolean = false;
|
||||
created_timestamp: Date = new Date();
|
||||
@@ -29,9 +35,15 @@ export class ConversationPrompt{
|
||||
tokens_out: number | null = null;
|
||||
citations: Citation[] = [];
|
||||
feedback: PromptFeedbackState | null = null;
|
||||
file_type: string | null = null;
|
||||
image: string | null = null;
|
||||
|
||||
constructor(initializer?: any){
|
||||
if(!initializer) return;
|
||||
if(!initializer) {
|
||||
localPromptKey += 1;
|
||||
this.localKey = `local-${localPromptKey}`;
|
||||
return;
|
||||
}
|
||||
if (initializer.id) this.id = initializer.id;
|
||||
if (initializer.message) this.message = initializer.message;
|
||||
if (initializer.user_created) this.user_created = initializer.user_created;
|
||||
@@ -41,6 +53,14 @@ export class ConversationPrompt{
|
||||
if (initializer.tokens_out !== undefined) this.tokens_out = initializer.tokens_out;
|
||||
if (Array.isArray(initializer.citations)) this.citations = initializer.citations;
|
||||
if (initializer.feedback !== undefined) this.feedback = initializer.feedback;
|
||||
if (initializer.file_type) this.file_type = initializer.file_type;
|
||||
if (initializer.image) this.image = initializer.image;
|
||||
if (initializer.localKey) this.localKey = String(initializer.localKey);
|
||||
else if (initializer.id) this.localKey = `id-${initializer.id}`;
|
||||
else {
|
||||
localPromptKey += 1;
|
||||
this.localKey = `local-${localPromptKey}`;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { AnalyticsEvents, trackEvent } from "../../utils/analytics";
|
||||
import { exportChat, type ExportFormat } from "../../utils/export/exportChat";
|
||||
import { showToast } from "../../utils/toastBus";
|
||||
import type { PromptRating } from "../../utils/promptFeedback";
|
||||
import { CHAT_FILE_ACCEPT, imageSrcForPrompt, isImageFile } from "../../utils/visionAttachment";
|
||||
|
||||
// Styled Components
|
||||
const PageContainer = styled.div`
|
||||
@@ -334,6 +335,46 @@ const AlwaysScrollToBottom = ({ trigger }: { trigger: string | number }): JSX.El
|
||||
return <div ref={elementRef} />;
|
||||
};
|
||||
|
||||
const AttachmentPreview = ({ file }: { file: Blob | null }): JSX.Element | null => {
|
||||
const theme = useContext(ThemeContext);
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!file || !isImageFile(file)) {
|
||||
setUrl(null);
|
||||
return;
|
||||
}
|
||||
const next = URL.createObjectURL(file);
|
||||
setUrl(next);
|
||||
return () => URL.revokeObjectURL(next);
|
||||
}, [file]);
|
||||
if (url) {
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
style={{
|
||||
display: "block",
|
||||
margin: "0.5rem auto 0",
|
||||
maxHeight: 120,
|
||||
maxWidth: "100%",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (!file) return null;
|
||||
return (
|
||||
<div style={{
|
||||
marginTop: "0.5rem",
|
||||
fontSize: "0.8rem",
|
||||
color: theme?.darkMode ? "rgba(255,255,255,0.6)" : "rgba(0,0,0,0.6)",
|
||||
textAlign: "center",
|
||||
}}>
|
||||
Attached: {file instanceof File ? file.name : "attachment"}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AsyncDashboardInner = (): JSX.Element => {
|
||||
const [, , , sendMessage, isConnected, connectionStatus] =
|
||||
useContext(WebSocketContext);
|
||||
@@ -432,9 +473,15 @@ const AsyncDashboardInner = (): JSX.Element => {
|
||||
}
|
||||
try {
|
||||
clearActivity();
|
||||
const attachedImage = file && isImageFile(file) ? URL.createObjectURL(file) : null;
|
||||
const tempConversations: ConversationPrompt[] = [
|
||||
...conversationDetails,
|
||||
new ConversationPrompt({ message: trimmedPrompt, user_created: true }),
|
||||
new ConversationPrompt({
|
||||
message: trimmedPrompt,
|
||||
user_created: true,
|
||||
image: attachedImage,
|
||||
file_type: fileType,
|
||||
}),
|
||||
new ConversationPrompt({ message: "", user_created: false }),
|
||||
];
|
||||
|
||||
@@ -457,6 +504,8 @@ const AsyncDashboardInner = (): JSX.Element => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
}
|
||||
const fileInput = document.getElementById("file-upload") as HTMLInputElement | null;
|
||||
if (fileInput) fileInput.value = "";
|
||||
} catch (e) {
|
||||
console.log(`error ${e}`);
|
||||
}
|
||||
@@ -573,7 +622,7 @@ const AsyncDashboardInner = (): JSX.Element => {
|
||||
: convo_detail.message;
|
||||
return (
|
||||
<ConversationDetailCard
|
||||
key={convo_detail.id || index}
|
||||
key={convo_detail.localKey}
|
||||
message={displayMessage}
|
||||
user_created={convo_detail.user_created}
|
||||
promptId={convo_detail.id}
|
||||
@@ -591,6 +640,7 @@ const AsyncDashboardInner = (): JSX.Element => {
|
||||
activityDetail={isLiveStream ? activityDetail : null}
|
||||
activityHistory={isLiveStream ? activityHistory : []}
|
||||
activityInterrupted={isLiveStream && streamInterrupted}
|
||||
imageSrc={imageSrcForPrompt(convo_detail.image, convo_detail.file_type)}
|
||||
/>
|
||||
);
|
||||
})
|
||||
@@ -652,7 +702,7 @@ const AsyncDashboardInner = (): JSX.Element => {
|
||||
<VisuallyHiddenInput
|
||||
id="file-upload"
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.txt,.pdf,.PDF"
|
||||
accept={CHAT_FILE_ACCEPT}
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
@@ -703,16 +753,7 @@ const AsyncDashboardInner = (): JSX.Element => {
|
||||
</span>
|
||||
</Tooltip>
|
||||
</StyledInputContainer>
|
||||
{formik.values.file && (
|
||||
<div style={{
|
||||
marginTop: '0.5rem',
|
||||
fontSize: '0.8rem',
|
||||
color: theme?.darkMode ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.6)',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
Attached: {(formik.values.file as File).name}
|
||||
</div>
|
||||
)}
|
||||
<AttachmentPreview file={formik.values.file} />
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
|
||||
@@ -52,7 +52,7 @@ describe('LandingPage', () => {
|
||||
expect(screen.getByRole('heading', { name: 'Business accounts' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Roadmap' })).toBeInTheDocument();
|
||||
expect(screen.getByText(/Image generation is on the roadmap/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Vision is on the roadmap/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Hesychia reads the picture/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Early adopters pay \$10 per month, locked for the lifetime of the account/i),
|
||||
).toBeInTheDocument();
|
||||
|
||||
@@ -3,29 +3,13 @@ import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import PasswordReset from './PasswordReset';
|
||||
import { RECAPTCHA_ACTION, RECAPTCHA_SITE_KEY } from './recaptchaV3';
|
||||
|
||||
const mockPost = jest.fn();
|
||||
const mockExecuteAsync = jest.fn();
|
||||
const mockReset = jest.fn();
|
||||
const mockExecute = jest.fn();
|
||||
|
||||
jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null);
|
||||
|
||||
jest.mock('react-google-recaptcha', () => {
|
||||
const ReactLib = require('react');
|
||||
return ReactLib.forwardRef(
|
||||
(
|
||||
_props: unknown,
|
||||
ref: React.Ref<{ executeAsync: () => Promise<string>; reset: () => void }>
|
||||
) => {
|
||||
ReactLib.useImperativeHandle(ref, () => ({
|
||||
executeAsync: mockExecuteAsync,
|
||||
reset: mockReset,
|
||||
}));
|
||||
return <div data-testid="recaptcha" />;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
jest.mock('../../../axiosApi', () => ({
|
||||
cleanAxiosInstance: {
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
@@ -48,13 +32,23 @@ const renderPasswordReset = () =>
|
||||
describe('PasswordReset', () => {
|
||||
beforeEach(() => {
|
||||
mockPost.mockReset();
|
||||
mockExecuteAsync.mockReset();
|
||||
mockReset.mockReset();
|
||||
mockExecuteAsync.mockResolvedValue('captcha-token');
|
||||
mockExecute.mockReset();
|
||||
mockExecute.mockResolvedValue('captcha-token');
|
||||
mockPost.mockResolvedValue({ status: 200 });
|
||||
window.grecaptcha = {
|
||||
ready: (callback) => callback(),
|
||||
execute: mockExecute,
|
||||
};
|
||||
});
|
||||
|
||||
it('submits email with captcha token and navigates to confirmation', async () => {
|
||||
afterEach(() => {
|
||||
delete window.grecaptcha;
|
||||
document
|
||||
.querySelectorAll('script[data-recaptcha-v3="true"]')
|
||||
.forEach((node) => node.remove());
|
||||
});
|
||||
|
||||
it('submits email with a v3 captcha token and navigates to confirmation', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPasswordReset();
|
||||
|
||||
@@ -62,6 +56,9 @@ describe('PasswordReset', () => {
|
||||
await user.click(screen.getByRole('button', { name: 'Reset Password' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockExecute).toHaveBeenCalledWith(RECAPTCHA_SITE_KEY, {
|
||||
action: RECAPTCHA_ACTION,
|
||||
});
|
||||
expect(mockPost).toHaveBeenCalledWith('user/reset_password/', {
|
||||
email: 'user@example.com',
|
||||
recaptchaToken: 'captcha-token',
|
||||
@@ -70,6 +67,20 @@ describe('PasswordReset', () => {
|
||||
expect(await screen.findByText('Confirmation page')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error when captcha fails and does not post', async () => {
|
||||
mockExecute.mockRejectedValue(new Error('invalid site key'));
|
||||
const user = userEvent.setup();
|
||||
renderPasswordReset();
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Email Address'), 'user@example.com');
|
||||
await user.click(screen.getByRole('button', { name: 'Reset Password' }));
|
||||
|
||||
expect(
|
||||
await screen.findByText('Unable to reset password. Please try again.')
|
||||
).toBeInTheDocument();
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows error when request fails', async () => {
|
||||
mockPost.mockRejectedValue(new Error('network'));
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Form, Formik, Field } from 'formik';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { cleanAxiosInstance } from '../../../axiosApi';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import ReCAPTCHA from 'react-google-recaptcha';
|
||||
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
|
||||
import styled from 'styled-components';
|
||||
import * as Yup from 'yup';
|
||||
import { executePasswordResetCaptcha, loadRecaptcha } from './recaptchaV3';
|
||||
|
||||
const PageContainer = styled.div`
|
||||
position: relative;
|
||||
@@ -139,20 +139,20 @@ const validationSchema = Yup.object().shape({
|
||||
|
||||
const PasswordReset = (): JSX.Element => {
|
||||
const navigate = useNavigate();
|
||||
const recaptchaRef = useRef<ReCAPTCHA>(null);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadRecaptcha().catch(() => {
|
||||
// Submit reports a failure if the v3 script never becomes ready.
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handlePasswordResetEmail = async ({
|
||||
email,
|
||||
}: EmailPasswordResetValues): Promise<void> => {
|
||||
setErrorMessage('');
|
||||
try {
|
||||
let token = '';
|
||||
if (recaptchaRef.current) {
|
||||
token = (await recaptchaRef.current.executeAsync()) || '';
|
||||
recaptchaRef.current.reset();
|
||||
}
|
||||
|
||||
const token = await executePasswordResetCaptcha();
|
||||
await cleanAxiosInstance.post('user/reset_password/', {
|
||||
email,
|
||||
recaptchaToken: token,
|
||||
@@ -186,14 +186,6 @@ const PasswordReset = (): JSX.Element => {
|
||||
placeholder="Email Address"
|
||||
type="email"
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<ReCAPTCHA
|
||||
ref={recaptchaRef}
|
||||
sitekey="6LfENu4qAAAAAFtPejcrP3dwBDxcRPjqi7RhytJJ"
|
||||
size="invisible"
|
||||
theme="dark"
|
||||
/>
|
||||
</div>
|
||||
<StyledButton type="submit" disabled={formik.isSubmitting}>
|
||||
Reset Password
|
||||
</StyledButton>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { RECAPTCHA_ACTION, RECAPTCHA_SITE_KEY, executePasswordResetCaptcha, loadRecaptcha } from './recaptchaV3';
|
||||
|
||||
describe('recaptchaV3', () => {
|
||||
afterEach(() => {
|
||||
delete window.grecaptcha;
|
||||
document
|
||||
.querySelectorAll('script[data-recaptcha-v3="true"]')
|
||||
.forEach((node) => node.remove());
|
||||
});
|
||||
|
||||
it('loads the v3 script once when grecaptcha is missing', async () => {
|
||||
const pending = loadRecaptcha();
|
||||
const again = loadRecaptcha();
|
||||
const scripts = document.querySelectorAll('script[data-recaptcha-v3="true"]');
|
||||
|
||||
expect(scripts).toHaveLength(1);
|
||||
expect(scripts[0]).toHaveAttribute(
|
||||
'src',
|
||||
`https://www.google.com/recaptcha/api.js?render=${RECAPTCHA_SITE_KEY}`
|
||||
);
|
||||
|
||||
window.grecaptcha = {
|
||||
ready: (callback) => callback(),
|
||||
execute: jest.fn(),
|
||||
};
|
||||
scripts[0].dispatchEvent(new Event('load'));
|
||||
await expect(pending).resolves.toBeUndefined();
|
||||
await expect(again).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('executes the password_reset action', async () => {
|
||||
const execute = jest.fn().mockResolvedValue('token-1');
|
||||
window.grecaptcha = {
|
||||
ready: (callback) => callback(),
|
||||
execute,
|
||||
};
|
||||
|
||||
await expect(executePasswordResetCaptcha()).resolves.toBe('token-1');
|
||||
expect(execute).toHaveBeenCalledWith(RECAPTCHA_SITE_KEY, {
|
||||
action: RECAPTCHA_ACTION,
|
||||
});
|
||||
expect(document.querySelector('script[data-recaptcha-v3="true"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an empty token', async () => {
|
||||
window.grecaptcha = {
|
||||
ready: (callback) => callback(),
|
||||
execute: jest.fn().mockResolvedValue(''),
|
||||
};
|
||||
|
||||
await expect(executePasswordResetCaptcha()).rejects.toThrow('empty reCAPTCHA token');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
export const RECAPTCHA_SITE_KEY = '6LfENu4qAAAAAFtPejcrP3dwBDxcRPjqi7RhytJJ';
|
||||
export const RECAPTCHA_ACTION = 'password_reset';
|
||||
|
||||
type Grecaptcha = {
|
||||
ready: (callback: () => void) => void;
|
||||
execute: (siteKey: string, options: { action: string }) => Promise<string>;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
grecaptcha?: Grecaptcha;
|
||||
}
|
||||
}
|
||||
|
||||
let loading: Promise<void> | null = null;
|
||||
|
||||
export function loadRecaptcha(siteKey: string = RECAPTCHA_SITE_KEY): Promise<void> {
|
||||
if (window.grecaptcha) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (loading && document.querySelector('script[data-recaptcha-v3="true"]')) {
|
||||
return loading;
|
||||
}
|
||||
|
||||
loading = new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = `https://www.google.com/recaptcha/api.js?render=${encodeURIComponent(siteKey)}`;
|
||||
script.async = true;
|
||||
script.dataset.recaptchaV3 = 'true';
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => {
|
||||
loading = null;
|
||||
script.remove();
|
||||
reject(new Error('reCAPTCHA failed to load'));
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
return loading;
|
||||
}
|
||||
|
||||
export async function executePasswordResetCaptcha(
|
||||
siteKey: string = RECAPTCHA_SITE_KEY
|
||||
): Promise<string> {
|
||||
await loadRecaptcha(siteKey);
|
||||
const grecaptcha = window.grecaptcha;
|
||||
if (!grecaptcha) {
|
||||
throw new Error('reCAPTCHA unavailable');
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
grecaptcha.ready(resolve);
|
||||
});
|
||||
|
||||
const token = await grecaptcha.execute(siteKey, { action: RECAPTCHA_ACTION });
|
||||
if (!token) {
|
||||
throw new Error('empty reCAPTCHA token');
|
||||
}
|
||||
return token;
|
||||
}
|
||||
@@ -27,7 +27,7 @@ describe('public crawler files', () => {
|
||||
expect(sitemap.startsWith('<?xml')).toBe(true);
|
||||
expect(llms).toContain('$10/month');
|
||||
expect(llms).toContain('locked for the lifetime of the account');
|
||||
expect(llms).toContain('Image generation and vision are on the roadmap');
|
||||
expect(llms).toContain('Upload an image with a prompt and Hesychia reads the picture');
|
||||
expect(llms).not.toContain('<html');
|
||||
|
||||
expect(html).toContain('<title>Hesychia — quiet, focused AI chat</title>');
|
||||
@@ -56,7 +56,9 @@ describe('public crawler files', () => {
|
||||
expect(html).toContain(
|
||||
'Image generation is on the roadmap, so a prompt can create a picture inside Hesychia.',
|
||||
);
|
||||
expect(html).toContain('Vision is on the roadmap, so you can ask questions about an image you upload.');
|
||||
expect(html).toContain(
|
||||
'Upload an image with your prompt and Hesychia reads the picture, so you can ask about what is in it.',
|
||||
);
|
||||
expect(html).not.toContain('>here<');
|
||||
expect(html.match(/application\/ld\+json/g)).toHaveLength(1);
|
||||
expect(html).toContain('"@type": "WebPage"');
|
||||
|
||||
@@ -32,7 +32,7 @@ const HOME = {
|
||||
roadmapImage:
|
||||
'Image generation is on the roadmap, so a prompt can create a picture inside Hesychia.',
|
||||
roadmapVision:
|
||||
'Vision is on the roadmap, so you can ask questions about an image you upload.',
|
||||
'Upload an image with your prompt and Hesychia reads the picture, so you can ask about what is in it.',
|
||||
};
|
||||
|
||||
const PRICING = {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { ConversationPrompt } from '../data';
|
||||
import { sortPrompts } from './promptOrder';
|
||||
|
||||
describe('sortPrompts', () => {
|
||||
it('orders by created time, not the array the server happened to return', () => {
|
||||
const prompts = [
|
||||
new ConversationPrompt({
|
||||
id: 4,
|
||||
message: 'I dont know',
|
||||
user_created: true,
|
||||
created_timestamp: new Date('2026-10-25T12:03:00Z'),
|
||||
}),
|
||||
new ConversationPrompt({
|
||||
id: 2,
|
||||
message: 'Tell me a joke',
|
||||
user_created: true,
|
||||
created_timestamp: new Date('2026-10-25T12:01:00Z'),
|
||||
}),
|
||||
new ConversationPrompt({
|
||||
id: 5,
|
||||
message: 'form errors',
|
||||
user_created: false,
|
||||
created_timestamp: new Date('2026-10-25T12:04:00Z'),
|
||||
}),
|
||||
new ConversationPrompt({
|
||||
id: 3,
|
||||
message: 'meditation ladder',
|
||||
user_created: false,
|
||||
created_timestamp: new Date('2026-10-25T12:02:00Z'),
|
||||
}),
|
||||
];
|
||||
|
||||
expect(sortPrompts(prompts).map((prompt) => prompt.message)).toEqual([
|
||||
'Tell me a joke',
|
||||
'meditation ladder',
|
||||
'I dont know',
|
||||
'form errors',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a same-millisecond user turn ahead of the empty assistant placeholder', () => {
|
||||
const stamp = new Date('2026-10-25T12:05:00Z');
|
||||
const prompts = [
|
||||
new ConversationPrompt({
|
||||
message: '',
|
||||
user_created: false,
|
||||
created_timestamp: stamp,
|
||||
}),
|
||||
new ConversationPrompt({
|
||||
message: 'I dont know',
|
||||
user_created: true,
|
||||
created_timestamp: stamp,
|
||||
}),
|
||||
];
|
||||
|
||||
expect(sortPrompts(prompts).map((prompt) => prompt.user_created)).toEqual([
|
||||
true,
|
||||
false,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ConversationPrompt } from '../data';
|
||||
|
||||
function timeMs(prompt: ConversationPrompt): number {
|
||||
const stamp = prompt.created_timestamp;
|
||||
const ms = stamp instanceof Date ? stamp.getTime() : NaN;
|
||||
return Number.isNaN(ms) ? 0 : ms;
|
||||
}
|
||||
|
||||
/** Oldest first. Same instant: lower id, then the user turn before the assistant placeholder. */
|
||||
export function comparePrompts(a: ConversationPrompt, b: ConversationPrompt): number {
|
||||
const dt = timeMs(a) - timeMs(b);
|
||||
if (dt !== 0) return dt;
|
||||
const ia = a.id ?? Number.MAX_SAFE_INTEGER;
|
||||
const ib = b.id ?? Number.MAX_SAFE_INTEGER;
|
||||
if (ia !== ib) return ia - ib;
|
||||
if (a.user_created !== b.user_created) return a.user_created ? -1 : 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function sortPrompts(prompts: ConversationPrompt[]): ConversationPrompt[] {
|
||||
return [...prompts].sort(comparePrompts);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { imageSrcForPrompt, isImageFile } from './visionAttachment';
|
||||
|
||||
describe('visionAttachment', () => {
|
||||
it('builds a data url from stored image bytes', () => {
|
||||
expect(imageSrcForPrompt('abc', 'png')).toBe('data:image/png;base64,abc');
|
||||
expect(imageSrcForPrompt('abc', 'jpeg')).toBe('data:image/jpeg;base64,abc');
|
||||
});
|
||||
|
||||
it('keeps blob and data urls', () => {
|
||||
expect(imageSrcForPrompt('blob:http://local/1', 'png')).toBe('blob:http://local/1');
|
||||
expect(imageSrcForPrompt('data:image/png;base64,abc', 'png')).toBe(
|
||||
'data:image/png;base64,abc',
|
||||
);
|
||||
});
|
||||
|
||||
it('detects image files', () => {
|
||||
expect(isImageFile(new File(['x'], 'a.png', { type: 'image/png' }))).toBe(true);
|
||||
expect(isImageFile(new File(['x'], 'a.pdf', { type: 'application/pdf' }))).toBe(false);
|
||||
expect(isImageFile(new Blob(['x'], { type: 'image/png' }))).toBe(true);
|
||||
expect(isImageFile(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
export const CHAT_FILE_ACCEPT =
|
||||
'.csv,.xlsx,.txt,.pdf,.PDF,image/png,image/jpeg,image/webp,image/gif';
|
||||
|
||||
export function isImageFile(file: Blob | null | undefined): boolean {
|
||||
return Boolean(file && file.type.startsWith('image/'));
|
||||
}
|
||||
|
||||
/** Preview URL or data URL for a stored prompt image. */
|
||||
export function imageSrcForPrompt(
|
||||
image?: string | null,
|
||||
fileType?: string | null,
|
||||
): string | null {
|
||||
if (!image) return null;
|
||||
if (image.startsWith('blob:') || image.startsWith('data:')) return image;
|
||||
const raw = (fileType || 'png').toLowerCase();
|
||||
const mime = raw.startsWith('image/')
|
||||
? raw
|
||||
: `image/${raw === 'jpg' ? 'jpeg' : raw}`;
|
||||
return `data:${mime};base64,${image}`;
|
||||
}
|
||||
Reference in new issue
Block a user