diff --git a/README.en.md b/README.en.md new file mode 100644 index 0000000..1c66314 --- /dev/null +++ b/README.en.md @@ -0,0 +1,63 @@ +

+ ConversationsWithGod icon +

+ +

ConversationsWithGod

+

Talk with yourself. Make room for an inner reply.

+

中文 · English

+

Begin a conversation →

+ +ConversationsWithGod is a quiet space for self-dialogue. On the right, write a question, a feeling or a thought. On the left, take another perspective and answer with your own inner wisdom. + +You write both sides. “God” can mean the divine, your higher self, or simply a clearer, kinder voice within. Give that voice a little room; an answer does not have to arrive immediately. + +

+ English interface with warm grey questions and sage green replies on paper +

+ +## Getting started + +Open the [web app](https://studentiz.github.io/ConversationsWithGod/) and write your first message. The two voices alternate after each send. + +- **Enter** sends; **Shift + Enter** adds a line. On a phone, tap the send button. +- The menu adjusts text size, switches light and dark appearance, exports or clears your conversation. +- You have five seconds to undo a deletion or a cleared conversation. +- The interface follows your browser language, in Chinese or English. + +The first visit loads the page; afterward, it can reopen offline. You can also download [index.html](index.html) and open it directly in a browser. + +## Stay with the moment + +Paper, warm grey questions and sage green replies place the visual focus on the left. Text appears in full, with a gentle upward movement when you send. A translucent scroll indicator appears while scrolling and slowly fades afterward. Multiline drafts have no visible scrollbar. + +Older messages gradually fade and blur, like memories receding into the background. Text and timestamps age together; the originals remain saved and exportable. Fading is a display effect, not encryption or deletion. + +Long conversations render only the area near the screen while keeping the full history continuously accessible. The interface respects the system’s reduced-motion preference. + +## Save or continue a conversation + +“Save a copy…” starts with readable text. Other options appear when you choose a different purpose. + +

+ English save dialog, defaulting to reading and Save text +

+ +| Purpose | Files | +| --- | --- | +| Reading | Plain text | +| Backup | Complete JSON with original text, roles and timestamps | +| Continuing with AI | Portable messages history; OpenAI / DeepSeek / Qwen Chat Completions, OpenAI Responses, Claude Messages and Gemini API requests | +| Fine-tuning | OpenAI, Hugging Face / TRL and ShareGPT JSONL examples | +| Other formats | ShareGPT JSON, Alpaca JSON, legacy Claude text and Llama 2 INST | + +Files are generated on your device. You can use them in a model tool yourself; this app does not call model APIs. See the [export guide](docs/EXPORTS.md) for formats and training examples. + +## Your conversation stays with you + +Conversations are saved in the current browser. There are no accounts, synchronization, analytics or services that upload your dialogue. Clearing browser data can remove your history; export a backup for anything you want to keep. + +## Contributing + +Issues, suggestions and contributions are welcome. See the [development guide](docs/DEVELOPMENT.md) and [validation record](docs/VALIDATION.md). + +If this small space helps you, feel free to share it with someone who could use it. diff --git a/README.md b/README.md index 6676529..29aed9e 100644 --- a/README.md +++ b/README.md @@ -1,126 +1,63 @@ -# ✨ ConversationsWithGod -

- ConversationsWithGod Logo + ConversationsWithGod 图标

-

- "Meaning is not something we find, but something we create." — Alfred Adler -

- -## 🚀 Getting Started - -No installation required! +

ConversationsWithGod

+

和自己说说话,听见内心的回应。

+

中文 · English

+

开始对话 →

