diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..2ff4b34 --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,28 @@ +name: Verify web application +on: + push: + branches: [main, 'codex/**'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + web: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm run build && git diff --exit-code -- index.html + - run: npm test + - run: npx playwright install --with-deps chromium + - run: npm run test:motion + - run: npm run test:browser + - uses: actions/upload-artifact@v4 + if: always() + with: + name: browser-validation + path: .test-results/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dbef872 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +node_modules/ +dist/ +.test-results/ +macos/ diff --git a/README.md b/README.md index 6c51133..6676529 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # ✨ ConversationsWithGod

- ConversationsWithGod Logo + ConversationsWithGod Logo

@@ -40,13 +40,15 @@ We all need space to process our thoughts, ask deep questions, and listen to our - **🔒 Complete Privacy**: All conversations stay on your device - **👁️ Ephemeral Memory**: Messages naturally fade and blur over time, mimicking real memory - **🎨 Beautiful Interface**: Clean, paper-like design with light and dark modes -- **💾 Export Options**: Save your conversations in multiple formats +- **💾 Export Options**: Complete backups, readable text, current API request bodies and fine-tuning datasets +- **🌿 Long Conversations**: Continuous history scrolling with only visible messages rendered +- **↩️ Undo**: Undo message deletion or clearing a conversation - **🤖 LLM Training Ready**: Export in formats compatible with various AI models - **📱 Fully Responsive**: Works on desktop and mobile devices ## 🔒 Privacy -Your conversations are stored only on your device using browser localStorage. Nothing is ever sent to a server. There are no analytics, no tracking, and no cookies. Your thoughts remain entirely your own. +Conversations are stored in browser IndexedDB, migrating older localStorage records. Nothing is ever sent to a server. There are no analytics, no tracking, and no cookies. Your thoughts remain entirely your own. ### Natural Privacy Protection @@ -54,12 +56,12 @@ Your conversations are stored only on your device using browser localStorage. No Privacy Through Fading

-Rather than relying on passwords or encryption, ConversationsWithGod implements privacy the way human memory works: +The fading display evokes the way human memory changes. It is a visual effect, not encryption or deletion: - **Time-Based Fading**: Older messages gradually become blurry and fade away - **Five Stages of Aging**: Messages progress through increasing levels of obscurity - **Natural Security**: Anyone glancing at your screen sees only your recent thoughts clearly -- **No Permanent Record**: The fading visual effect creates a sense of impermanence +- **Visual Fading**: The original text and timestamps remain saved and exportable. Both fade together on screen. This design reflects how memories and conversations naturally fade with time, providing both privacy and encouraging a focus on the present moment. @@ -73,19 +75,14 @@ This design reflects how memories and conversations naturally fade with time, pr ## 🧠 Exporting for AI Training -Your self-conversations can be valuable training data for personalizing AI models. ConversationsWithGod supports exporting in formats compatible with most major LLM platforms: +Choose a full backup for safekeeping, a request body to continue a dialogue with a model, or a JSONL dataset for fine-tuning. -- **🟢 OpenAI/ChatGPT** (JSON, JSONL) -- **🟣 Anthropic/Claude** (Human/Assistant format) -- **🔵 Meta/LLaMA** (Instruction format) -- **🟠 ShareGPT** (Community format) -- **🟡 Alpaca** (Instruction tuning format) -- **⚪ Plain Text** (Universal format) +- OpenAI-compatible Chat Completions, OpenAI Responses, Anthropic Messages and Gemini generateContent +- OpenAI supervised fine-tuning JSONL with answer weights, Hugging Face / TRL messages JSONL and ShareGPT JSONL +- Complete JSON backup, portable messages history, plain text +- Legacy Claude text, Llama 2 INST, ShareGPT JSON and Alpaca JSON -

- Export Format Options - Export Format Options -

+Saving starts with readable text. Choose another use to reveal backup, AI continuation or fine-tuning options. All exports are generated locally; the app does not call model APIs. Supply your provider's model ID when preparing an API request. See [the export guide](docs/EXPORTS.md) for continuation, training context, loss masking and current API schemas. ## 🕊️ The Philosophy Behind It @@ -116,7 +113,7 @@ Every star, fork, and share helps this little light reach more seekers in a digi ## 👥 Contributing -Contributions are welcome! Feel free to open issues or submit pull requests on the [GitHub repository](https://github.com/studentiz/ConversationsWithGod). +Contributions are welcome! Feel free to open issues or submit pull requests on the [GitHub repository](https://github.com/studentiz/ConversationsWithGod). See [development instructions](docs/DEVELOPMENT.md) and [validation results](docs/VALIDATION.md) for building and checking the web version. --- diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..174b355 --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,28 @@ +# Develop and build + +Node.js 24 is used for the web build and tests. + +```sh +npm ci +npm run build +npm test +npx playwright install chromium +npm run test:motion +npm run test:browser +``` + +`web/index.template.html` contains the original paper UI and interaction code. `web/storage.js`, `web/transcript.js` and `web/exports.js` are inlined by the build into the standalone root `index.html`. GitHub Pages serves that generated file from `main` as before. + +The public build is a single standalone HTML file plus optional PWA assets. `npm run build:icons` regenerates the web icons and README logo from `icons/mark.svg`, using the same borderless dialogue-bubble shapes and paper/gray/green palette as the interface. + +## Storage + +Pages migrates the previous `chat_messages` localStorage array into IndexedDB atomically and only removes the source after a successful transaction. Subsequent appends write one message. Standalone browsers that deny IndexedDB retain the localStorage fallback. A storage failure keeps the current text in memory, gives an actionable error and lets the next save recover the full state. Damaged history is not overwritten. + +## Rendering and motion + +The transcript uses variable-height virtual rows with overscan, measured height caching, binary-search lookup and explicit scroll anchoring. Native browser anchoring is disabled to avoid double adjustment when spacers change. Every message remains in the history and is reachable by continuous scrolling; exporting reads the full history, not the rendered DOM. + +Only a newly sent short message gets a character-opacity reveal. Grapheme segmentation preserves composed emoji and text, spans are capped at 160. A single character fades in over 600 ms; phrases have at least a 700 ms span between the first and last character starts, with a 420 ms fade for each. Longer passages use approximately 65 ms between starts, capped at a six-second stagger, and collapse back to one text node within 6.52 seconds. Long messages render immediately. Menus and dialogs use short opacity / transform transitions. The reduced-motion preference removes reveals and transitions; hidden documents stop maintenance timers and finish active text reveals. Idle conversations have no continuous decorative animation. Aging is applied to a shared content wrapper so the body and timestamp fade and blur together, while deletion controls stay clear. There is no added fading toggle or backup import item. + +These choices follow [Apple's motion guidance](https://developer.apple.com/design/human-interface-guidelines/motion): purposeful, brief feedback, input available during animation, and accessibility preferences. Smoothness and power are tested separately from correctness; there is no measured battery-life claim. diff --git a/docs/EXPORTS.md b/docs/EXPORTS.md new file mode 100644 index 0000000..69a03f8 --- /dev/null +++ b/docs/EXPORTS.md @@ -0,0 +1,44 @@ +# Export a dialogue for another model + +The two voices are written by you. In exports, the seeker becomes `user` and the voice of inner wisdom becomes `assistant`. Choose **Save a copy… → Backup** to preserve the exact individual messages, timestamps, names and order. Fading affects the display only. + +Opening **Save a copy…** starts with readable text. You can save immediately, or change **Save for** to a backup, AI continuation, fine-tuning, or another format. Only the fields for that use appear. Additional model instructions and training context are under **Options**; model instructions are off by default. These operations generate files locally and never call a model or upload a conversation. + +## Continue a conversation + +| Export | Request body | Where it fits | +| --- | --- | --- | +| Messages history | `messages` with `role` and `content` | SDKs and local models; choose your own chat template | +| OpenAI-compatible Chat Completions | `model`, `messages` | Compatible OpenAI, DeepSeek, Qwen and local endpoints | +| OpenAI Responses | `model`, `input`, optional `instructions` | Responses API | +| Anthropic Messages | `model`, `max_tokens`, `messages`, optional top-level `system` | Messages API | +| Gemini generateContent | `contents` with `parts`, optional `systemInstruction` | Model chosen in the request URL | + +Enter the model ID your provider supplies. If the dialogue already ends with an answer, enter your next question in the export dialog. That question is added to the exported request only. Supply endpoint, authentication and provider-specific settings in your own caller. The Anthropic starter output limit is 2048 tokens; adjust it in your caller. + +For example, in Python, an exported compatible request body can be used with a configured client: + +```python +import json +from openai import OpenAI + +client = OpenAI() # reads credentials from your environment +with open("request.json", encoding="utf-8") as file: + request = json.load(file) +response = client.chat.completions.create(**request) +print(response.choices[0].message.content) +``` + +Adjacent messages from the same role, which can occur after deleting a turn, are joined with a blank line. Their text remains intact. Exports that require a user-first dialogue report an assistant-first history instead of silently removing it; use the complete backup or messages history to retain that history as-is. + +## Fine-tune + +Choose **Fine-tuning a model**, then your training tool. The file is a JSONL dataset. Each line ends at an answered assistant turn. The context setting limits previous **exchanges**, not tokens; use the chosen model's tokenizer to check the final examples. Incomplete final questions are retained in backups and continuation history, and excluded from training examples. An assistant-only opening is excluded with a visible explanation. + +- **OpenAI supervised fine-tuning:** messages JSONL. The newest assistant answer has `weight: 1`; earlier answers in the same window have `weight: 0`, preventing those context answers from becoming repeated loss targets. OpenAI currently requires at least 10 examples; the dialog reports smaller datasets. Review your examples before combining conversations and uploading. +- **Hugging Face / TRL:** messages JSONL without provider-specific weights. Select assistant loss masking in your trainer. With zero previous exchanges, each answered pair occurs once; with overlapping context, your trainer determines which earlier answers contribute to loss. +- **ShareGPT:** `conversations` JSONL with `from` / `value`, for training tools accepting this schema. Configure the tool's dataset mapping and chat template. + +Human/Assistant text, Llama 2 INST, whole-conversation ShareGPT JSON and Alpaca JSON remain available under **Other formats**. Use the current model's own template for newer Llama and Qwen models. Alpaca exports answered pairs without prior dialogue context; it is not a complete backup. + +Sources checked on 2026-10-11: [OpenAI SFT](https://developers.openai.com/api/docs/guides/supervised-fine-tuning), [OpenAI loss weights](https://developers.openai.com/api/docs/guides/fine-tuning-best-practices), [Responses input](https://developers.openai.com/api/reference/resources/responses/methods/create), [Anthropic Messages](https://platform.claude.com/docs/en/api/messages/create), [Gemini generateContent](https://ai.google.dev/api/generate-content), [TRL datasets and loss configuration](https://huggingface.co/docs/trl/sft_trainer). Automated tests validate emitted data and edge cases; they do not perform paid API calls or actual model training. diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md new file mode 100644 index 0000000..7f17dbe --- /dev/null +++ b/docs/VALIDATION.md @@ -0,0 +1,19 @@ +# Validation · 2026-10-11 + +Local hardware: Apple M4 Pro, macOS 15.8. Chrome runs headlessly at 390 × 844 for the history benchmark. Both versions use the same message contents, timestamps, viewport, browser and first-frame measurement. Baseline HTML is pinned to GitHub commit `6a4ae6e9a7980d7d83af4e41ee6b75671f341ee2`. + +| History | Before: open | After: open | Before: live message nodes | After: live message nodes | Before: send median | After: send median | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| 3,000 messages | 778 ms | 178 ms | 3,003 | 13 | 22.3 ms | 18.4 ms | +| 10,000 messages | 2,310 ms | 316 ms | 10,003 | 14 | 79.2 ms | 6.2 ms | + +Opening measurements include the new version's initial migration. Send timing measures click-to-next-animation-frame; it is not disk durability latency. The data is one local run with three sends per condition, rather than a cross-device performance guarantee. In the 90-frame scripted scroll probe, both versions recorded zero intervals above 34 ms; after-change p95 intervals were 16.7 ms in both sizes. This probe alone does not establish smoothness of every animation. Run `npm run benchmark` to reproduce; raw results go into `.test-results/performance.json`. + +Browser integration covers 100, 3,000 and 10,000 messages: complete migration and reload, continuous top/middle/end history access, bounded row count, stable-ID deletion and undo, full-history download, composition input, multiline input, bounded character animation and cleanup, reduced motion, quota failure recovery, corrupt-history preservation, offline reopening, standalone HTML, font changes and fading. Notification rectangles remain below the header and above the composer in portrait and small landscape layouts. The normal conversation has no persistent save/status footer, connection messages, font-change messages or automatic installation banner. Saving starts with a readable copy; backup and model settings appear only when selected. A storage failure offers a working Save a copy action, opening a damaged history can be retried. Browser tests exercise these controls and verify continuation and training exports while offline, without network requests after initial page loading. + +Export-format tests cover backup fidelity, provider-specific request fields, training target weights, incomplete turns, adjacent-role merging, Unicode text and the retained legacy formats. + +API requests and fine-tuning files are validated against the linked schemas and local tests. No paid model calls or training jobs were performed. Battery consumption has not been measured; the implementation bounds rendering and character spans, stops maintenance when hidden and avoids continuous idle animation. + + +The timestamp regression first failed with a clear timestamp beside blurred text; after the fix the inherited blur is identical for both, with the delete control unaffected. A three-character Chinese phrase previously reached 0.96 opacity on the last character after 210 ms; it now still has its last character at zero opacity then, completes within the bounded reveal time and retains its exact text. These focused regressions run with `npm run test:motion`. Browser integration also checks compact 320 × 400 / 320 × 568, landscape, tablet and desktop dimensions, complete reveal cleanup, reduced motion, and the removal of the two unrequested menu items. diff --git a/docs/performance.json b/docs/performance.json new file mode 100644 index 0000000..579c379 --- /dev/null +++ b/docs/performance.json @@ -0,0 +1,54 @@ +[ + { + "version": "old", + "messages": 3000, + "bootMs": 778, + "domMessages": 3003, + "sendMs": [ + 23.399999998509884, + 22.30000000447035, + 22.100000001490116 + ], + "scrollFrameP95Ms": 16.700000000000273, + "scrollFramesOver34Ms": 0 + }, + { + "version": "old", + "messages": 10000, + "bootMs": 2310, + "domMessages": 10003, + "sendMs": [ + 86.5, + 79.10000000149012, + 79.19999999552965 + ], + "scrollFrameP95Ms": 16.700000000000728, + "scrollFramesOver34Ms": 0 + }, + { + "version": "new", + "messages": 3000, + "bootMs": 178, + "domMessages": 13, + "sendMs": [ + 18.599999994039536, + 14.600000001490116, + 18.399999998509884 + ], + "scrollFrameP95Ms": 16.700000000000045, + "scrollFramesOver34Ms": 0 + }, + { + "version": "new", + "messages": 10000, + "bootMs": 316, + "domMessages": 14, + "sendMs": [ + 18.200000002980232, + 4.5, + 6.200000002980232 + ], + "scrollFrameP95Ms": 16.700000000000273, + "scrollFramesOver34Ms": 0 + } +] diff --git a/icons/apple-touch-icon.png b/icons/apple-touch-icon.png index e0b8714..9c2540e 100644 Binary files a/icons/apple-touch-icon.png and b/icons/apple-touch-icon.png differ diff --git a/icons/favicon-16x16.png b/icons/favicon-16x16.png new file mode 100644 index 0000000..34beeb1 Binary files /dev/null and b/icons/favicon-16x16.png differ diff --git a/icons/favicon-32x32.png b/icons/favicon-32x32.png index fa69b76..9348078 100644 Binary files a/icons/favicon-32x32.png and b/icons/favicon-32x32.png differ diff --git a/icons/icon-192x192.png b/icons/icon-192x192.png index 8f30a74..e06238c 100644 Binary files a/icons/icon-192x192.png and b/icons/icon-192x192.png differ diff --git a/icons/icon-512x512.png b/icons/icon-512x512.png index e0b8714..03f59f4 100644 Binary files a/icons/icon-512x512.png and b/icons/icon-512x512.png differ diff --git a/icons/mark.svg b/icons/mark.svg new file mode 100644 index 0000000..5926a2a --- /dev/null +++ b/icons/mark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/icons/maskable-icon.png b/icons/maskable-icon.png index e0b8714..7301856 100644 Binary files a/icons/maskable-icon.png and b/icons/maskable-icon.png differ diff --git a/img/export-formats1.png b/img/export-formats1.png index fdad3d2..e9ec942 100644 Binary files a/img/export-formats1.png and b/img/export-formats1.png differ diff --git a/img/export-formats2.png b/img/export-formats2.png index 137ebe3..65ccf26 100644 Binary files a/img/export-formats2.png and b/img/export-formats2.png differ diff --git a/img/logo.png b/img/logo.png index 5095fcc..3251a3d 100644 Binary files a/img/logo.png and b/img/logo.png differ diff --git a/img/privacy-blur.png b/img/privacy-blur.png index b24971d..d95f3f0 100644 Binary files a/img/privacy-blur.png and b/img/privacy-blur.png differ diff --git a/img/screenshot.png b/img/screenshot.png index 9bae310..cce98ad 100644 Binary files a/img/screenshot.png and b/img/screenshot.png differ diff --git a/index.html b/index.html index 60aaf91..6e22a32 100644 --- a/index.html +++ b/index.html @@ -2,25 +2,25 @@ - + ConversationsWithGod - + - + 你好 🌙');await page.locator('#messageInput').dispatchEvent('keydown',{key:'Enter',isComposing:true});assert.equal(await count(page),0);await page.locator('#messageInput').press('Shift+Enter');await page.locator('#messageInput').type('second line');await page.locator('#sendButton').click();assert.ok(await page.locator('.revealed-glyph').count()>0);await page.waitForFunction(()=>!document.querySelector('[data-reveal]'),{},{timeout:8000});assert.equal(await page.locator('.revealed-glyph').count(),0);assert.ok((await page.locator('.message-text').innerText()).includes('\n'));assert.deepEqual(errors,[]); + // Quota failure leaves the message and next save can recover the entire state. + await page.evaluate(()=>{window.originalPut=IDBObjectStore.prototype.put;IDBObjectStore.prototype.put=function(){throw new DOMException('Quota','QuotaExceededError');};});await send(page,'Kept on storage failure');assert.equal(await page.evaluate(()=>cwgFlush()),false);assert.match(await page.locator('#saveState').innerText(),/Not saved/);assert.match(await page.locator('.message-text').last().innerText(),/Kept/); + await page.getByRole('button',{name:'Save a copy',exact:true}).click();assert.equal(await page.locator('#exportPurpose').inputValue(),'backup');const copyDownload=page.waitForEvent('download');await page.locator('#confirmExport').click();const copy=JSON.parse(fs.readFileSync(await (await copyDownload).path(),'utf8'));assert.equal(copy.messages.length,2);assert.equal(copy.messages.at(-1).content,'Kept on storage failure');assert.equal(await page.evaluate(()=>cwgHasUnsavedChanges()),true); + await page.evaluate(()=>IDBObjectStore.prototype.put=window.originalPut);await send(page,'Recovered');assert.equal(await page.evaluate(()=>cwgFlush()),true);assert.equal(await count(page),3);assert.equal(await page.locator('#notification').isVisible(),false);assert.equal(await page.evaluate(()=>cwgHasUnsavedChanges()),false); + await context.close();} + {const {context,page}=await open(0,{reducedMotion:'reduce'});await send(page,'No motion');assert.equal(await page.locator('.revealed-glyph').count(),0);await context.close();} + // Denied appearance storage cannot prevent durable IndexedDB conversation saves. + {const context=await browser.newContext({serviceWorkers:'block'});const page=await context.newPage();await page.addInitScript(()=>Object.defineProperty(window,'localStorage',{get(){throw new DOMException('Denied','SecurityError');}}));const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto(url);await page.waitForFunction(()=>!document.getElementById('messageInput').disabled);await send(page,'Saved without preference storage');assert.equal(await count(page),1);await page.reload();await page.waitForFunction(()=>!document.getElementById('messageInput').disabled);assert.equal(await page.locator('.message-text').innerText(),'Saved without preference storage');assert.deepEqual(errors,[]);await context.close();} + // The standalone HTML still works without its neighboring assets. + {const context=await browser.newContext();const page=await context.newPage();await page.goto('file://'+root+'/index.html');await page.waitForFunction(()=>!document.getElementById('messageInput').disabled);await send(page,'A standalone dialogue');assert.equal(await page.evaluate(()=>cwgFlush()),true);await context.close();} + // Font changes remeasure virtual rows; original aging covers text and timestamp together. + {const {context,page}=await open(3000);const text=await page.locator('.message-text').last().innerText();assert.notEqual(await page.locator('.message-content').last().evaluate(el=>getComputedStyle(el).filter),'none');assert.equal(await page.locator('.message-time').last().evaluate(el=>el.parentElement.classList.contains('message-content')),true);await page.locator('#menuButton').click();assert.equal(await page.locator('#fadeItem, #importItem, #importFile').count(),0);await page.locator('#fontSizeItem').click();await page.waitForTimeout(200);assert.equal(await page.evaluate(()=>document.body.classList.contains('font-size-large')),true);assert.ok(await page.locator('.message').count()<=100);assert.equal(await page.locator('.message-text').last().innerText(),text);await context.close();} + // Damaged legacy data is never silently replaced by a fresh empty conversation. + {const context=await browser.newContext();const page=await context.newPage();await page.addInitScript(()=>localStorage.setItem('chat_messages','broken json'));await page.goto(url);await page.waitForFunction(()=>document.getElementById('saveState').textContent.includes('Unable'));assert.equal(await page.locator('#messageInput').isDisabled(),true);assert.equal(await page.evaluate(()=>localStorage.getItem('chat_messages')),'broken json');assert.equal(await page.locator('#notification span').innerText(),'Your conversation couldn’t be opened.');await page.evaluate(records=>localStorage.setItem('chat_messages',JSON.stringify(records)),fixture(2));await page.getByRole('button',{name:'Try again',exact:true}).click();await page.waitForFunction(()=>!document.getElementById('messageInput').disabled);assert.equal(await count(page),2);assert.equal(await page.locator('#notification').isVisible(),false);await context.close();} + // The installed Pages experience reopens offline with all saved text. + {const {context,page}=await open(2);await page.evaluate(()=>navigator.serviceWorker.ready);await page.reload();await page.waitForFunction(()=>!document.getElementById('messageInput').disabled);await context.setOffline(true);await page.reload();await page.waitForFunction(()=>!document.getElementById('messageInput').disabled);assert.equal(await count(page),2);await send(page,'Written offline');assert.equal(await count(page),3);await context.close();} + // Saving is one action by default. Model options are revealed only for their selected use. + {const {context,page,errors}=await open(2,{serviceWorkers:'block'});const requests=[];page.on('request',r=>requests.push(r.url()));await context.setOffline(true); + await page.locator('#menuButton').click();await page.locator('#exportItem').click();await page.locator('#confirmExport').waitFor({state:'visible'});assert.equal(await page.locator('#exportPurpose').inputValue(),'reading');assert.equal(await page.locator('#confirmExport').innerText(),'Save text');assert.equal(await page.locator('#exportDetails').isVisible(),false);assert.equal(await page.locator('#apiOptions').isVisible(),false);assert.equal(await page.locator('#exportSummary').isVisible(),false); + const textDownload=page.waitForEvent('download');await page.locator('#confirmExport').click();const text=fs.readFileSync(await (await textDownload).path(),'utf8');assert.ok(text.includes(fixture(2)[0].content));assert.equal(await page.locator('#notification').isVisible(),false); + await page.locator('#menuButton').click();await page.locator('#exportItem').click();await page.locator('#confirmExport').waitFor({state:'visible'});await page.locator('#exportPurpose').selectOption('continue');assert.equal(await page.locator('#continuationFormat').inputValue(),'history');assert.equal(await page.locator('#apiOptions').isVisible(),false);assert.equal(await page.locator('#systemPromptInput').isVisible(),false); + await page.locator('#continuationFormat').selectOption('api_claude');assert.equal(await page.locator('#modelRow').isVisible(),true);assert.equal(await page.locator('#trainingFormatOptions').isVisible(),false);await page.locator('#confirmExport').click();assert.equal(await page.locator('#exportModal').getAttribute('aria-hidden'),'false');assert.equal(await page.locator('#exportModel').evaluate(el=>el.validity.valueMissing),true); + await page.locator('#exportModel').fill('chosen-model');await page.locator('#exportNextMessage').fill('What next?');const apiDownload=page.waitForEvent('download');await page.locator('#confirmExport').click();const api=JSON.parse(fs.readFileSync(await (await apiDownload).path(),'utf8'));assert.equal(api.model,'chosen-model');assert.equal(api.messages.length,3);assert.equal(api.messages.at(-1).content,'What next?');assert.equal(api.system,undefined);assert.equal(await count(page),2); + await page.locator('#menuButton').click();await page.locator('#exportItem').click();await page.locator('#confirmExport').waitFor({state:'visible'});await page.locator('#exportPurpose').selectOption('training');await page.locator('#trainingFormat').selectOption('sft_messages');assert.equal(await page.locator('#exportContext').isVisible(),false);await page.locator('#exportDetails summary').click();await page.locator('#exportContext').fill('0');await page.locator('#includeSystemPrompt').check();await page.locator('#systemPromptInput').fill('Listen carefully.');const trainingDownload=page.waitForEvent('download');await page.locator('#confirmExport').click();const training=JSON.parse(fs.readFileSync(await (await trainingDownload).path(),'utf8'));assert.equal(training.messages.length,3);assert.equal(training.messages[0].content,'Listen carefully.');assert.equal(training.messages.at(-1).role,'assistant');assert.ok(!JSON.stringify(training).includes('What next?')); + await page.locator('#menuButton').click();await page.locator('#exportItem').click();await page.locator('#confirmExport').waitFor({state:'visible'});await page.locator('#exportPurpose').selectOption('other');await page.locator('#otherFormat').selectOption('legacy_alpaca');const legacyDownload=page.waitForEvent('download');await page.locator('#confirmExport').click();const legacy=JSON.parse(fs.readFileSync(await (await legacyDownload).path(),'utf8'));assert.equal(legacy.length,1); + await page.locator('#menuButton').click();await page.locator('#fontSizeItem').click();assert.equal(await page.locator('#notification').isVisible(),false);await page.evaluate(()=>{window.dispatchEvent(new Event('offline'));window.dispatchEvent(new Event('online'));});assert.equal(await page.locator('#notification').isVisible(),false); + assert.deepEqual(requests,[]);assert.deepEqual(errors,[]);await context.close();} + // Small landscape composer and export dialog remain usable. Produce real UI previews. + {const {context,page}=await open(0,{viewport:{width:320,height:568}});await send(page,'为什么越忙碌,越觉得离自己很远?');await send(page,'也许可以先停下来,听听此刻的感受。你不必马上找到所有答案。');await send(page,'那今天,就从给自己留一点安静开始。');await page.waitForFunction(()=>!document.querySelector('[data-reveal]'),{},{timeout:8000});await page.screenshot({path:path.join(out,'web.png')});await page.locator('#menuButton').click();await page.locator('#exportItem').click();await page.locator('#confirmExport').waitFor({state:'visible'});await page.waitForTimeout(250);await page.screenshot({path:path.join(out,'export.png')});await page.locator('#cancelExport').click(); + const original=await page.locator('.message-text').allInnerTexts(); + for(const size of [{width:320,height:400},{width:768,height:1024},{width:1280,height:832},{width:320,height:568}]){await page.setViewportSize(size);await page.waitForTimeout(100);const composer=await page.locator('.chat-input-area').boundingBox();assert.ok(composer.y+composer.height<=size.height+1);assert.equal(await page.locator('#sendButton').isVisible(),true);await page.locator('#menuButton').click();await page.locator('#exportItem').click();await page.locator('#confirmExport').waitFor({state:'visible'});await page.locator('#confirmExport').scrollIntoViewIfNeeded();const save=await page.locator('#confirmExport').boundingBox();assert.ok(save.y+save.height<=size.height+1);await page.locator('#cancelExport').click();} + assert.deepEqual(await page.locator('.message-text').allInnerTexts(),original);await page.setViewportSize({width:844,height:390});await page.locator('.message').last().hover();await page.locator('.message').last().locator('.message-delete').click();const overlap=await page.evaluate(()=>document.getElementById('notification').getBoundingClientRect().bottom>document.querySelector('.chat-input-area').getBoundingClientRect().top);assert.equal(overlap,false);await context.close();} + fs.writeFileSync(path.join(out,'browser.json'),JSON.stringify({results,passed:true},null,2));console.log(JSON.stringify({results,passed:true},null,2)); + }finally{await browser.close();server.close();} +})().catch(e=>{console.error(e);process.exitCode=1;}); diff --git a/tests/export.test.cjs b/tests/export.test.cjs new file mode 100644 index 0000000..0d7b1e6 --- /dev/null +++ b/tests/export.test.cjs @@ -0,0 +1,27 @@ +const test=require('node:test'),assert=require('node:assert/strict'); +const E=require('../web/exports.js'); +const timestamp='2026-10-11T00:00:00.000Z'; +const m=(role,content,i=0)=>({id:String(i),role,content,timestamp,order:i,displayName:role==='user'?'User':'God'}); +const history=[m('user','你好\n第一问',0),m('assistant','答案 "quoted" 🌿',1),m('user','第二问',2),m('assistant','第二答',3),m('user','待回答',4)]; +const parse=(format,options={})=>JSON.parse(E.createExport(history,{format,model:'user-selected-model',system:'be thoughtful',...options}).content); +test('backup preserves every original message and the shared storage schema',()=>{const out=parse('backup');assert.deepEqual(out.messages,history);assert.equal(out.schemaVersion,2);assert.deepEqual(E.validate(out.messages).map(x=>x.content),history.map(x=>x.content));}); +test('OpenAI-compatible request keeps all history and separates optional next question',()=>{const out=parse('api_chat');assert.equal(out.model,'user-selected-model');assert.equal(out.messages.length,6);assert.deepEqual(out.messages[0],{role:'system',content:'be thoughtful'});assert.equal(out.messages.at(-1).content,'待回答');assert.ok(out.messages.every(m=>Object.keys(m).sort().join(',')==='content,role'));}); +test('Responses input uses plain roles and top-level instructions',()=>{const out=parse('api_responses');assert.equal(out.instructions,'be thoughtful');assert.equal(out.input.length,5);assert.equal(out.input[1].role,'assistant');assert.equal(out.input[1].content,history[1].content);}); +test('Anthropic exports Messages API, not legacy Human/Assistant prompt text',()=>{const out=parse('api_claude');assert.equal(out.system,'be thoughtful');assert.equal(out.max_tokens,2048);assert.equal(out.messages.length,5);assert.ok(out.messages.every(m=>m.role!=='system'));}); +test('Gemini converts assistant to model and retains systemInstruction separately',()=>{const out=parse('api_gemini');assert.equal(out.contents[1].role,'model');assert.equal(out.contents[1].parts[0].text,history[1].content);assert.deepEqual(out.systemInstruction,{parts:[{text:'be thoughtful'}]});}); +test('training ends at assistant answers and masks repeated earlier answers',()=>{const result=E.createExport(history,{format:'sft_openai',system:'S',contextTurns:4});const rows=result.content.trim().split('\n').map(JSON.parse);assert.equal(rows.length,2);assert.equal(rows[1].messages.at(-1).content,'第二答');assert.equal(rows[1].messages[2].weight,0);assert.equal(rows[1].messages[4].weight,1);assert.ok(rows.every(r=>r.messages.at(-1).role==='assistant'));assert.ok(result.warnings.some(x=>x.includes('unanswered')));assert.ok(!result.content.includes('待回答'));}); +test('one exchange per target with zero context, no invented answers',()=>{const rows=E.createExport(history,{format:'sft_messages',contextTurns:0}).content.trim().split('\n').map(JSON.parse);assert.deepEqual(rows.map(r=>r.messages.length),[2,2]);assert.equal(rows[1].messages[0].content,'第二问');assert.equal(rows[0].messages[1].weight,undefined);}); +test('ShareGPT training uses conversations/from/value per line',()=>{const row=JSON.parse(E.createExport(history,{format:'sft_sharegpt'}).content.split('\n')[0]);assert.deepEqual(row.conversations.map(m=>m.from),['human','gpt']);}); +test('adjacent roles after deleting a turn are merged without losing text',()=>{const source=[m('user','A'),m('user','B'),m('assistant','C')];const out=E.createExport(source,{format:'sft_messages'});assert.equal(JSON.parse(out.content).messages[0].content,'A\n\nB');assert.equal(source.length,3);}); +test('assistant-only and unanswered data cannot become training targets',()=>{assert.throws(()=>E.createExport([m('assistant','A')],{format:'sft_openai'}),/requires/);assert.throws(()=>E.createExport([m('user','Q')],{format:'sft_messages'}),/requires/);}); +test('continuation prevents unsupported assistant-prefill and accepts explicit next question',()=>{const answered=history.slice(0,4);assert.throws(()=>E.createExport(answered,{format:'api_claude',model:'M'}),/next question/);const out=JSON.parse(E.createExport(answered,{format:'api_claude',model:'M',nextMessage:'Continue?'}).content);assert.equal(out.messages.at(-1).content,'Continue?');assert.equal(answered.length,4);}); +test('raw history supports leading assistant without silently removing it',()=>{const source=[m('assistant','A'),m('user','Q')];assert.throws(()=>E.createExport(source,{format:'api_gemini'}),/initial user/);assert.equal(JSON.parse(E.createExport(source,{format:'history'}).content).messages[0].content,'A');}); +test('shared schema rejects malformed conversation records',()=>{assert.throws(()=>E.validate([{role:'alien',text:'bad',timestamp}]),/role/);assert.throws(()=>E.validate([{role:'user',content:'valid',timestamp:'invalid'}]),/timestamp/);assert.throws(()=>E.validate([{role:'user',content:' ',timestamp}]),/Empty/);}); +test('legacy seeker/sage records retain their text and roles',()=>{const out=E.validate([{id:'A',role:'seeker',text:'Q',createdAt:timestamp},{id:'B',role:'sage',text:'A',createdAt:timestamp}]);assert.deepEqual(out.map(x=>x.role),['user','assistant']);assert.deepEqual(out.map(x=>x.content),['Q','A']);}); +test('model IDs are supplied by the user and not fabricated in API exports',()=>{assert.throws(()=>E.createExport(history,{format:'api_chat'}),/model ID/);assert.throws(()=>E.createExport(history,{format:'api_responses'}),/model ID/);}); +test('legacy formats remain available alongside modern formats',()=>{ + assert.match(E.createExport(history,{format:'legacy_claude'}).content,/Human: 你好/); + const llama=E.createExport(history,{format:'legacy_llama',system:'S'}).content;assert.match(llama,/<>\nS/);assert.match(llama,/\[INST\] 待回答 \[\/INST\]/); + assert.equal(parse('legacy_sharegpt').conversations.length,6); + const alpaca=parse('legacy_alpaca');assert.equal(alpaca.length,2);assert.equal(alpaca[0].output,history[1].content); +}); diff --git a/tests/motion.cjs b/tests/motion.cjs new file mode 100644 index 0000000..f477e47 --- /dev/null +++ b/tests/motion.cjs @@ -0,0 +1,32 @@ +const assert=require('node:assert/strict'),path=require('node:path'),fs=require('node:fs'); +const {chromium}=require('playwright'); +const root=path.resolve(__dirname,'..'); +(async()=>{ + const installed='/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; + const browser=await chromium.launch({headless:true,...(fs.existsSync(installed)?{executablePath:installed}:{})}); + try{ + const context=await browser.newContext({viewport:{width:320,height:568},serviceWorkers:'block'}),page=await context.newPage(); + await page.addInitScript(()=>{localStorage.setItem('theme','light');localStorage.setItem('chat_messages',JSON.stringify([{role:'user',content:'Earlier words',timestamp:new Date(Date.now()-600000).toISOString()}]));}); + await page.goto('file://'+root+'/index.html');await page.waitForFunction(()=>!document.getElementById('messageInput').disabled); + if(!process.argv[2]||process.argv[2]==='timestamp'){ + const styles=await page.locator('.message').evaluate(row=>{ + const filters=el=>{const result=[];for(;el&&el!==row;el=el.parentElement){const f=getComputedStyle(el).filter;if(f!=='none')result.push(f);}return result;}; + return {text:filters(row.querySelector('.message-text')),time:filters(row.querySelector('.message-time')),delete:filters(row.querySelector('.message-delete'))}; + }); + assert.ok(styles.text.length>0);assert.deepEqual(styles.time,styles.text,'Timestamp must blur with its message');assert.deepEqual(styles.delete,[],'Delete control must stay clear');console.log('Timestamp and text age together.'); + } + if(!process.argv[2]||process.argv[2]==='cadence'){ + const opacity=await page.evaluate(async()=>{const input=document.getElementById('messageInput');input.value='慢慢说';input.dispatchEvent(new Event('input'));document.getElementById('sendButton').click();await new Promise(r=>setTimeout(r,210));const glyphs=[...document.querySelector('.message:last-child').querySelectorAll('.revealed-glyph')];return {first:Number(getComputedStyle(glyphs[0]).opacity),last:Number(getComputedStyle(glyphs.at(-1)).opacity)};}); + assert.ok(opacity.first>0);assert.equal(opacity.last,0,'A short phrase must still be unfolding after 210 ms'); + await page.waitForFunction(()=>!document.querySelector('[data-reveal]'),{},{timeout:8000});assert.equal(await page.locator('.message-text').last().textContent(),'慢慢说');console.log('Short phrase unfolds visibly and finishes without losing text.'); + } + if(!process.argv[2]){ + const bounded=await page.evaluate(()=>{const text='听🌙'.repeat(120),input=document.getElementById('messageInput');input.value=text;input.dispatchEvent(new Event('input'));document.getElementById('sendButton').click();const count=document.querySelectorAll('.revealed-glyph').length;Object.defineProperty(document,'hidden',{configurable:true,get:()=>window.fixtureHidden===true});window.fixtureHidden=true;document.dispatchEvent(new Event('visibilitychange'));return {count,remaining:document.querySelectorAll('[data-reveal]').length,text:document.querySelector('.message:last-child .message-text').textContent,expected:text};}); + assert.ok(bounded.count>0&&bounded.count<=160);assert.equal(bounded.remaining,0);assert.equal(bounded.text,bounded.expected); + await page.evaluate(()=>{window.fixtureHidden=false;document.dispatchEvent(new Event('visibilitychange'));const input=document.getElementById('messageInput');input.value='慢慢听';input.dispatchEvent(new Event('input'));document.getElementById('sendButton').click();}); + assert.ok(await page.locator('.revealed-glyph').count()>0);await page.emulateMedia({reducedMotion:'reduce'});await page.waitForFunction(()=>!document.querySelector('[data-reveal]'));assert.equal(await page.locator('.message-text').last().textContent(),'慢慢听');assert.equal(await page.locator('.revealed-glyph').count(),0); + console.log('Hidden and reduced-motion views finish text immediately; animated layers stay bounded.'); + } + await context.close(); + }finally{await browser.close();} +})().catch(error=>{console.error(error);process.exitCode=1;}); diff --git a/tests/performance.cjs b/tests/performance.cjs new file mode 100644 index 0000000..38aad87 --- /dev/null +++ b/tests/performance.cjs @@ -0,0 +1,6 @@ +const {chromium}=require('playwright'); +const http=require('node:http'),fs=require('node:fs');const root=require('node:path').resolve(__dirname,'..');const baseline=require('node:child_process').execFileSync('git',['show','6a4ae6e9a7980d7d83af4e41ee6b75671f341ee2:index.html'],{cwd:root});fs.mkdirSync(root+'/.test-results',{recursive:true}); +(async()=>{const server=http.createServer((req,res)=>{res.setHeader('Content-Type','text/html');res.end(req.url==='/old'?baseline:fs.readFileSync(root+'/index.html'));}).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));const browser=await chromium.launch({headless:true,...(process.env.CHROMIUM_PATH?{executablePath:process.env.CHROMIUM_PATH}:fs.existsSync('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome')?{executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'}:{})});const results=[]; +for(const version of ['old','new'])for(const n of [3000,10000]){const context=await browser.newContext({viewport:{width:390,height:844},serviceWorkers:'block'});const page=await context.newPage();await page.addInitScript(n=>{localStorage.setItem('theme','light');localStorage.setItem('chat_messages',JSON.stringify(Array.from({length:n},(_,i)=>({role:i%2?'assistant':'user',content:'A quiet moment to listen to the wisdom within. '.repeat(5),timestamp:new Date(Date.now()-600000+i*10).toISOString()}))));},n);const t=Date.now();await page.goto('http://127.0.0.1:'+server.address().port+'/'+version);await page.locator('.message').last().waitFor();if(version==='new')await page.waitForFunction(()=>!document.getElementById('messageInput').disabled);const bootMs=Date.now()-t;await page.waitForTimeout(400);const sendMs=[];for(let i=0;i<3;i++)sendMs.push(await page.evaluate(async()=>{const input=document.getElementById('messageInput');input.value='A new question';input.dispatchEvent(new Event('input'));const t=performance.now();document.getElementById('sendButton').click();await new Promise(requestAnimationFrame);return performance.now()-t;})); +const frames=await page.evaluate(async()=>{const c=document.getElementById('chatMessages'),intervals=[];let last=await new Promise(requestAnimationFrame);for(let i=0;i<90;i++){const now=await new Promise(requestAnimationFrame);intervals.push(now-last);last=now;c.scrollTop=c.scrollHeight*(1-i/90);}return intervals;});frames.sort((a,b)=>a-b);results.push({version,messages:n,bootMs,domMessages:await page.locator('.message').count(),sendMs,scrollFrameP95Ms:frames[Math.floor(frames.length*.95)],scrollFramesOver34Ms:frames.filter(x=>x>34).length});await context.close();} +await browser.close();server.close();fs.writeFileSync(root+'/.test-results/performance.json',JSON.stringify(results,null,2));console.log(JSON.stringify(results,null,2));})().catch(e=>{console.error(e);process.exitCode=1}); diff --git a/web/exports.js b/web/exports.js new file mode 100644 index 0000000..9b9431e --- /dev/null +++ b/web/exports.js @@ -0,0 +1,93 @@ +/* Export formats shared by the standalone HTML and GitHub Pages. */ +(function(root) { +'use strict'; +const DEFAULT_SYSTEM = 'You are the reflective voice of inner wisdom in a self-dialogue. Respond thoughtfully and compassionately, helping the seeker examine their own beliefs and choices.'; +function exportError(code,message) { const error=new Error(message);error.code=code;return error; } +function validate(records, preserveOrder=false) { + if (!Array.isArray(records)) throw new Error('The backup must contain a messages array.'); + const seen=new Set(); + return records.map((m,i)=>{ + if (!m || !['user','assistant','seeker','sage'].includes(m.role)) throw new Error(`Invalid role in message ${i+1}.`); + const content=m.content??m.text; + if(typeof content!=='string' || !content.trim()) throw new Error(`Empty or invalid text in message ${i+1}.`); + const timestamp=m.timestamp??m.createdAt; + if(typeof timestamp!=='string'||!Number.isFinite(Date.parse(timestamp))) throw new Error(`Invalid timestamp in message ${i+1}.`); + const role=['user','seeker'].includes(m.role)?'user':'assistant'; + const id=typeof m.id==='string'&&m.id&&!seen.has(m.id)?m.id:globalThis.crypto?.randomUUID?.()??`import-${Date.now()}-${i}`; + seen.add(id); + return {id,role,content,displayName:typeof m.displayName==='string'?m.displayName:role==='user'?'User':'God',timestamp,order:preserveOrder&&Number.isSafeInteger(m.order)&&m.order>=0?m.order:i}; + }); +} +function normalize(records) { + const turns=[];let merged=0; + for(const m of records){ + if(!m||!['user','assistant'].includes(m.role)||typeof m.content!=='string'||!m.content.trim())throw exportError('invalid_conversation','Conversation contains an invalid message. Export a backup first.'); + if(turns.at(-1)?.role===m.role){turns.at(-1).content+='\n\n'+m.content;merged++;} + else turns.push({role:m.role,content:m.content}); + } + return {turns,merged}; +} +function createExport(records, options={}) { + const {format='backup',system='',model='',nextMessage='',contextTurns=4,preview=false}=options; + const date=new Date().toISOString().slice(0,10);const warnings=[],notices=[]; + let data,extension='json',mime='application/json;charset=utf-8',examples=0; + if(format==='backup')data=preview?'':{schemaVersion:2,exportedAt:new Date().toISOString(),messages:records.map(m=>({...m}))}; + else if(format==='text'){extension='txt';mime='text/plain;charset=utf-8';data=preview?'':records.map(m=>`${m.displayName|| (m.role==='user'?'User':'God')} [${m.timestamp}]:\n${m.content}`).join('\n\n');} + else { + const normalized=normalize(records);const turns=normalized.turns; + if(normalized.merged){warnings.push(`${normalized.merged} adjacent same-role messages merged without removing text.`);notices.push('grouped_messages');} + const prompt=system.trim(); + const withSystem=t=>prompt?[{role:'system',content:prompt},...t]:t; + if (format.startsWith('legacy_')) { + warnings.push('Legacy format: use the structured API / messages exports for current models. The complete backup retains every original turn.'); + switch(format) { + case 'legacy_claude':extension='txt';mime='text/plain;charset=utf-8';data=(prompt?'System: '+prompt+'\n\n':'')+turns.map(m=>(m.role==='user'?'Human':'Assistant')+': '+m.content).join('\n\n');break; + case 'legacy_llama': { + if(turns[0]?.role!=='user')throw exportError('user_first','Llama 2 INST format needs an initial user message. Use messages history to retain an assistant-first conversation.'); + extension='txt';mime='text/plain;charset=utf-8'; + data=turns.map((m,i)=>m.role==='user'?'[INST] '+(i===0&&prompt?'<>\n'+prompt+'\n<>\n\n':'')+m.content+' [/INST]':m.content+' ').join('\n');break; + } + case 'legacy_sharegpt':data={conversations:withSystem(turns).map(m=>({from:m.role==='user'?'human':m.role==='assistant'?'gpt':'system',value:m.content}))};break; + case 'legacy_alpaca':data=turns.flatMap((m,i)=>m.role==='user'&&turns[i+1]?.role==='assistant'?[{instruction:prompt||m.content,input:prompt?m.content:'',output:turns[i+1].content}]:[]);if(!data.length)throw exportError('no_answers','Alpaca requires an answered user turn.');warnings.push('Alpaca contains answered question / answer pairs without previous conversation context.');notices.push('pairs_only');break; + default:throw exportError('unknown_format','Unknown legacy format.'); + } + }else if(['sft_openai','sft_messages','sft_sharegpt'].includes(format)){ + if(!Number.isInteger(contextTurns)||contextTurns<0||contextTurns>50)throw exportError('invalid_context','Context must be between 0 and 50 previous exchanges.'); + const leading=turns[0]?.role==='assistant'?1:0; + const training=turns.slice(leading);const rows=[]; + if(leading){warnings.push('The initial assistant-only turn is excluded from training because it has no user prompt. It remains in the full backup.');notices.push('opening_reply');} + if(training.at(-1)?.role==='user'){warnings.push('The final unanswered user turn is excluded from training; it remains in API history and the full backup.');notices.push('unanswered_question');} + for(let i=1;i({...m,...(format==='sft_openai'&&m.role==='assistant'?{weight:j===a.length-1?1:0}:{})})); + if(format==='sft_sharegpt')rows.push({conversations:withSystem(window).map(m=>({from:m.role==='user'?'human':m.role==='assistant'?'gpt':'system',value:m.content}))}); + else rows.push({messages:withSystem(window)}); + } + if(!rows.length)throw exportError('no_answers','Fine-tuning requires at least one user question followed by an assistant answer.'); + if(format==='sft_openai'&&rows.length<10){warnings.push('OpenAI fine-tuning requires at least 10 examples; combine additional reviewed conversations before uploading.');notices.push('too_few_examples');} + examples=rows.length;extension='jsonl';mime='application/jsonl;charset=utf-8';data=preview?'':rows.map(r=>JSON.stringify(r)).join('\n')+'\n'; + warnings.push('Context is limited by exchanges, not tokens. Check the chosen model’s token limit before training.'); + }else{ + if(!turns.length)throw exportError('empty','There are no messages to export.'); + const history=turns.map(m=>({...m})); + if(nextMessage.trim())history.push({role:'user',content:nextMessage.trim()}); + if(history[0]?.role==='assistant'&&format!=='api_responses'&&format!=='history')throw exportError('user_first','This API export needs an initial user message. Restore the first question, or use full backup / messages history.'); + if(format!=='history'&&history.at(-1)?.role!=='user')throw exportError('question_required','To continue with the model, enter your next question below, or choose messages history to export without adding one.'); + const requireModel=()=>{if(!model.trim())throw exportError('model_required','Enter the model ID supplied by your API provider.');return model.trim();}; + switch(format){ + case 'history':data={messages:withSystem(history)};break; + case 'api_chat':data={model:requireModel(),messages:withSystem(history)};break; + case 'api_responses':data={model:requireModel(),input:history,...(prompt?{instructions:prompt}:{})};break; + case 'api_claude':data={model:requireModel(),max_tokens:2048,messages:history,...(prompt?{system:prompt}:{})};break; + case 'api_gemini':data={contents:history.map(m=>({role:m.role==='assistant'?'model':'user',parts:[{text:m.content}]})),...(prompt?{systemInstruction:{parts:[{text:prompt}]}}:{})};break; + default:throw exportError('unknown_format','Unknown export format.'); + } + if(nextMessage.trim())warnings.push('Your next question is added only to this export, not to your saved conversation.'); + } + } + return {content:preview?'':typeof data==='string'?data:JSON.stringify(data,null,2),filename:`cwg-${format}-${date}.${extension}`,mime,warnings,notices,examples,messageCount:records.length}; +} +root.CWGExports={DEFAULT_SYSTEM,validate,normalize,createExport}; +if(typeof module!=='undefined')module.exports=root.CWGExports; +})(globalThis); diff --git a/web/index.template.html b/web/index.template.html new file mode 100644 index 0000000..f73278e --- /dev/null +++ b/web/index.template.html @@ -0,0 +1,2185 @@ + + + + + + + + + + ConversationsWithGod + + + + + + + + + + + +
+ + + + + +
+
+ Install as App +

Add this app to your home screen for a better experience

+
+
+ + +
+
+ + + +
+ +
+ +
Opening conversation…
+ +
+ + +
+
+ + + + + + + + + + + + + + diff --git a/web/storage.js b/web/storage.js new file mode 100644 index 0000000..caeebca --- /dev/null +++ b/web/storage.js @@ -0,0 +1,27 @@ +(function(root){ +'use strict'; +function transaction(db,mode,operation){return new Promise((resolve,reject)=>{const tx=db.transaction('messages',mode);let result;tx.oncomplete=()=>resolve(result);tx.onerror=()=>reject(tx.error||new Error('Storage transaction failed.'));tx.onabort=()=>reject(tx.error||new Error('Storage transaction aborted.'));const req=operation(tx.objectStore('messages'));if(req)req.onsuccess=()=>{result=req.result;};});} +async function openDB(){return new Promise((resolve,reject)=>{const req=indexedDB.open('cwg-history',1);req.onupgradeneeded=()=>req.result.createObjectStore('messages',{keyPath:'id'});req.onsuccess=()=>resolve(req.result);req.onerror=()=>reject(req.error);req.onblocked=()=>reject(new Error('Close another tab to update conversation storage.'));});} +async function openRepository(){ + let legacy,legacyError;try{legacy=localStorage.getItem('chat_messages');}catch(error){legacyError=error;}let db; + try{db=await openDB();}catch(error){ + // A standalone file / private browser may deny IndexedDB. Keep the original storage contract. + if(legacyError)throw legacyError; + const messages=root.CWGExports.validate(legacy?JSON.parse(legacy):[]); + const write=async all=>{await new Promise(resolve=>requestAnimationFrame(()=>setTimeout(resolve,0)));localStorage.setItem('chat_messages',JSON.stringify(all));}; + return {messages,kind:'localStorage',append:(_,all)=>write(all),remove:(_,all)=>write(all),replace:write}; + } + try{ + let stored=await transaction(db,'readonly',store=>store.getAll()); + if(!stored.length&&legacy){ + stored=root.CWGExports.validate(JSON.parse(legacy)); + await transaction(db,'readwrite',store=>{for(const m of stored)store.put(m);}); + // Remove only after the atomic migration succeeds; failures leave the source untouched. + try{localStorage.removeItem('chat_messages');}catch{} + } + stored.sort((a,b)=>a.order-b.order); + return {messages:root.CWGExports.validate(stored,true),kind:'indexedDB',append:m=>transaction(db,'readwrite',store=>store.put(m)),remove:id=>transaction(db,'readwrite',store=>store.delete(id)),replace:messages=>transaction(db,'readwrite',store=>{store.clear();for(const m of messages)store.put(m);})}; + }catch(error){db.close();throw error;} +} +root.CWGStorage={openRepository}; +})(globalThis); diff --git a/web/transcript.js b/web/transcript.js new file mode 100644 index 0000000..5ee7c3f --- /dev/null +++ b/web/transcript.js @@ -0,0 +1,67 @@ +(function(root){ +'use strict'; +// Variable-height virtualization: scroll continuously through the full history; +// only the viewport and a small overscan region have DOM nodes or blur layers. +class Transcript { + constructor(container,createRow){ + this.container=container;this.createRow=createRow;this.messages=[];this.ids=[];this.heights=new Map();this.estimates=new Map();this.prefix=[0];this.range='';this.frame=0;this.pinned=true; + this.top=document.createElement('div');this.window=document.createElement('div');this.bottom=document.createElement('div');this.window.className='message-window'; + this.top.setAttribute('aria-hidden','true');this.bottom.setAttribute('aria-hidden','true');container.replaceChildren(this.top,this.window,this.bottom); + container.addEventListener('scroll',()=>{this.pinned=container.scrollHeight-container.scrollTop-container.clientHeight<48;this.schedule();},{passive:true}); + this.width=container.clientWidth; + this.observer=new ResizeObserver(()=>{if(container.clientWidth!==this.width){this.width=container.clientWidth;this.heights.clear();this.estimates.clear();this.rebuild();this.range='';}this.schedule();});this.observer.observe(container); + } + estimate(m){const font=this.font||16;const columns=this.columns||20;const lines=m.content.split('\n').reduce((n,s)=>n+Math.max(1,Math.ceil(Array.from(s).length/columns)),0);return Math.max(76,lines*font*1.5+62);} + rebuild(){this.font=parseFloat(getComputedStyle(this.container).fontSize)||16;this.columns=Math.max(8,Math.floor((this.width*.85-64)/(this.font*.65)));const next=new Float64Array(this.messages.length+1);for(let i=0;i>>1;if(this.prefix[mid+1]<=y)lo=mid+1;else hi=mid;}return Math.min(lo,Math.max(0,this.messages.length-1));} + setMessages(messages,{bottom=false}={}){ + const anchor=this.index(Math.max(0,this.container.scrollTop-16));const id=this.ids[anchor];const offset=this.container.scrollTop-this.prefix[anchor]; + this.messages=messages;this.ids=messages.map(m=>m.id);this.rebuild();this.range=''; + this.pinned=bottom; + if(bottom)this.container.scrollTop=this.prefix.at(-1); + else if(id){const i=this.ids.indexOf(id);if(i>=0)this.container.scrollTop=this.prefix[i]+offset;} + this.render();if(bottom){this.container.scrollTop=this.container.scrollHeight;this.schedule();} + } + schedule(){if(!this.frame)this.frame=requestAnimationFrame(()=>{this.frame=0;this.render();});} + render(){ + if(!this.messages.length){this.top.style.height=this.bottom.style.height='0px';finishReveals(this.window);this.window.replaceChildren();return;} + const y=Math.max(0,this.container.scrollTop-16),start=Math.max(0,this.index(y)-5),end=Math.min(this.messages.length,this.index(y+this.container.clientHeight)+6,start+100); + const key=start+':'+end; + if(this.range!==key){this.range=key;const nodes=[];for(let i=start;i({id:row.dataset.messageId,height:row.getBoundingClientRect().height+18})); + for(const m of measured)if(Math.abs((this.heights.get(m.id)||0)-m.height)>1){this.heights.set(m.id,m.height);changed=true;} + if(changed){this.rebuild();this.top.style.height=this.prefix[start]+'px';this.bottom.style.height=(this.prefix.at(-1)-this.prefix[end])+'px';this.container.scrollTop=this.pinned?this.container.scrollHeight:this.prefix[anchor]+offset;this.schedule();} + } +} +const activeReveals=new WeakMap(); +const reducedMotion=root.matchMedia('(prefers-reduced-motion: reduce)'); +function finishReveal(element){ + const timer=activeReveals.get(element);if(timer)clearTimeout(timer); + activeReveals.delete(element); + if(element.dataset.reveal!==undefined){element.textContent=element.dataset.reveal;delete element.dataset.reveal;} +} +function finishReveals(container){for(const element of container.querySelectorAll('[data-reveal]'))finishReveal(element);} +function revealText(element,text){ + finishReveal(element); + if(reducedMotion.matches||text.length>800||document.hidden){element.textContent=text;return;} + const glyphs=typeof Intl.Segmenter==='function'?Array.from(new Intl.Segmenter(undefined,{granularity:'grapheme'}).segment(text),s=>s.segment):Array.from(text); + if(!glyphs.length){element.textContent=text;return;} + const groupSize=Math.ceil(glyphs.length/160),groups=Math.ceil(glyphs.length/groupSize); + // Short phrases have a deliberate cadence. Longer passages share a bounded + // six-second reveal budget and at most 160 animated layers. + const lastDelay=groups>1?Math.min(6000,Math.max(700,(glyphs.length-1)*65)):0; + const duration=groups===1?600:420,fragment=document.createDocumentFragment(); + for(let i=0;i1?lastDelay*i/(groups-1):0)+'ms';span.style.animationDuration=duration+'ms';fragment.appendChild(span); + } + element.dataset.reveal=text;element.replaceChildren(fragment); + activeReveals.set(element,setTimeout(()=>finishReveal(element),lastDelay+duration+100)); +} +reducedMotion.addEventListener('change',()=>{if(reducedMotion.matches)finishReveals(document);}); +root.CWGTranscript={Transcript,revealText,finishReveals}; +})(globalThis);