Author SHA1 Message Date
westfarn 608fb37413 Fix beta build: satisfy MessageContext hook deps (#140)
Deploy Beta / unit-tests (push) Successful in 12s
Unit Tests / test (push) Successful in 12s
Deploy Beta / deploy-beta (push) Successful in 3m22s
## Summary
- Closes #139.
- [Deploy Beta run 1025](https://git.aimloperations.com/ai_ml_operations/chat_web_app/actions/runs/1025/jobs/1743) failed `npm run build:beta` on `ai-server-4080` because `CI=true` turns `react-hooks/exhaustive-deps` warnings into errors.
- `GetConversationDetails` is a `useCallback` and is listed on the post-stream refetch, the selected-conversation effect, and the reconnect effect. `setConversationDetails` stays stable, so those effects still run only when their real inputs change.
- Adds a project rule to lint changed `llm-fe` files with warnings as errors before pushing a merge request.

## Test plan
- [x] `CI=true ./node_modules/.bin/eslint --max-warnings 0 src/llm-fe/contexts/MessageContext.tsx`
- [x] `MessageContext.test.tsx` and `MessageContext.order.test.tsx` (8 passed)
- [ ] Deploy Beta `build:beta` succeeds on `ai-server-4080` after merge

Reviewed-on: #140
2026-10-25 06:20:00 -07:00
westfarn ae59c2731d Keep chat turns in the order they were sent (#138)
Deploy Beta / unit-tests (push) Successful in 12s
Unit Tests / test (push) Successful in 12s
Deploy Beta / deploy-beta (push) Failing after 3m29s
## Summary
- Closes #136.
- Chat turns are sorted oldest-first by `created`, then id, whenever they are written into state. A scrambled `conversation_details` payload no longer redraws the thread backwards after the reply lands.
- `START_OF_THE_STREAM` no longer copies the websocket closure into the message ref. That copy could drop the optimistic turn and let `END` rebuild the thread from the previous list.
- Optimistic bubbles use a stable local key so they do not collide with a server id.
- The account toggle is labeled "Remember this chat" and defaults on, matching chat_backend#84.

## Test plan
- [ ] `CI=true npm test -- --watchAll=false --testPathPattern='promptOrder|MessageContext|data.test'`
- [ ] Ask for a joke, then "I don't know, why?". While it works, order stays: joke request, joke, follow-up, status. After the reply, the follow-up stays under the joke and the new answer is last, with the action row on that answer.
- [ ] Reload the conversation. Order stays the same.
- [ ] Send another follow-up. It appends at the bottom.
- [ ] Needs chat_backend#84 (or that branch) for the reply to actually use the previous turn.

Reviewed-on: #138
2026-10-25 06:05:28 -07:00
westfarn 6b6df4247f Use reCAPTCHA v3 on password reset (#135)
Deploy Beta / unit-tests (push) Successful in 14s
Unit Tests / test (push) Successful in 12s
Deploy Beta / deploy-beta (push) Successful in 4m0s
## Summary
- Closes #134.
- Password reset loads reCAPTCHA v3 and sends `grecaptcha.execute(siteKey, { action: 'password_reset' })` as `recaptchaToken`.
- Removes `react-google-recaptcha`. That package is a v2 widget, and the Hesychia key is v3, so Google rejected the site key on https://hesychia.ai/password_reset/ before submit.
- Site key stays `6LfENu4qAAAAAFtPejcrP3dwBDxcRPjqi7RhytJJ`. The backend already checks the v3 token with `CAPTCHA_SECRET_KEY` and rejects a score under 0.5.

## Test plan
- [x] `CI=true npm test -- --watchAll=false --coverage=false --testPathPattern=pages/PasswordReset` (6 passed)
- [ ] After deploy, open https://hesychia.ai/password_reset/ and confirm the invalid reCAPTCHA error is gone
- [ ] Submit a real account email and confirm the reset email sends
- [ ] Submit again with a bad or blocked client and confirm the page shows "Unable to reset password"

Reviewed-on: #135
2026-10-23 03:51:15 -07:00
westfarn 83c52a06dd Drop the word image from the chat attachment alt (#133)
Deploy Beta / unit-tests (push) Successful in 12s
Unit Tests / test (push) Successful in 12s
Deploy Beta / deploy-beta (push) Successful in 3m22s
## Summary
- Closes #132.
- [Deploy Beta run 932](https://git.aimloperations.com/ai_ml_operations/chat_web_app/actions/runs/932/jobs/1572) failed `npm run build:beta` on ai-server-4080 because `CI=true` turns the `jsx-a11y/img-redundant-alt` warning into a compile error.
- The chat attachment `alt` is now `Attachment`, so the word `image` is gone. `CI=true` stays.

## Test plan
- [ ] ESLint on `ConversationDetailCard.tsx` reports no `img-redundant-alt` issue.
- [ ] After merge, deploy-beta on ai-server-4080 gets past `build:beta`.

Reviewed-on: #133
2026-10-20 09:00:43 -07:00
westfarn ac165a2db4 Fix beta build: image check accepts Blob attachments (#131)
Unit Tests / test (push) Successful in 12s
Deploy Beta / unit-tests (push) Successful in 37s
Deploy Beta / deploy-beta (push) Failing after 3m49s
## Summary
- Closes #130.
- [Deploy Beta run 929](https://git.aimloperations.com/ai_ml_operations/chat_web_app/actions/runs/929) failed `npm run build:beta` with `TS2345`: `isImageFile` took a `File`, but the composer file is a `Blob`.
- The helper now accepts `Blob`. `URL.createObjectURL` already does. The `as File` casts are gone.
- Jest stayed green on that run because it does not typecheck this call. `tsc --noEmit` is clean on this branch.

## Test plan
- [ ] `npx tsc --noEmit` in `llm-fe`
- [ ] `npm run build:beta` (or the next Deploy Beta) compiles
- [ ] Image attach still shows a thumbnail and sends
- [ ] Document attach still shows the filename

Reviewed-on: #131
2026-10-20 08:07:39 -07:00
westfarn 89228719b5 Vision: attach an image with a prompt and have the model read it (#129)
Deploy Beta / unit-tests (push) Successful in 13s
Unit Tests / test (push) Successful in 13s
Deploy Beta / deploy-beta (push) Failing after 3m44s
## Summary
- Closes #128.
- Chat composer accepts PNG, JPEG, WebP, and GIF along with existing documents.
- Selected image shows a thumbnail before send. Filename stays hidden for images. Documents still show the filename.
- User turn keeps the image after reload, from the prompt `image` field the backend returns.
- Oversized or unsupported images show the websocket error on the assistant bubble.
- Landing copy and `llms.txt` no longer list vision as roadmap-only.

Needs the matching `chat_backend` branch `feat/128-vision-image-prompt` so the model actually reads the image.

## Test plan
- [ ] Attach a PNG with a prompt that asks to read text in the image. Answer uses the picture.
- [ ] Thumbnail shows before send, with no filename.
- [ ] Reload the conversation. The image is still on the user turn.
- [ ] CSV/PDF attach still works.
- [ ] Text-only prompt unchanged.
- [ ] A BMP or an image over 8 MB shows an error instead of a silent failure.

Reviewed-on: #129
2026-10-20 06:13:42 -07:00
23 changed files with 573 additions and 117 deletions

No files matched your search

+23
View File
@@ -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.
-37
View File
@@ -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",
-2
View File
@@ -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": {
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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',
);
});
});
+41 -16
View File
@@ -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(
+1
View File
@@ -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');
});
});
+21 -1
View File
@@ -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;
}
+4 -2
View File
@@ -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"');
+1 -1
View File
@@ -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,
]);
});
});
+22
View File
@@ -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}`;
}