-1. Visit [https://studentiz.github.io/ConversationsWithGod/](https://studentiz.github.io/ConversationsWithGod/) -2. Start your conversation -3. Alternate between you (the seeker/student) and your inner wisdom (God) +ConversationsWithGod 是一个安静的自我对话空间。右侧写下你的困惑、念头和提问;左侧,换一个角度,用自己的内在智慧回应。 -Or download the HTML file and open it locally in any browser. - -## 🪞 A Mirror for Your Soul - -ConversationsWithGod is a simple but profound tool for having meaningful conversations with yourself. Frame your thoughts as a dialogue between a seeker and the divine – or however you choose to imagine your higher self. - -Unlike traditional journal apps or AI chatbots, this is a space for genuine self-reflection. You play both parts in the conversation, allowing your own wisdom to emerge through the dialogue. +你扮演对话中的双方。这里的“God”可以是你理解的神、理想中的自己,或心底那个更清醒、更温柔的声音。留一点时间给它,答案不必马上出现。

- Application Screenshot + 中文界面:纸色背景,右侧暖灰色提问,左侧鼠尾草绿色回应

-## 💭 Why This Exists - -> "Know thyself." — Ancient Greek aphorism +## 开始使用 -We all need space to process our thoughts, ask deep questions, and listen to our inner wisdom. This tool provides a structured way to externalize that internal dialogue, making it easier to explore your own beliefs, challenges, and insights. - -## ✨ Features - -- **🍃 Pure Simplicity**: Single HTML file, no installation, works offline -- **🔒 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**: 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 - -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 - -

- Privacy Through Fading -

+打开 [网页版](https://studentiz.github.io/ConversationsWithGod/),写下第一句话即可。每次发送后,角色自动交替。 -The fading display evokes the way human memory changes. It is a visual effect, not encryption or deletion: +- **Enter** 发送,**Shift + Enter** 换行;手机上也可以点发送按钮。 +- 右上角菜单可以调整字号、切换深浅外观、导出或清空对话。 +- 删除一条对话或清空后,5 秒内可以撤销。 +- 界面跟随浏览器语言显示中文或英文。 -- **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 -- **Visual Fading**: The original text and timestamps remain saved and exportable. Both fade together on screen. +首次访问需要加载网页,之后支持离线打开。也可以下载仓库里的 [index.html](index.html),直接用浏览器打开。 -This design reflects how memories and conversations naturally fade with time, providing both privacy and encouraging a focus on the present moment. +## 留在当下 -## 📝 How To Use +纸色背景、暖灰色提问和绿色回应,让目光自然落在左侧。文字完整显示,发送时气泡轻轻上移。滚动指示条在滚动时出现,停下后慢慢淡出;输入框支持换行,也不会露出突兀的滚动条。 -1. **🙋 Start a dialogue**: Type your question or thought as yourself -2. **💫 Respond as wisdom**: The next message automatically switches roles -3. **🔄 Continue the conversation**: Alternate between the two perspectives -4. **⚙️ Adjust settings**: Change font size or toggle dark mode as needed -5. **📤 Export when ready**: Save your conversations for reference or training +较早的对话会逐渐淡化、模糊,像记忆一样退到背景中。文字与时间一起变化;原文仍保留,可以完整导出。淡化是显示效果,并非加密或删除。 -## 🧠 Exporting for AI Training +长对话只渲染屏幕附近的内容,完整历史仍可连续浏览。开启系统的“减少动态效果”后,界面会减少动画。 -Choose a full backup for safekeeping, a request body to continue a dialogue with a model, or a JSONL dataset for fine-tuning. +## 保存与继续对话 -- 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 - -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 - -> "The kingdom of God is within you." — Luke 17:21 - -This tool is built on the belief that we all have access to profound wisdom within ourselves. By creating space for dialogue, we can access parts of our consciousness that often go unheard in the noise of everyday thinking. - -Whether you see this higher wisdom as God, your higher self, universal consciousness, or simply your own deeper insight doesn't matter. What matters is creating the space to listen. - -## ⭐ Share the Light - -> "A candle loses nothing by lighting another candle." — Father James Keller - -In a world filled with noise and distraction, tools for inner reflection become precious sanctuaries. If ConversationsWithGod has helped bring clarity, peace, or insight to your life, consider helping others find it too: - -- **⭐ Star this repository** to show your appreciation and help others discover it -- **🔄 Fork it** if you'd like to build upon it or customize it for your needs -- **🌱 Share it** with friends who might be seeking space for deeper reflection -- **💬 Tell your story** in the repository discussions about how it has helped you - -Every star, fork, and share helps this little light reach more seekers in a digital landscape that often optimizes for engagement over reflection. Together, we can create more spaces for meaningful inner dialogue. +“导出对话”默认保存便于阅读的文本。选择其他用途时,才会出现相应的选项。

- - Star on GitHub - + 中文导出窗口,默认选择阅读并保存文本

-## 👥 Contributing +| 用途 | 可选文件 | +| --- | --- | +| 阅读 | 纯文本 | +| 备份 | 包含原始内容、角色和时间的完整 JSON | +| 交给 AI 继续对话 | 通用 messages 历史;OpenAI / DeepSeek / Qwen Chat Completions、OpenAI Responses、Claude Messages、Gemini API 请求 | +| 微调模型 | OpenAI、Hugging Face / TRL、ShareGPT 的 JSONL 训练样本 | +| 其他格式 | ShareGPT JSON、Alpaca JSON、旧版 Claude 文本与 Llama 2 INST | -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. +文件在设备上生成。你可以自行将它交给模型工具;本应用不调用模型 API。具体格式与训练样本的说明见 [导出指南](docs/EXPORTS.md)。 ---- +## 对话留在你的设备上 -

- "It is not what happens to us that determines our lives, but how we interpret and respond to it." — Alfred Adler -

+对话保存在当前浏览器中,没有账号、同步、分析追踪或上传对话的服务。清理浏览器数据可能会移除历史;需要长期留存的内容,可以导出备份。 -

- Made with ❤️ for seekers everywhere -

+## 参与项目 + +欢迎提出问题、改进建议或贡献代码。构建与验证方式见 [开发说明](docs/DEVELOPMENT.md),已有检查见 [验证记录](docs/VALIDATION.md)。 + +如果这个小空间对你有所帮助,也欢迎把它分享给需要的人。 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 174b355..c782d0d 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -11,7 +11,7 @@ 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. +`web/index.template.html` contains the original paper UI and interaction code. `web/storage.js`, `web/transcript.js`, `web/i18n.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. @@ -23,6 +23,10 @@ Pages migrates the previous `chat_messages` localStorage array into IndexedDB at 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. +Text is inserted in full as one text node. Sending applies a 240 ms upward transform to the new bubble and to visible earlier bubbles displaced by it, with at most 24 moving rows. Scrolling cancels message movement; hidden views and reduced-motion changes finish it immediately. Historical rows do not replay entry animation. + +The conversation keeps native wheel, touch and keyboard scrolling with a custom translucent overlay handle. It is hidden when there is no overflow, appears immediately during scrolling or interaction near the right edge, waits 800 ms after activity, then fades over 600 ms. Dragging, track clicks, Home/End and arrow/page keys are supported. ResizeObserver and one scheduled animation frame update its geometry; there is no polling or permanent decorative animation. Native scrollbar visibility settings cannot reveal a second track. The composer auto-grows to a viewport-limited height and allows caret scrolling without showing a scrollbar; width changes remeasure wrapped drafts. + +Aging is applied to a shared content wrapper so the body and timestamp fade and blur together, while deletion controls stay clear. Language follows the browser; Chinese and English documentation screenshots are captured from isolated example histories with `npm run screenshots`. 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/VALIDATION.md b/docs/VALIDATION.md index 7f17dbe..f4b55e0 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -9,11 +9,17 @@ Local hardware: Apple M4 Pro, macOS 15.8. Chrome runs headlessly at 390 × 844 f 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. +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, complete-text insertion and bounded whole-bubble movement, 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. +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 animated rows, 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. +The current UI regressions run with `npm run test:motion`. A 278 × 497 draft with seven lines previously had `overflow-y: auto`, a 118 px viewport and 188 px of content, exposing a native scrollbar. The revised composer uses hidden overflow, grows within the viewport and preserves all multiline input. Tests edit the end of long drafts at 278 × 497, 320 × 400, 320 × 568 and 844 × 390. Width changes remeasure wrapping on the next animation frame, avoiding mutation during ResizeObserver delivery. + +New messages contain their entire Unicode and multiline text immediately in one text node. A whole-bubble transform lasts 240 ms; reduced motion skips it. Old messages do not replay entry motion during virtual scrolling. Timestamp and text still share their blur layer, while deletion controls remain clear. + +The custom conversation scrollbar is invisible at rest and absent without overflow. Tests observe opacity 1 during scrolling, an intermediate opacity during the 600 ms fade, and 0 after it finishes. They exercise actual pointer dragging, Home/End keyboard navigation and ARIA position updates. The browser retains native wheel and touch scrolling. Chinese and English UI labels, purpose-based export actions, bubble colors and matching documentation screenshots are checked. No added fading or backup-import menu items are present. + +Browser integration was rerun with 100, 3,000 and 10,000 records after this change, including save/reload, complete exports, deletion/undo, offline reopening and narrow/landscape layouts. The 10,000-record case kept 10 live message rows. Opening timings above describe the earlier baseline comparison, not a new cross-device benchmark. diff --git a/icons/apple-touch-icon.png b/icons/apple-touch-icon.png index 9c2540e..6a522c0 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 index 34beeb1..8820a94 100644 Binary files a/icons/favicon-16x16.png and b/icons/favicon-16x16.png differ diff --git a/icons/favicon-32x32.png b/icons/favicon-32x32.png index 9348078..b3c5ffe 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 e06238c..de61aa1 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 03f59f4..0e92605 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 index 5926a2a..bf2e087 100644 --- a/icons/mark.svg +++ b/icons/mark.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/icons/maskable-icon.png b/icons/maskable-icon.png index 7301856..2e8b669 100644 Binary files a/icons/maskable-icon.png and b/icons/maskable-icon.png differ diff --git a/img/en/conversation.png b/img/en/conversation.png new file mode 100644 index 0000000..3c62ee4 Binary files /dev/null and b/img/en/conversation.png differ diff --git a/img/en/dark.png b/img/en/dark.png new file mode 100644 index 0000000..f08e155 Binary files /dev/null and b/img/en/dark.png differ diff --git a/img/en/export.png b/img/en/export.png new file mode 100644 index 0000000..c197f71 Binary files /dev/null and b/img/en/export.png differ diff --git a/img/export-formats1.png b/img/export-formats1.png deleted file mode 100644 index e9ec942..0000000 Binary files a/img/export-formats1.png and /dev/null differ diff --git a/img/export-formats2.png b/img/export-formats2.png deleted file mode 100644 index 65ccf26..0000000 Binary files a/img/export-formats2.png and /dev/null differ diff --git a/img/logo.png b/img/logo.png index 3251a3d..fde0894 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 d95f3f0..f11eea2 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 cce98ad..7c7c7c9 100644 Binary files a/img/screenshot.png and b/img/screenshot.png differ diff --git a/img/zh/conversation.png b/img/zh/conversation.png new file mode 100644 index 0000000..83d4d93 Binary files /dev/null and b/img/zh/conversation.png differ diff --git a/img/zh/dark.png b/img/zh/dark.png new file mode 100644 index 0000000..e76237e Binary files /dev/null and b/img/zh/dark.png differ diff --git a/img/zh/export.png b/img/zh/export.png new file mode 100644 index 0000000..b083f44 Binary files /dev/null and b/img/zh/export.png differ diff --git a/index.html b/index.html index 6e22a32..ac0435d 100644 --- a/index.html +++ b/index.html @@ -51,8 +51,8 @@ --header-background: #f1ebe3; --input-area-background: #f1ebe3; --text-color: #4a4a4a; - --own-message-background: #e6f3e6; - --other-message-background: #f0f0f0; + --own-message-background: #eeebe6; + --other-message-background: #dce7dc; --input-background: #ffffff; --button-color: #7c9c81; --button-text: white; @@ -81,8 +81,8 @@ --header-background: #211e1a; --input-area-background: #211e1a; --text-color: #e8d9c5; - --own-message-background: #3d3426; - --other-message-background: #322b21; + --own-message-background: #39342e; + --other-message-background: #35463b; --input-background: #352e25; --button-color: #7c6e56; --button-text: #f0e6d9; @@ -162,7 +162,7 @@ word-wrap: break-word; -webkit-hyphens: auto; -ms-hyphens: auto; - hyphens: auto; + hyphens: manual; overflow-wrap: break-word; } @@ -991,11 +991,20 @@ } } - /* Preserve the original palette, typography and bubble shapes. */ + /* Paper, warm grey and sage; the reply is the visual focus. */ body { height: 100dvh; } .chat-container { height: 100%; } .chat-header .title { font-family: 'Brush Script MT', 'Segoe Script', cursive; } .chat-messages { display: block; scroll-behavior: auto; min-height: 0; overflow-anchor: none; } + .transcript-shell { flex: 1; min-height: 0; position: relative; overflow: hidden; } + .transcript-shell .chat-messages { height: 100%; overflow-x: hidden; scrollbar-width: none; } + .chat-messages::-webkit-scrollbar, #messageInput::-webkit-scrollbar { display: none; width: 0; height: 0; } + .scroll-indicator { position: absolute; top: 8px; bottom: 8px; right: 2px; width: 18px; opacity: 0; transition: opacity .6s ease-out; pointer-events: none; touch-action: none; border-radius: 10px; outline-offset: -2px; } + .scroll-indicator.visible, .scroll-indicator:focus-visible { opacity: 1; transition: none; pointer-events: auto; } + .scroll-thumb { position: absolute; width: 5px; right: 4px; top: 0; border-radius: 4px; background: rgba(74, 74, 74, .32); } + [data-theme="dark"] .scroll-thumb { background: rgba(248, 245, 241, .34); } + .scroll-indicator:hover .scroll-thumb, .scroll-indicator:focus-visible .scroll-thumb { background: rgba(74, 74, 74, .5); } + [data-theme="dark"] .scroll-indicator:hover .scroll-thumb, [data-theme="dark"] .scroll-indicator:focus-visible .scroll-thumb { background: rgba(248, 245, 241, .52); } .message-window { display: flex; flex-direction: column; gap: 10px; padding-bottom: 10px; } .message { animation: none; transition: none; } .message-text { white-space: pre-wrap; } @@ -1007,7 +1016,7 @@ .notification span { flex: 1; overflow-wrap: anywhere; } .notification button { border: 0; background: none; font: inherit; color: var(--button-color); cursor: pointer; flex-shrink: 0; } .notification .notice-dismiss { color: var(--text-color); font-size: 20px; } - #messageInput { font-family: inherit; resize: none; max-height: 120px; line-height: 1.5; } + #messageInput { font-family: inherit; resize: none; max-height: min(160px, 32dvh); line-height: 1.5; overflow: hidden; scrollbar-width: none; } #sendButton:disabled { opacity: .45; cursor: default; transform: none; } .chat-input-area { align-items: flex-end; padding-bottom: max(12px,env(safe-area-inset-bottom)); } .export-field { display: flex; flex-direction: column; gap: 6px; margin: 14px 0; font-size: 13px; } @@ -1029,12 +1038,10 @@ .modal-dialog { max-height: 88dvh; } .modal-body { overflow-y: auto; min-height: 0; } .sr-only { position: absolute; width: 1px; height: 1px; clip-path: inset(50%); overflow: hidden; } - .revealed-glyph { animation: gentleReveal .42s cubic-bezier(.2,.8,.2,1) both; } - @keyframes gentleReveal { from { opacity: 0; } to { opacity: 1; } } .menu-dropdown { transition: opacity .18s ease-out, transform .22s cubic-bezier(.2,.8,.2,1), visibility .18s; } #sendButton { transition: background-color .18s, transform .16s cubic-bezier(.2,.8,.2,1), opacity .18s; } .modal-overlay { transition: opacity .2s ease-out, visibility .2s; } - @media (prefers-reduced-motion: reduce) { .revealed-glyph { animation: none; } * { scroll-behavior: auto !important; } } + @media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; } } @@ -1087,14 +1094,16 @@ -
+
+
+
Opening conversation…
- + @@ -1241,6 +1250,51 @@ }; })(); + (function(root){ +'use strict'; +const chinese=(navigator.language||'en').startsWith('zh'); +const translations={ + 'Say something…':'说点什么…','Type your message':'输入对话','Send message':'发送','Conversation':'对话', + 'Open menu':'打开菜单','Menu options':'菜单','Change font size':'调整字号','Toggle dark mode':'深色外观','Dark appearance':'深色外观','Light appearance':'浅色外观', + 'Save a copy…':'导出对话…','Clear conversation':'清空对话','Install app':'添加到主屏幕', + 'Install as App':'添加到主屏幕','Add this app to your home screen for a better experience':'从主屏幕打开对话。','Not Now':'稍后','Install':'添加', + 'Opening conversation…':'正在打开对话…','Save conversation':'导出对话','Close save dialog':'关闭导出窗口', + 'Save for':'用途','Reading':'阅读','Backup':'备份','Continuing with AI':'交给 AI 继续对话','Fine-tuning a model':'微调模型','Other formats':'其他格式', + 'Format':'格式','Messages history · JSON':'对话历史 · JSON','Use this file in your AI tool or API client.':'在 AI 工具或 API 客户端中使用此文件。', + 'Model':'模型','Model name from your AI service':'填写模型名称','Next question':'接下来想问什么','Next question (optional)':'接下来想问什么(可选)', + 'What would you like to ask next?':'写下你的下一个问题','Added to this file only.':'只加入导出的文件。','Training tool':'训练工具', + 'ShareGPT · complete JSON':'ShareGPT · 完整 JSON','Claude · Human / Assistant text':'Claude · Human / Assistant 文本','Llama 2 · INST text':'Llama 2 · INST 文本', + 'Options':'选项','Earlier exchanges to include':'包含之前几轮对话','Include earlier questions and replies with each example.':'为每个样本保留之前的问答。', + 'Add instructions for the model':'添加给模型的说明','Instructions for the model':'给模型的说明','How would you like the model to respond?':'希望模型怎样回应?', + 'Cancel':'取消','Save text':'保存文本','Save backup':'保存备份','Save history':'保存对话历史','Save training file':'保存训练文件','Save API request':'保存 API 请求','Save file':'保存文件','Saving…':'正在保存…', + 'Clear Conversation':'清空对话','Close clear conversation dialog':'关闭清空窗口','This removes all messages. You can undo for 5 seconds afterward.':'清空后,5 秒内可以撤销。','Clear':'清空', + 'User: ':'自己:','God: ':'内在智慧:','User':'自己','God':'内在智慧','Delete this message':'删除这条对话','Delete message':'删除对话','Dismiss notification':'关闭提示', + 'Not saved':'尚未保存','Your latest changes couldn’t be saved.':'刚才的内容未能保存。','Save a copy':'导出一份备份','Unable to open saved conversation':'未能打开已保存的对话', + 'Your conversation couldn’t be opened.':'未能打开对话。','Try again':'重试','Message deleted':'已删除','Conversation cleared':'已清空','Undo':'撤销', + 'There isn’t a conversation to save yet.':'还没有可以导出的对话。', + 'This conversation needs a question followed by a reply before it can be used for training.':'先写下一轮完整的问答,再导出训练文件。', + 'This format needs the conversation to start with a question. Choose Messages history to keep every message.':'此格式需要以提问开始。选择“对话历史”可保留所有内容。', + 'Choose a number from 0 to 50 for earlier exchanges in Options.':'请在选项中填写 0 到 50 之间的轮数。', + 'This conversation couldn’t be prepared in that format. Save a backup instead.':'无法导出此格式,可以先保存备份。', + 'Enter the model name supplied by your AI service.':'请填写模型名称。','Enter the question you would like to ask next.':'请写下接下来想问的问题。', + 'This file couldn’t be prepared. Try another format.':'无法生成此文件,请换一种格式。', + 'The file couldn’t be saved. Choose another location and try again.':'文件未能保存,请换一个位置重试。', + 'The opening reply has no preceding question, so it isn’t included.':'开头的回应没有对应提问,未加入训练文件。', + 'Your last question has no reply yet, so it isn’t included.':'最后的提问还没有回应,未加入训练文件。', + 'OpenAI needs at least 10 examples. Add more answered exchanges before training.':'OpenAI 训练至少需要 10 个样本,请再补充几轮完整问答。', + 'This format saves question-and-answer pairs without earlier exchanges.':'此格式只保存问答对。','Consecutive messages from the same voice are grouped together.':'连续的同角色内容会合并。' +}; +function t(text){return chinese?(translations[text]??text):text;} +document.documentElement.lang=chinese?'zh-CN':'en'; +function apply(container){ + const walker=document.createTreeWalker(container,NodeFilter.SHOW_TEXT),nodes=[];let node; + while((node=walker.nextNode()))if(!node.parentElement.closest('script,style'))nodes.push(node); + for(const node of nodes){const key=node.textContent.trim();if(translations[key])node.textContent=node.textContent.replace(key,t(key));} + for(const el of container.querySelectorAll('[placeholder],[aria-label],[title]'))for(const attr of ['placeholder','aria-label','title'])if(el.hasAttribute(attr))el.setAttribute(attr,t(el.getAttribute(attr))); +} +root.CWGI18n={t,apply,chinese}; +})(globalThis); + /* Export formats shared by the standalone HTML and GitHub Pages. */ (function(root) { 'use strict'; @@ -1369,10 +1423,12 @@ // 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.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.animations=new Set();this.motionFrame=0; 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}); + for(const event of ['wheel','touchstart','pointerdown'])container.addEventListener(event,()=>this.stopMotion(),{passive:true}); + reducedMotion.addEventListener('change',()=>{if(reducedMotion.matches)this.stopMotion();}); 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); } @@ -1388,11 +1444,29 @@ this.render();if(bottom){this.container.scrollTop=this.container.scrollHeight;this.schedule();} } schedule(){if(!this.frame)this.frame=requestAnimationFrame(()=>{this.frame=0;this.render();});} + stopMotion(){if(this.motionFrame)cancelAnimationFrame(this.motionFrame);this.motionFrame=0;for(const animation of this.animations)animation.cancel();this.animations.clear();} + append(messages,id){ + this.stopMotion(); + const bounds=this.container.getBoundingClientRect(),previous=new Map(); + for(const row of this.window.children){const rect=row.getBoundingClientRect();if(rect.bottom>bounds.top&&rect.top{ + this.motionFrame=0; + const positions=Array.from(this.window.children).slice(-24).map(row=>({row,y:row.getBoundingClientRect().top})); + for(const {row,y} of positions){ + const fresh=row.dataset.messageId===id,old=previous.get(row.dataset.messageId),delta=fresh?12:old===undefined?0:old-y; + if(!delta||Math.abs(delta)>this.container.clientHeight)continue; + const animation=row.animate([{transform:`translateY(${delta}px)`,opacity:fresh?.65:1},{transform:'translateY(0)',opacity:1}],{duration:240,easing:'cubic-bezier(.2,.8,.2,1)'}); + this.animations.add(animation);animation.finished.then(()=>this.animations.delete(animation),()=>this.animations.delete(animation)); + } + }); + } render(){ - if(!this.messages.length){this.top.style.height=this.bottom.style.height='0px';finishReveals(this.window);this.window.replaceChildren();return;} + if(!this.messages.length){this.top.style.height=this.bottom.style.height='0px';this.stopMotion();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;i800||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); +// The browser keeps native wheel, touch and keyboard scrolling. This overlay +// only draws position and supplies an accessible drag/keyboard handle. +class ScrollIndicator { + constructor(container){ + this.container=container;this.frame=0;this.timer=0;this.drag=null;this.focused=false; + const track=this.track=document.createElement('div'),thumb=this.thumb=document.createElement('div'); + track.className='scroll-indicator';thumb.className='scroll-thumb';track.hidden=true; + track.setAttribute('role','scrollbar');track.setAttribute('aria-controls',container.id);track.setAttribute('aria-orientation','vertical');track.setAttribute('aria-label',document.documentElement.lang.startsWith('zh')?'滚动对话':'Scroll conversation');track.setAttribute('aria-valuemin','0');track.setAttribute('aria-valuemax','100'); + track.appendChild(thumb);container.parentElement.appendChild(track); + container.addEventListener('scroll',()=>{this.schedule();this.show();},{passive:true}); + container.addEventListener('pointermove',e=>{if(e.clientX>=container.getBoundingClientRect().right-24)this.show();},{passive:true}); + track.addEventListener('pointerenter',()=>this.show()); + track.addEventListener('focus',()=>{this.focused=true;this.show();}); + track.addEventListener('blur',()=>{this.focused=false;this.show();}); + track.addEventListener('pointerdown',e=>{ + if(e.button!==0||!this.max)return; + e.preventDefault();this.show();track.setPointerCapture(e.pointerId); + if(e.target!==thumb)this.scrollToRatio((e.clientY-track.getBoundingClientRect().top-this.thumbHeight/2)/this.travel); + this.drag={id:e.pointerId,y:e.clientY,scroll:container.scrollTop}; + }); + track.addEventListener('pointermove',e=>{if(this.drag?.id===e.pointerId){container.scrollTop=this.drag.scroll+(e.clientY-this.drag.y)*this.max/this.travel;this.show();}}); + const end=e=>{if(this.drag?.id===e.pointerId){this.drag=null;this.show();}}; + track.addEventListener('pointerup',end);track.addEventListener('lostpointercapture',end);track.addEventListener('pointercancel',end); + track.addEventListener('keydown',e=>{ + const jumps={ArrowUp:-40,ArrowDown:40,PageUp:-container.clientHeight*.85,PageDown:container.clientHeight*.85}; + if(e.key==='Home')container.scrollTop=0; + else if(e.key==='End')container.scrollTop=this.max; + else if(e.key in jumps)container.scrollTop+=jumps[e.key];else return; + e.preventDefault();this.schedule();this.show(); + }); + this.observer=new ResizeObserver(()=>this.schedule());this.observer.observe(container); + for(const child of container.children)this.observer.observe(child); + this.schedule(); + } + scrollToRatio(ratio){this.container.scrollTop=Math.max(0,Math.min(1,ratio))*this.max;this.schedule();} + schedule(){if(!this.frame)this.frame=requestAnimationFrame(()=>{this.frame=0;this.update();});} + update(){ + const {container,track,thumb}=this;this.max=Math.max(0,container.scrollHeight-container.clientHeight); + const available=this.max>1;track.hidden=!available;track.tabIndex=available?0:-1; + if(!available){this.hide();return;} + const height=track.clientHeight;this.thumbHeight=Math.min(height,Math.max(32,height*container.clientHeight/container.scrollHeight));this.travel=Math.max(1,height-this.thumbHeight); + thumb.style.height=this.thumbHeight+'px';thumb.style.transform=`translateY(${this.travel*container.scrollTop/this.max}px)`; + track.setAttribute('aria-valuenow',String(Math.round(100*container.scrollTop/this.max))); + } + show(){ + if(document.hidden||root.cwgActive===false)return; + this.update();if(this.track.hidden)return; + clearTimeout(this.timer);this.track.classList.add('visible'); + this.timer=setTimeout(()=>{if(!this.drag&&!this.focused)this.track.classList.remove('visible');},800); } - element.dataset.reveal=text;element.replaceChildren(fragment); - activeReveals.set(element,setTimeout(()=>finishReveal(element),lastDelay+duration+100)); + hide(){clearTimeout(this.timer);this.timer=0;this.track.classList.remove('visible');} } -reducedMotion.addEventListener('change',()=>{if(reducedMotion.matches)finishReveals(document);}); -root.CWGTranscript={Transcript,revealText,finishReveals}; +root.CWGTranscript={Transcript,ScrollIndicator}; })(globalThis); document.addEventListener('DOMContentLoaded', function() { + const {t}=CWGI18n; + CWGI18n.apply(document.body); // DOM element references const messageInput = document.getElementById('messageInput'); const sendButton = document.getElementById('sendButton'); @@ -1469,20 +1572,17 @@ let currentFontSize = CWGPreferences.getItem('fontSize') || 'normal'; let nightModeChecker, agingTimer; // Maintenance runs only while visible let deferredPrompt; // Store the install prompt event - const APP_VERSION = '2.0.0'; // Version for cache management + const APP_VERSION = '2.1.0'; // Version for cache management let repository, persistence = Promise.resolve(), storageFailed = false, readFailed = false; let exporting = false; - let nextOrder = 0, revealTargetID = null, undoRecord = null, undoTimer; + let nextOrder = 0, undoRecord = null, undoTimer; const transcript = new CWGTranscript.Transcript(messagesContainer, (msg, index) => { const row = createMessageElement(msg.content, msg.role === 'user', new Date(msg.timestamp), index); row.dataset.messageId = msg.id; - if (msg.id === revealTargetID) { - revealTargetID = null; - CWGTranscript.revealText(row.querySelector('.message-text'), msg.content); - } return row; }); + const scrollbar = new CWGTranscript.ScrollIndicator(messagesContainer); messageInput.disabled = sendButton.disabled = true; messagesContainer.setAttribute('aria-live', 'off'); @@ -1537,7 +1637,7 @@ const messageElement = document.createElement('div'); messageElement.classList.add('message', isOwn ? 'own-message' : 'other-message'); messageElement.setAttribute('role', 'listitem'); - messageElement.setAttribute('aria-label', (isOwn ? 'User: ' : 'God: ') + text); + messageElement.setAttribute('aria-label', t(isOwn ? 'User: ' : 'God: ') + text); // Add timestamp as data attribute for aging calculation messageElement.dataset.timestamp = timestamp.toISOString(); @@ -1556,8 +1656,8 @@ const deleteButton = document.createElement('button'); deleteButton.classList.add('message-delete'); deleteButton.innerHTML = '×'; - deleteButton.setAttribute('aria-label', 'Delete this message'); - deleteButton.setAttribute('title', 'Delete message'); + deleteButton.setAttribute('aria-label', t('Delete this message')); + deleteButton.setAttribute('title', t('Delete message')); // Note: Click handler will be managed by event delegation messageElement.appendChild(deleteButton); @@ -1618,14 +1718,14 @@ clearTimeout(window.notificationTimeout); notification.replaceChildren();notification.dataset.kind=kind; notification.setAttribute('role',kind.endsWith('-error')?'alert':'status'); - const label = document.createElement('span');label.textContent=message; + const label = document.createElement('span');label.textContent=t(message); notification.appendChild(label); if (action) { const button=document.createElement('button');button.className='notice-action'; - button.textContent=action.label;button.onclick=action.run;notification.appendChild(button); + button.textContent=t(action.label);button.onclick=action.run;notification.appendChild(button); } const dismiss=document.createElement('button');dismiss.className='notice-dismiss'; - dismiss.textContent='×';dismiss.setAttribute('aria-label','Dismiss notification'); + dismiss.textContent='×';dismiss.setAttribute('aria-label',t('Dismiss notification')); dismiss.onclick=()=>hideNotification();notification.appendChild(dismiss); notification.classList.add('show');notification.setAttribute('aria-hidden','false'); if (duration>0) window.notificationTimeout=setTimeout(()=>hideNotification(),duration); @@ -1734,7 +1834,7 @@ const currentTheme = document.documentElement.getAttribute('data-theme'); const themeIcon = themeItem.querySelector('.icon'); themeIcon.textContent = currentTheme === 'dark' ? '☀️' : '🌓'; - themeItem.querySelector('span:last-child').textContent=currentTheme==='dark'?'Light appearance':'Dark appearance'; + themeItem.querySelector('span:last-child').textContent=t(currentTheme==='dark'?'Light appearance':'Dark appearance'); } /** @@ -1812,6 +1912,7 @@ } else { setFontSize('small'); } + resizeComposer(); } /** @@ -1913,17 +2014,17 @@ if (!text || !repository || readFailed) return; addMessage(text,isOwnMessage); isOwnMessage = !isOwnMessage; - messageInput.value = ''; messageInput.style.height = ''; sendButton.disabled = true; + messageInput.value = ''; resizeComposer(); sendButton.disabled = true; messageInput.focus(); } function addMessage(text, isOwn) { const msg = {id: crypto.randomUUID(), role:isOwn?'user':'assistant', content:text, displayName:isOwn?'User':'God', timestamp:new Date().toISOString(), order:nextOrder++}; - messages.push(msg); revealTargetID = msg.id; - transcript.setMessages(messages,{bottom:true}); + messages.push(msg); + transcript.append(messages, msg.id); saveMessages('append',msg); - document.getElementById('newMessageAnnouncement').textContent = msg.displayName + ': ' + text; + document.getElementById('newMessageAnnouncement').textContent = t(msg.displayName) + ': ' + text; } function selectedExportFormat() { @@ -1955,12 +2056,12 @@ model_required:'Enter the model name supplied by your AI service.', question_required:'Enter the question you would like to ask next.' }; - return messages[error.code] || 'This file couldn’t be prepared. Try another format.'; + return t(messages[error.code] || 'This file couldn’t be prepared. Try another format.'); } async function exportChat() { if(exporting)return; - exporting=true;confirmExport.disabled=true;confirmExport.textContent='Saving…'; + exporting=true;confirmExport.disabled=true;confirmExport.textContent=t('Saving…'); let failureMessage; try { const result=CWGExports.createExport(messages,exportOptions()); @@ -1985,7 +2086,7 @@ document.getElementById('modelRow').hidden=!api||format==='api_gemini'; model.disabled=!api||format==='api_gemini';model.required=!model.disabled; next.disabled=!api;next.required=api&&messages.at(-1)?.role!=='user'; - document.getElementById('nextQuestionLabel').textContent=next.required?'Next question':'Next question (optional)'; + document.getElementById('nextQuestionLabel').textContent=t(next.required?'Next question':'Next question (optional)'); document.getElementById('trainingOptions').hidden=!training; document.getElementById('exportContext').disabled=!training; exportDetails.hidden=['text','backup'].includes(format); @@ -1993,17 +2094,17 @@ const summary=document.getElementById('exportSummary'),detailSummary=document.getElementById('exportDetailSummary'); summary.setAttribute('role','status');summary.textContent='';detailSummary.textContent=''; const labels={text:'Save text',backup:'Save backup',history:'Save history'}; - confirmExport.textContent=exporting?'Saving…':labels[format]||(training?'Save training file':api?'Save API request':'Save file'); - if(!messages.length){summary.textContent='There isn’t a conversation to save yet.';confirmExport.disabled=true;return;} + confirmExport.textContent=t(exporting?'Saving…':labels[format]||(training?'Save training file':api?'Save API request':'Save file')); + if(!messages.length){summary.textContent=t('There isn’t a conversation to save yet.');confirmExport.disabled=true;return;} try{ const result=CWGExports.createExport(messages,exportOptions(true)),notes=[]; - if(training)notes.push(result.examples+(result.examples===1?' training example.':' training examples.')); + if(training)notes.push(CWGI18n.chinese?`${result.examples} 个训练样本。`:result.examples+(result.examples===1?' training example.':' training examples.')); if(result.notices.includes('opening_reply'))notes.push('The opening reply has no preceding question, so it isn’t included.'); if(result.notices.includes('unanswered_question'))notes.push('Your last question has no reply yet, so it isn’t included.'); if(result.notices.includes('too_few_examples'))notes.push('OpenAI needs at least 10 examples. Add more answered exchanges before training.'); if(result.notices.includes('pairs_only'))notes.push('This format saves question-and-answer pairs without earlier exchanges.'); - summary.textContent=notes.join(' '); - detailSummary.textContent=result.notices.includes('grouped_messages')?'Consecutive messages from the same voice are grouped together.':''; + summary.textContent=notes.map(t).join(' '); + detailSummary.textContent=result.notices.includes('grouped_messages')?t('Consecutive messages from the same voice are grouped together.'):''; confirmExport.disabled=exporting; }catch(error){ if(['model_required','question_required'].includes(error.code)){ @@ -2328,9 +2429,20 @@ /* Browser zoom stays available for accessibility. */ messageInput.addEventListener('input', () => { sendButton.disabled = !repository || !messageInput.value.trim(); - messageInput.style.height = 'auto'; - messageInput.style.height = Math.min(messageInput.scrollHeight,120)+'px'; + resizeComposer(); }); + function resizeComposer() { + const top = messagesContainer.scrollTop, pinned = transcript.pinned; + messageInput.style.height = 'auto'; + messageInput.style.height = (messageInput.scrollHeight + 2) + 'px'; + if(pinned)messagesContainer.scrollTop=messagesContainer.scrollHeight; + else messagesContainer.scrollTop=top; + } + let composerWidth=messageInput.clientWidth; + let composerFrame=0; + new ResizeObserver(()=>{ + if(messageInput.clientWidth!==composerWidth){composerWidth=messageInput.clientWidth;if(!composerFrame)composerFrame=requestAnimationFrame(()=>{composerFrame=0;resizeComposer();});} + }).observe(messageInput); let previewTimer; for (const modal of [exportModal,clearModal]) modal.addEventListener('keydown',e=>{ if(e.key!=='Tab')return; @@ -2350,7 +2462,7 @@ nightModeChecker=setInterval(checkAndApplyNightMode,60000); } } - document.addEventListener('visibilitychange',()=>{if(document.hidden)CWGTranscript.finishReveals(document);updateMessageAging();scheduleMaintenance();}); + document.addEventListener('visibilitychange',()=>{if(document.hidden){transcript.stopMotion();scrollbar.hide();}updateMessageAging();scheduleMaintenance();}); }); diff --git a/package.json b/package.json index 670e7bb..dac09f3 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "test:browser": "node tests/browser.cjs", "build:icons": "node scripts/build-icons.cjs", "benchmark": "node tests/performance.cjs", - "test:motion": "node tests/motion.cjs" + "test:motion": "node tests/motion.cjs", + "screenshots": "node scripts/capture-screenshots.cjs" }, "devDependencies": { "playwright": "^1.58.2", diff --git a/screenshots/screenshot1.png b/screenshots/screenshot1.png index d8f07d6..7c1c86a 100644 Binary files a/screenshots/screenshot1.png and b/screenshots/screenshot1.png differ diff --git a/screenshots/screenshot2.png b/screenshots/screenshot2.png index 78be261..22bc944 100644 Binary files a/screenshots/screenshot2.png and b/screenshots/screenshot2.png differ diff --git a/scripts/build-web.cjs b/scripts/build-web.cjs index 3aac4f7..98d5db6 100644 --- a/scripts/build-web.cjs +++ b/scripts/build-web.cjs @@ -1,7 +1,7 @@ const fs=require('node:fs'),path=require('node:path'); const root=path.resolve(__dirname,'..'); let html=fs.readFileSync(path.join(root,'web/index.template.html'),'utf8'); -for(const [token,file] of [['CWG_EXPORTS','exports.js'],['CWG_STORAGE','storage.js'],['CWG_TRANSCRIPT','transcript.js']]){ +for(const [token,file] of [['CWG_I18N','i18n.js'],['CWG_EXPORTS','exports.js'],['CWG_STORAGE','storage.js'],['CWG_TRANSCRIPT','transcript.js']]){ const script=fs.readFileSync(path.join(root,'web',file),'utf8').replace(/<\/script/gi,'<\\/script'); html=html.replace('/* '+token+' */',()=>script); } diff --git a/scripts/capture-screenshots.cjs b/scripts/capture-screenshots.cjs new file mode 100644 index 0000000..037e12e --- /dev/null +++ b/scripts/capture-screenshots.cjs @@ -0,0 +1,31 @@ +// Capture only isolated example conversations, never a user's browser history. +const fs=require('node:fs'),path=require('node:path'); +const {chromium}=require('playwright'); +const root=path.resolve(__dirname,'..'); +const examples={ + zh:['为什么越忙,越觉得离自己很远?','也许先停一会儿。\n听听此刻的感受,不急着找到答案。','那今天,就从一件小事开始吧。','给自己留一点安静。\n慢慢走,也是在往前走。'], + en:['Why does being busy make me feel so far from myself?','Pause for a moment.\nListen to what you feel, without rushing to find an answer.','Then I can begin with one small thing today.','Leave a little room for quiet.\nA slower step is still a step forward.'] +}; +(async()=>{const chrome='/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';const browser=await chromium.launch({headless:true,...(fs.existsSync(chrome)?{executablePath:chrome}:{})}); + try{for(const lang of ['zh','en']){ + fs.mkdirSync(path.join(root,'img',lang),{recursive:true}); + const context=await browser.newContext({viewport:{width:390,height:740},deviceScaleFactor:2,locale:lang==='zh'?'zh-CN':'en-US',serviceWorkers:'block'}),page=await context.newPage(); + const fixture=examples[lang].map((content,i)=>({content,role:i%2?'assistant':'user',timestamp:`2026-10-11T04:23:${String(i*6).padStart(2,'0')}.000Z`})); + await page.addInitScript(data=>{Date.now=()=>Date.parse('2026-10-11T04:23:30.000Z');localStorage.setItem('theme','light');localStorage.setItem('chat_messages',JSON.stringify(data));},fixture); + await page.goto('file://'+root+'/index.html');await page.waitForFunction(()=>!document.getElementById('messageInput').disabled); + await page.waitForTimeout(1600);await page.locator('#chatMessages').evaluate(el=>el.blur()); + await page.screenshot({path:path.join(root,'img',lang,'conversation.png')}); + await page.locator('#menuButton').click();await page.locator('#exportItem').click();await page.locator('#confirmExport').waitFor({state:'visible'});await page.waitForTimeout(250);await page.locator('#exportPurpose').evaluate(el=>el.blur()); + await page.locator('#exportModal .modal-dialog').screenshot({path:path.join(root,'img',lang,'export.png')}); + await page.locator('#cancelExport').click();await page.locator('#menuButton').click();await page.locator('#themeItem').click();await page.waitForTimeout(550); + await page.screenshot({path:path.join(root,'img',lang,'dark.png')}); + if(lang==='zh'){ + await page.locator('#menuButton').click();await page.locator('#themeItem').click();await page.waitForTimeout(550); + await page.setViewportSize({width:1280,height:720});await page.waitForTimeout(1600);await page.screenshot({path:path.join(root,'screenshots','screenshot1.png'),scale:'css'}); + await page.setViewportSize({width:720,height:1280});await page.waitForTimeout(1600);await page.screenshot({path:path.join(root,'screenshots','screenshot2.png'),scale:'css'}); + await page.setViewportSize({width:390,height:740});await page.waitForTimeout(1600);await page.screenshot({path:path.join(root,'img','screenshot.png')}); + await page.evaluate(()=>{Date.now=()=>Date.parse('2026-10-11T04:33:30.000Z');document.dispatchEvent(new Event('visibilitychange'));});await page.waitForTimeout(400);await page.screenshot({path:path.join(root,'img','privacy-blur.png')}); + } + await context.close(); + }}finally{await browser.close();} +})().catch(e=>{console.error(e);process.exitCode=1}); diff --git a/service-worker.js b/service-worker.js index c1cafa7..f79faec 100644 --- a/service-worker.js +++ b/service-worker.js @@ -1,6 +1,6 @@ // Cache only this application's public assets; conversations remain in IndexedDB. const PREFIX = 'cwg:' + self.registration.scope + ':'; -const CACHE_NAME = PREFIX + '2.0.0'; +const CACHE_NAME = PREFIX + '2.1.0'; const ASSETS = ['./','./index.html','./manifest.json','./icons/favicon-32x32.png','./icons/apple-touch-icon.png','./icons/icon-192x192.png','./icons/icon-512x512.png','./icons/maskable-icon.png']; self.addEventListener('install',event=>event.waitUntil(caches.open(CACHE_NAME).then(cache=>cache.addAll(ASSETS)).then(()=>self.skipWaiting()))); self.addEventListener('activate',event=>event.waitUntil(caches.keys().then(keys=>Promise.all(keys.filter(key=>key.startsWith(PREFIX)&&key!==CACHE_NAME).map(key=>caches.delete(key)))).then(()=>self.clients.claim()))); diff --git a/tests/browser.cjs b/tests/browser.cjs index 7e4e95c..1536514 100644 --- a/tests/browser.cjs +++ b/tests/browser.cjs @@ -8,7 +8,7 @@ const fixture=n=>Array.from({length:n},(_,i)=>({role:i%2?'assistant':'user',cont const chrome=process.env.CHROMIUM_PATH||(fs.existsSync('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome')?'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome':undefined); const browser=await chromium.launch({headless:true,...(chrome?{executablePath:chrome}:{})});const results=[]; const count=page=>page.evaluate(async()=>{const db=await new Promise((r,j)=>{const q=indexedDB.open('cwg-history',1);q.onsuccess=()=>r(q.result);q.onerror=j;});const all=await new Promise(r=>{const q=db.transaction('messages').objectStore('messages').getAll();q.onsuccess=()=>r(q.result);});db.close();return all.length;}); - const open=async(n=0,options={})=>{const context=await browser.newContext({viewport:{width:390,height:844},...options});const page=await context.newPage();await page.addInitScript(data=>{if(!sessionStorage.getItem('seed')){localStorage.setItem('chat_messages',JSON.stringify(data));localStorage.setItem('theme','light');sessionStorage.setItem('seed','1');}},fixture(n));const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto(url);await page.waitForFunction(()=>!document.getElementById('messageInput').disabled);return {context,page,errors};}; + const open=async(n=0,options={})=>{const context=await browser.newContext({locale:'en-US',viewport:{width:390,height:844},...options});const page=await context.newPage();await page.addInitScript(data=>{if(!sessionStorage.getItem('seed')){localStorage.setItem('chat_messages',JSON.stringify(data));localStorage.setItem('theme','light');sessionStorage.setItem('seed','1');}},fixture(n));const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto(url);await page.waitForFunction(()=>!document.getElementById('messageInput').disabled);return {context,page,errors};}; const send=async(page,text)=>{await page.locator('#messageInput').fill(text);await page.locator('#sendButton').click();await page.evaluate(()=>cwgFlush());}; try{ for(const n of [100,3000,10000]){ @@ -30,9 +30,9 @@ const fixture=n=>Array.from({length:n},(_,i)=>({role:i%2?'assistant':'user',cont await page.locator('#menuButton').click();await page.locator('#exportItem').click();await page.locator('#confirmExport').waitFor({state:'visible'});await page.locator('#exportPurpose').selectOption('backup');const downloaded=page.waitForEvent('download');await page.locator('#confirmExport').click();const download=await downloaded;const file=await download.path();const backup=JSON.parse(fs.readFileSync(file,'utf8'));assert.equal(backup.messages.length,n+3);assert.equal(backup.messages[0].content,fixture(n)[0].content); assert.deepEqual(errors,[]);results.push({messages:n,domMessages:await page.locator('.message').count(),bootMs:boot,sendMs:samples,boxes});await context.close(); } - // IME doesn't submit, Shift+Enter retains a newline, character animation ends, - // reduced motion doesn't allocate character spans; no replay during scrolling. - {const {context,page,errors}=await open(0);await page.locator('#messageInput').fill(' 你好 🌙');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,[]); + // IME doesn't submit, Shift+Enter retains a newline, whole-message motion ends, + // reduced motion skips message movement; no replay during scrolling. + {const {context,page,errors}=await open(0);await page.locator('#messageInput').fill(' 你好 🌙');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.equal(await page.locator('.message-text span').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); @@ -40,13 +40,13 @@ const fixture=n=>Array.from({length:n},(_,i)=>({role:i%2?'assistant':'user',cont 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();} + {const context=await browser.newContext({locale:'en-US',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();} + {const context=await browser.newContext({locale:'en-US'});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();} + {const context=await browser.newContext({locale:'en-US'});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. diff --git a/tests/motion.cjs b/tests/motion.cjs index f477e47..4cdbf55 100644 --- a/tests/motion.cjs +++ b/tests/motion.cjs @@ -4,29 +4,50 @@ 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}:{})}); + const open=async(n=0,options={})=>{ + const context=await browser.newContext({viewport:{width:278,height:497},locale:'en-US',serviceWorkers:'block',...options}),page=await context.newPage(); + const data=Array.from({length:n},(_,i)=>({role:i%2?'assistant':'user',content:`Earlier reflection ${i}. A moment to listen.`,timestamp:new Date(Date.now()-600000).toISOString()})); + await page.addInitScript(data=>{localStorage.setItem('theme','light');localStorage.setItem('chat_messages',JSON.stringify(data));},data); + await page.goto('file://'+root+'/index.html');await page.waitForFunction(()=>!document.getElementById('messageInput').disabled);return {context,page}; + }; 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 {context,page}=await open(1); 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.'); + assert.ok(styles.text.length>0);assert.deepEqual(styles.time,styles.text);assert.deepEqual(styles.delete,[]); + for(const size of [{width:278,height:497},{width:320,height:400},{width:320,height:568},{width:844,height:390}]){ + await page.setViewportSize(size);await page.locator('#messageInput').fill('一句话\n另一句话\n'.repeat(40)); + const metrics=await page.locator('#messageInput').evaluate(el=>({overflow:getComputedStyle(el).overflowY,width:el.getBoundingClientRect().right,height:el.getBoundingClientRect().height,text:el.value})); + assert.equal(metrics.overflow,'hidden','No scrollbar even when the draft exceeds the composer height');assert.ok(metrics.width{ + const input=document.getElementById('messageInput'),text='慢慢说,🌙 家人 👨‍👩‍👧‍👦。\n这是完整的内容。';input.value=text;input.dispatchEvent(new Event('input'));document.getElementById('sendButton').click(); + const row=document.querySelector('.message:last-child'),body=row.querySelector('.message-text'); + const immediate={text:body.textContent,expected:text,nodes:body.childNodes.length,spans:body.querySelectorAll('span').length};await new Promise(requestAnimationFrame); + return {...immediate,moving:document.getAnimations().some(a=>a.effect?.target===row)}; + });assert.equal(sent.text,sent.expected);assert.equal(sent.nodes,1);assert.equal(sent.spans,0);assert.ok(sent.moving,'Whole bubble floats up on send'); + await page.waitForTimeout(300);assert.equal(await page.locator('.message-text span').count(),0);assert.equal(await page.evaluate(()=>document.getAnimations().filter(a=>a.effect?.target?.classList?.contains('message')).length),0); + await page.emulateMedia({reducedMotion:'reduce'});await page.locator('#messageInput').fill('静静地出现');await page.locator('#sendButton').click();await page.waitForTimeout(30);assert.equal(await page.evaluate(()=>document.getAnimations().filter(a=>a.effect?.target?.classList?.contains('message')).length),0); + await context.close();console.log('Timestamp blur, narrow multiline editing, complete text and reduced motion passed.'); } - 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.'); + {const {context,page}=await open(100);const track=page.locator('.scroll-indicator');await page.waitForTimeout(1550); + assert.equal(await track.evaluate(el=>Number(getComputedStyle(el).opacity)),0,'No persistent scrollbar at rest'); + assert.equal(await page.locator('#chatMessages').evaluate(el=>getComputedStyle(el).scrollbarWidth),'none'); + await page.locator('#chatMessages').hover();await page.mouse.wheel(0,-220);await page.waitForTimeout(80);assert.equal(await track.evaluate(el=>Number(getComputedStyle(el).opacity)),1); + await page.waitForTimeout(1000);const fading=await track.evaluate(el=>Number(getComputedStyle(el).opacity));assert.ok(fading>0&&fading<1,'Scrollbar slowly fades away');await page.waitForTimeout(500);assert.equal(await track.evaluate(el=>Number(getComputedStyle(el).opacity)),0); + // Visible handle supports actual mouse dragging and keyboard navigation. + await track.focus();await track.press('Home');await page.waitForTimeout(80);assert.equal(await page.locator('#chatMessages').evaluate(el=>el.scrollTop),0); + const thumb=await page.locator('.scroll-thumb').boundingBox();await page.mouse.move(thumb.x+2,thumb.y+10);await page.mouse.down();await page.mouse.move(thumb.x+2,thumb.y+160,{steps:6});await page.mouse.up();assert.ok(await page.locator('#chatMessages').evaluate(el=>el.scrollTop>300)); + await track.press('End');await page.waitForTimeout(150);assert.equal(await track.getAttribute('aria-valuenow'),'100'); + const colors=await page.evaluate(()=>[getComputedStyle(document.querySelector('.other-message')).backgroundColor,getComputedStyle(document.querySelector('.own-message')).backgroundColor]);assert.deepEqual(colors,['rgb(220, 231, 220)','rgb(238, 235, 230)']); + await context.close();console.log('Overlay visibility, gradual fade, dragging, keyboard and palette passed.'); } - 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(); + {const {context,page}=await open();assert.equal(await page.locator('.scroll-indicator').isVisible(),false);await context.close();} + {const {context,page}=await open(2,{locale:'zh-CN'});assert.equal(await page.locator('html').getAttribute('lang'),'zh-CN');assert.equal(await page.locator('#messageInput').getAttribute('placeholder'),'说点什么…');await page.locator('#menuButton').click();assert.match(await page.locator('#exportItem').textContent(),/导出对话…/);await page.locator('#exportItem').click();await page.locator('#confirmExport').waitFor({state:'visible'});assert.equal(await page.locator('#confirmExport').innerText(),'保存文本');await page.locator('#exportPurpose').selectOption('training');assert.equal(await page.locator('#confirmExport').innerText(),'保存训练文件');assert.match(await page.locator('#exportSummary').innerText(),/训练样本/);await context.close();console.log('Actual Chinese interface and export labels passed.');} }finally{await browser.close();} })().catch(error=>{console.error(error);process.exitCode=1;}); diff --git a/web/i18n.js b/web/i18n.js new file mode 100644 index 0000000..9837a17 --- /dev/null +++ b/web/i18n.js @@ -0,0 +1,44 @@ +(function(root){ +'use strict'; +const chinese=(navigator.language||'en').startsWith('zh'); +const translations={ + 'Say something…':'说点什么…','Type your message':'输入对话','Send message':'发送','Conversation':'对话', + 'Open menu':'打开菜单','Menu options':'菜单','Change font size':'调整字号','Toggle dark mode':'深色外观','Dark appearance':'深色外观','Light appearance':'浅色外观', + 'Save a copy…':'导出对话…','Clear conversation':'清空对话','Install app':'添加到主屏幕', + 'Install as App':'添加到主屏幕','Add this app to your home screen for a better experience':'从主屏幕打开对话。','Not Now':'稍后','Install':'添加', + 'Opening conversation…':'正在打开对话…','Save conversation':'导出对话','Close save dialog':'关闭导出窗口', + 'Save for':'用途','Reading':'阅读','Backup':'备份','Continuing with AI':'交给 AI 继续对话','Fine-tuning a model':'微调模型','Other formats':'其他格式', + 'Format':'格式','Messages history · JSON':'对话历史 · JSON','Use this file in your AI tool or API client.':'在 AI 工具或 API 客户端中使用此文件。', + 'Model':'模型','Model name from your AI service':'填写模型名称','Next question':'接下来想问什么','Next question (optional)':'接下来想问什么(可选)', + 'What would you like to ask next?':'写下你的下一个问题','Added to this file only.':'只加入导出的文件。','Training tool':'训练工具', + 'ShareGPT · complete JSON':'ShareGPT · 完整 JSON','Claude · Human / Assistant text':'Claude · Human / Assistant 文本','Llama 2 · INST text':'Llama 2 · INST 文本', + 'Options':'选项','Earlier exchanges to include':'包含之前几轮对话','Include earlier questions and replies with each example.':'为每个样本保留之前的问答。', + 'Add instructions for the model':'添加给模型的说明','Instructions for the model':'给模型的说明','How would you like the model to respond?':'希望模型怎样回应?', + 'Cancel':'取消','Save text':'保存文本','Save backup':'保存备份','Save history':'保存对话历史','Save training file':'保存训练文件','Save API request':'保存 API 请求','Save file':'保存文件','Saving…':'正在保存…', + 'Clear Conversation':'清空对话','Close clear conversation dialog':'关闭清空窗口','This removes all messages. You can undo for 5 seconds afterward.':'清空后,5 秒内可以撤销。','Clear':'清空', + 'User: ':'自己:','God: ':'内在智慧:','User':'自己','God':'内在智慧','Delete this message':'删除这条对话','Delete message':'删除对话','Dismiss notification':'关闭提示', + 'Not saved':'尚未保存','Your latest changes couldn’t be saved.':'刚才的内容未能保存。','Save a copy':'导出一份备份','Unable to open saved conversation':'未能打开已保存的对话', + 'Your conversation couldn’t be opened.':'未能打开对话。','Try again':'重试','Message deleted':'已删除','Conversation cleared':'已清空','Undo':'撤销', + 'There isn’t a conversation to save yet.':'还没有可以导出的对话。', + 'This conversation needs a question followed by a reply before it can be used for training.':'先写下一轮完整的问答,再导出训练文件。', + 'This format needs the conversation to start with a question. Choose Messages history to keep every message.':'此格式需要以提问开始。选择“对话历史”可保留所有内容。', + 'Choose a number from 0 to 50 for earlier exchanges in Options.':'请在选项中填写 0 到 50 之间的轮数。', + 'This conversation couldn’t be prepared in that format. Save a backup instead.':'无法导出此格式,可以先保存备份。', + 'Enter the model name supplied by your AI service.':'请填写模型名称。','Enter the question you would like to ask next.':'请写下接下来想问的问题。', + 'This file couldn’t be prepared. Try another format.':'无法生成此文件,请换一种格式。', + 'The file couldn’t be saved. Choose another location and try again.':'文件未能保存,请换一个位置重试。', + 'The opening reply has no preceding question, so it isn’t included.':'开头的回应没有对应提问,未加入训练文件。', + 'Your last question has no reply yet, so it isn’t included.':'最后的提问还没有回应,未加入训练文件。', + 'OpenAI needs at least 10 examples. Add more answered exchanges before training.':'OpenAI 训练至少需要 10 个样本,请再补充几轮完整问答。', + 'This format saves question-and-answer pairs without earlier exchanges.':'此格式只保存问答对。','Consecutive messages from the same voice are grouped together.':'连续的同角色内容会合并。' +}; +function t(text){return chinese?(translations[text]??text):text;} +document.documentElement.lang=chinese?'zh-CN':'en'; +function apply(container){ + const walker=document.createTreeWalker(container,NodeFilter.SHOW_TEXT),nodes=[];let node; + while((node=walker.nextNode()))if(!node.parentElement.closest('script,style'))nodes.push(node); + for(const node of nodes){const key=node.textContent.trim();if(translations[key])node.textContent=node.textContent.replace(key,t(key));} + for(const el of container.querySelectorAll('[placeholder],[aria-label],[title]'))for(const attr of ['placeholder','aria-label','title'])if(el.hasAttribute(attr))el.setAttribute(attr,t(el.getAttribute(attr))); +} +root.CWGI18n={t,apply,chinese}; +})(globalThis); diff --git a/web/index.template.html b/web/index.template.html index f73278e..6228800 100644 --- a/web/index.template.html +++ b/web/index.template.html @@ -51,8 +51,8 @@ --header-background: #f1ebe3; --input-area-background: #f1ebe3; --text-color: #4a4a4a; - --own-message-background: #e6f3e6; - --other-message-background: #f0f0f0; + --own-message-background: #eeebe6; + --other-message-background: #dce7dc; --input-background: #ffffff; --button-color: #7c9c81; --button-text: white; @@ -81,8 +81,8 @@ --header-background: #211e1a; --input-area-background: #211e1a; --text-color: #e8d9c5; - --own-message-background: #3d3426; - --other-message-background: #322b21; + --own-message-background: #39342e; + --other-message-background: #35463b; --input-background: #352e25; --button-color: #7c6e56; --button-text: #f0e6d9; @@ -162,7 +162,7 @@ word-wrap: break-word; -webkit-hyphens: auto; -ms-hyphens: auto; - hyphens: auto; + hyphens: manual; overflow-wrap: break-word; } @@ -991,11 +991,20 @@ } } - /* Preserve the original palette, typography and bubble shapes. */ + /* Paper, warm grey and sage; the reply is the visual focus. */ body { height: 100dvh; } .chat-container { height: 100%; } .chat-header .title { font-family: 'Brush Script MT', 'Segoe Script', cursive; } .chat-messages { display: block; scroll-behavior: auto; min-height: 0; overflow-anchor: none; } + .transcript-shell { flex: 1; min-height: 0; position: relative; overflow: hidden; } + .transcript-shell .chat-messages { height: 100%; overflow-x: hidden; scrollbar-width: none; } + .chat-messages::-webkit-scrollbar, #messageInput::-webkit-scrollbar { display: none; width: 0; height: 0; } + .scroll-indicator { position: absolute; top: 8px; bottom: 8px; right: 2px; width: 18px; opacity: 0; transition: opacity .6s ease-out; pointer-events: none; touch-action: none; border-radius: 10px; outline-offset: -2px; } + .scroll-indicator.visible, .scroll-indicator:focus-visible { opacity: 1; transition: none; pointer-events: auto; } + .scroll-thumb { position: absolute; width: 5px; right: 4px; top: 0; border-radius: 4px; background: rgba(74, 74, 74, .32); } + [data-theme="dark"] .scroll-thumb { background: rgba(248, 245, 241, .34); } + .scroll-indicator:hover .scroll-thumb, .scroll-indicator:focus-visible .scroll-thumb { background: rgba(74, 74, 74, .5); } + [data-theme="dark"] .scroll-indicator:hover .scroll-thumb, [data-theme="dark"] .scroll-indicator:focus-visible .scroll-thumb { background: rgba(248, 245, 241, .52); } .message-window { display: flex; flex-direction: column; gap: 10px; padding-bottom: 10px; } .message { animation: none; transition: none; } .message-text { white-space: pre-wrap; } @@ -1007,7 +1016,7 @@ .notification span { flex: 1; overflow-wrap: anywhere; } .notification button { border: 0; background: none; font: inherit; color: var(--button-color); cursor: pointer; flex-shrink: 0; } .notification .notice-dismiss { color: var(--text-color); font-size: 20px; } - #messageInput { font-family: inherit; resize: none; max-height: 120px; line-height: 1.5; } + #messageInput { font-family: inherit; resize: none; max-height: min(160px, 32dvh); line-height: 1.5; overflow: hidden; scrollbar-width: none; } #sendButton:disabled { opacity: .45; cursor: default; transform: none; } .chat-input-area { align-items: flex-end; padding-bottom: max(12px,env(safe-area-inset-bottom)); } .export-field { display: flex; flex-direction: column; gap: 6px; margin: 14px 0; font-size: 13px; } @@ -1029,12 +1038,10 @@ .modal-dialog { max-height: 88dvh; } .modal-body { overflow-y: auto; min-height: 0; } .sr-only { position: absolute; width: 1px; height: 1px; clip-path: inset(50%); overflow: hidden; } - .revealed-glyph { animation: gentleReveal .42s cubic-bezier(.2,.8,.2,1) both; } - @keyframes gentleReveal { from { opacity: 0; } to { opacity: 1; } } .menu-dropdown { transition: opacity .18s ease-out, transform .22s cubic-bezier(.2,.8,.2,1), visibility .18s; } #sendButton { transition: background-color .18s, transform .16s cubic-bezier(.2,.8,.2,1), opacity .18s; } .modal-overlay { transition: opacity .2s ease-out, visibility .2s; } - @media (prefers-reduced-motion: reduce) { .revealed-glyph { animation: none; } * { scroll-behavior: auto !important; } } + @media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; } } @@ -1087,14 +1094,16 @@ -
+
+
+
Opening conversation…
- + @@ -1241,10 +1250,13 @@ }; })(); + /* CWG_I18N */ /* CWG_EXPORTS */ /* CWG_STORAGE */ /* CWG_TRANSCRIPT */ document.addEventListener('DOMContentLoaded', function() { + const {t}=CWGI18n; + CWGI18n.apply(document.body); // DOM element references const messageInput = document.getElementById('messageInput'); const sendButton = document.getElementById('sendButton'); @@ -1282,20 +1294,17 @@ let currentFontSize = CWGPreferences.getItem('fontSize') || 'normal'; let nightModeChecker, agingTimer; // Maintenance runs only while visible let deferredPrompt; // Store the install prompt event - const APP_VERSION = '2.0.0'; // Version for cache management + const APP_VERSION = '2.1.0'; // Version for cache management let repository, persistence = Promise.resolve(), storageFailed = false, readFailed = false; let exporting = false; - let nextOrder = 0, revealTargetID = null, undoRecord = null, undoTimer; + let nextOrder = 0, undoRecord = null, undoTimer; const transcript = new CWGTranscript.Transcript(messagesContainer, (msg, index) => { const row = createMessageElement(msg.content, msg.role === 'user', new Date(msg.timestamp), index); row.dataset.messageId = msg.id; - if (msg.id === revealTargetID) { - revealTargetID = null; - CWGTranscript.revealText(row.querySelector('.message-text'), msg.content); - } return row; }); + const scrollbar = new CWGTranscript.ScrollIndicator(messagesContainer); messageInput.disabled = sendButton.disabled = true; messagesContainer.setAttribute('aria-live', 'off'); @@ -1350,7 +1359,7 @@ const messageElement = document.createElement('div'); messageElement.classList.add('message', isOwn ? 'own-message' : 'other-message'); messageElement.setAttribute('role', 'listitem'); - messageElement.setAttribute('aria-label', (isOwn ? 'User: ' : 'God: ') + text); + messageElement.setAttribute('aria-label', t(isOwn ? 'User: ' : 'God: ') + text); // Add timestamp as data attribute for aging calculation messageElement.dataset.timestamp = timestamp.toISOString(); @@ -1369,8 +1378,8 @@ const deleteButton = document.createElement('button'); deleteButton.classList.add('message-delete'); deleteButton.innerHTML = '×'; - deleteButton.setAttribute('aria-label', 'Delete this message'); - deleteButton.setAttribute('title', 'Delete message'); + deleteButton.setAttribute('aria-label', t('Delete this message')); + deleteButton.setAttribute('title', t('Delete message')); // Note: Click handler will be managed by event delegation messageElement.appendChild(deleteButton); @@ -1431,14 +1440,14 @@ clearTimeout(window.notificationTimeout); notification.replaceChildren();notification.dataset.kind=kind; notification.setAttribute('role',kind.endsWith('-error')?'alert':'status'); - const label = document.createElement('span');label.textContent=message; + const label = document.createElement('span');label.textContent=t(message); notification.appendChild(label); if (action) { const button=document.createElement('button');button.className='notice-action'; - button.textContent=action.label;button.onclick=action.run;notification.appendChild(button); + button.textContent=t(action.label);button.onclick=action.run;notification.appendChild(button); } const dismiss=document.createElement('button');dismiss.className='notice-dismiss'; - dismiss.textContent='×';dismiss.setAttribute('aria-label','Dismiss notification'); + dismiss.textContent='×';dismiss.setAttribute('aria-label',t('Dismiss notification')); dismiss.onclick=()=>hideNotification();notification.appendChild(dismiss); notification.classList.add('show');notification.setAttribute('aria-hidden','false'); if (duration>0) window.notificationTimeout=setTimeout(()=>hideNotification(),duration); @@ -1547,7 +1556,7 @@ const currentTheme = document.documentElement.getAttribute('data-theme'); const themeIcon = themeItem.querySelector('.icon'); themeIcon.textContent = currentTheme === 'dark' ? '☀️' : '🌓'; - themeItem.querySelector('span:last-child').textContent=currentTheme==='dark'?'Light appearance':'Dark appearance'; + themeItem.querySelector('span:last-child').textContent=t(currentTheme==='dark'?'Light appearance':'Dark appearance'); } /** @@ -1625,6 +1634,7 @@ } else { setFontSize('small'); } + resizeComposer(); } /** @@ -1726,17 +1736,17 @@ if (!text || !repository || readFailed) return; addMessage(text,isOwnMessage); isOwnMessage = !isOwnMessage; - messageInput.value = ''; messageInput.style.height = ''; sendButton.disabled = true; + messageInput.value = ''; resizeComposer(); sendButton.disabled = true; messageInput.focus(); } function addMessage(text, isOwn) { const msg = {id: crypto.randomUUID(), role:isOwn?'user':'assistant', content:text, displayName:isOwn?'User':'God', timestamp:new Date().toISOString(), order:nextOrder++}; - messages.push(msg); revealTargetID = msg.id; - transcript.setMessages(messages,{bottom:true}); + messages.push(msg); + transcript.append(messages, msg.id); saveMessages('append',msg); - document.getElementById('newMessageAnnouncement').textContent = msg.displayName + ': ' + text; + document.getElementById('newMessageAnnouncement').textContent = t(msg.displayName) + ': ' + text; } function selectedExportFormat() { @@ -1768,12 +1778,12 @@ model_required:'Enter the model name supplied by your AI service.', question_required:'Enter the question you would like to ask next.' }; - return messages[error.code] || 'This file couldn’t be prepared. Try another format.'; + return t(messages[error.code] || 'This file couldn’t be prepared. Try another format.'); } async function exportChat() { if(exporting)return; - exporting=true;confirmExport.disabled=true;confirmExport.textContent='Saving…'; + exporting=true;confirmExport.disabled=true;confirmExport.textContent=t('Saving…'); let failureMessage; try { const result=CWGExports.createExport(messages,exportOptions()); @@ -1798,7 +1808,7 @@ document.getElementById('modelRow').hidden=!api||format==='api_gemini'; model.disabled=!api||format==='api_gemini';model.required=!model.disabled; next.disabled=!api;next.required=api&&messages.at(-1)?.role!=='user'; - document.getElementById('nextQuestionLabel').textContent=next.required?'Next question':'Next question (optional)'; + document.getElementById('nextQuestionLabel').textContent=t(next.required?'Next question':'Next question (optional)'); document.getElementById('trainingOptions').hidden=!training; document.getElementById('exportContext').disabled=!training; exportDetails.hidden=['text','backup'].includes(format); @@ -1806,17 +1816,17 @@ const summary=document.getElementById('exportSummary'),detailSummary=document.getElementById('exportDetailSummary'); summary.setAttribute('role','status');summary.textContent='';detailSummary.textContent=''; const labels={text:'Save text',backup:'Save backup',history:'Save history'}; - confirmExport.textContent=exporting?'Saving…':labels[format]||(training?'Save training file':api?'Save API request':'Save file'); - if(!messages.length){summary.textContent='There isn’t a conversation to save yet.';confirmExport.disabled=true;return;} + confirmExport.textContent=t(exporting?'Saving…':labels[format]||(training?'Save training file':api?'Save API request':'Save file')); + if(!messages.length){summary.textContent=t('There isn’t a conversation to save yet.');confirmExport.disabled=true;return;} try{ const result=CWGExports.createExport(messages,exportOptions(true)),notes=[]; - if(training)notes.push(result.examples+(result.examples===1?' training example.':' training examples.')); + if(training)notes.push(CWGI18n.chinese?`${result.examples} 个训练样本。`:result.examples+(result.examples===1?' training example.':' training examples.')); if(result.notices.includes('opening_reply'))notes.push('The opening reply has no preceding question, so it isn’t included.'); if(result.notices.includes('unanswered_question'))notes.push('Your last question has no reply yet, so it isn’t included.'); if(result.notices.includes('too_few_examples'))notes.push('OpenAI needs at least 10 examples. Add more answered exchanges before training.'); if(result.notices.includes('pairs_only'))notes.push('This format saves question-and-answer pairs without earlier exchanges.'); - summary.textContent=notes.join(' '); - detailSummary.textContent=result.notices.includes('grouped_messages')?'Consecutive messages from the same voice are grouped together.':''; + summary.textContent=notes.map(t).join(' '); + detailSummary.textContent=result.notices.includes('grouped_messages')?t('Consecutive messages from the same voice are grouped together.'):''; confirmExport.disabled=exporting; }catch(error){ if(['model_required','question_required'].includes(error.code)){ @@ -2141,9 +2151,20 @@ /* Browser zoom stays available for accessibility. */ messageInput.addEventListener('input', () => { sendButton.disabled = !repository || !messageInput.value.trim(); - messageInput.style.height = 'auto'; - messageInput.style.height = Math.min(messageInput.scrollHeight,120)+'px'; + resizeComposer(); }); + function resizeComposer() { + const top = messagesContainer.scrollTop, pinned = transcript.pinned; + messageInput.style.height = 'auto'; + messageInput.style.height = (messageInput.scrollHeight + 2) + 'px'; + if(pinned)messagesContainer.scrollTop=messagesContainer.scrollHeight; + else messagesContainer.scrollTop=top; + } + let composerWidth=messageInput.clientWidth; + let composerFrame=0; + new ResizeObserver(()=>{ + if(messageInput.clientWidth!==composerWidth){composerWidth=messageInput.clientWidth;if(!composerFrame)composerFrame=requestAnimationFrame(()=>{composerFrame=0;resizeComposer();});} + }).observe(messageInput); let previewTimer; for (const modal of [exportModal,clearModal]) modal.addEventListener('keydown',e=>{ if(e.key!=='Tab')return; @@ -2163,7 +2184,7 @@ nightModeChecker=setInterval(checkAndApplyNightMode,60000); } } - document.addEventListener('visibilitychange',()=>{if(document.hidden)CWGTranscript.finishReveals(document);updateMessageAging();scheduleMaintenance();}); + document.addEventListener('visibilitychange',()=>{if(document.hidden){transcript.stopMotion();scrollbar.hide();}updateMessageAging();scheduleMaintenance();}); }); diff --git a/web/transcript.js b/web/transcript.js index 5ee7c3f..9e249e2 100644 --- a/web/transcript.js +++ b/web/transcript.js @@ -4,10 +4,12 @@ // 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.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.animations=new Set();this.motionFrame=0; 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}); + for(const event of ['wheel','touchstart','pointerdown'])container.addEventListener(event,()=>this.stopMotion(),{passive:true}); + reducedMotion.addEventListener('change',()=>{if(reducedMotion.matches)this.stopMotion();}); 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); } @@ -23,11 +25,29 @@ class Transcript { this.render();if(bottom){this.container.scrollTop=this.container.scrollHeight;this.schedule();} } schedule(){if(!this.frame)this.frame=requestAnimationFrame(()=>{this.frame=0;this.render();});} + stopMotion(){if(this.motionFrame)cancelAnimationFrame(this.motionFrame);this.motionFrame=0;for(const animation of this.animations)animation.cancel();this.animations.clear();} + append(messages,id){ + this.stopMotion(); + const bounds=this.container.getBoundingClientRect(),previous=new Map(); + for(const row of this.window.children){const rect=row.getBoundingClientRect();if(rect.bottom>bounds.top&&rect.top{ + this.motionFrame=0; + const positions=Array.from(this.window.children).slice(-24).map(row=>({row,y:row.getBoundingClientRect().top})); + for(const {row,y} of positions){ + const fresh=row.dataset.messageId===id,old=previous.get(row.dataset.messageId),delta=fresh?12:old===undefined?0:old-y; + if(!delta||Math.abs(delta)>this.container.clientHeight)continue; + const animation=row.animate([{transform:`translateY(${delta}px)`,opacity:fresh?.65:1},{transform:'translateY(0)',opacity:1}],{duration:240,easing:'cubic-bezier(.2,.8,.2,1)'}); + this.animations.add(animation);animation.finished.then(()=>this.animations.delete(animation),()=>this.animations.delete(animation)); + } + }); + } render(){ - if(!this.messages.length){this.top.style.height=this.bottom.style.height='0px';finishReveals(this.window);this.window.replaceChildren();return;} + if(!this.messages.length){this.top.style.height=this.bottom.style.height='0px';this.stopMotion();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;i800||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); +// The browser keeps native wheel, touch and keyboard scrolling. This overlay +// only draws position and supplies an accessible drag/keyboard handle. +class ScrollIndicator { + constructor(container){ + this.container=container;this.frame=0;this.timer=0;this.drag=null;this.focused=false; + const track=this.track=document.createElement('div'),thumb=this.thumb=document.createElement('div'); + track.className='scroll-indicator';thumb.className='scroll-thumb';track.hidden=true; + track.setAttribute('role','scrollbar');track.setAttribute('aria-controls',container.id);track.setAttribute('aria-orientation','vertical');track.setAttribute('aria-label',document.documentElement.lang.startsWith('zh')?'滚动对话':'Scroll conversation');track.setAttribute('aria-valuemin','0');track.setAttribute('aria-valuemax','100'); + track.appendChild(thumb);container.parentElement.appendChild(track); + container.addEventListener('scroll',()=>{this.schedule();this.show();},{passive:true}); + container.addEventListener('pointermove',e=>{if(e.clientX>=container.getBoundingClientRect().right-24)this.show();},{passive:true}); + track.addEventListener('pointerenter',()=>this.show()); + track.addEventListener('focus',()=>{this.focused=true;this.show();}); + track.addEventListener('blur',()=>{this.focused=false;this.show();}); + track.addEventListener('pointerdown',e=>{ + if(e.button!==0||!this.max)return; + e.preventDefault();this.show();track.setPointerCapture(e.pointerId); + if(e.target!==thumb)this.scrollToRatio((e.clientY-track.getBoundingClientRect().top-this.thumbHeight/2)/this.travel); + this.drag={id:e.pointerId,y:e.clientY,scroll:container.scrollTop}; + }); + track.addEventListener('pointermove',e=>{if(this.drag?.id===e.pointerId){container.scrollTop=this.drag.scroll+(e.clientY-this.drag.y)*this.max/this.travel;this.show();}}); + const end=e=>{if(this.drag?.id===e.pointerId){this.drag=null;this.show();}}; + track.addEventListener('pointerup',end);track.addEventListener('lostpointercapture',end);track.addEventListener('pointercancel',end); + track.addEventListener('keydown',e=>{ + const jumps={ArrowUp:-40,ArrowDown:40,PageUp:-container.clientHeight*.85,PageDown:container.clientHeight*.85}; + if(e.key==='Home')container.scrollTop=0; + else if(e.key==='End')container.scrollTop=this.max; + else if(e.key in jumps)container.scrollTop+=jumps[e.key];else return; + e.preventDefault();this.schedule();this.show(); + }); + this.observer=new ResizeObserver(()=>this.schedule());this.observer.observe(container); + for(const child of container.children)this.observer.observe(child); + this.schedule(); + } + scrollToRatio(ratio){this.container.scrollTop=Math.max(0,Math.min(1,ratio))*this.max;this.schedule();} + schedule(){if(!this.frame)this.frame=requestAnimationFrame(()=>{this.frame=0;this.update();});} + update(){ + const {container,track,thumb}=this;this.max=Math.max(0,container.scrollHeight-container.clientHeight); + const available=this.max>1;track.hidden=!available;track.tabIndex=available?0:-1; + if(!available){this.hide();return;} + const height=track.clientHeight;this.thumbHeight=Math.min(height,Math.max(32,height*container.clientHeight/container.scrollHeight));this.travel=Math.max(1,height-this.thumbHeight); + thumb.style.height=this.thumbHeight+'px';thumb.style.transform=`translateY(${this.travel*container.scrollTop/this.max}px)`; + track.setAttribute('aria-valuenow',String(Math.round(100*container.scrollTop/this.max))); + } + show(){ + if(document.hidden||root.cwgActive===false)return; + this.update();if(this.track.hidden)return; + clearTimeout(this.timer);this.track.classList.add('visible'); + this.timer=setTimeout(()=>{if(!this.drag&&!this.focused)this.track.classList.remove('visible');},800); } - element.dataset.reveal=text;element.replaceChildren(fragment); - activeReveals.set(element,setTimeout(()=>finishReveal(element),lastDelay+duration+100)); + hide(){clearTimeout(this.timer);this.timer=0;this.track.classList.remove('visible');} } -reducedMotion.addEventListener('change',()=>{if(reducedMotion.matches)finishReveals(document);}); -root.CWGTranscript={Transcript,revealText,finishReveals}; +root.CWGTranscript={Transcript,ScrollIndicator}; })(globalThis);