diff --git a/docs/superpowers/plans/2026-05-24-ai-typewriter-output.md b/docs/superpowers/plans/2026-05-24-ai-typewriter-output.md new file mode 100644 index 0000000..834eaf7 --- /dev/null +++ b/docs/superpowers/plans/2026-05-24-ai-typewriter-output.md @@ -0,0 +1,60 @@ +# AI Typewriter Output Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make all existing AI stream outputs in mini-program, life-script, and web show a themed loading state first, then reveal generated text smoothly one character at a time. + +**Architecture:** Keep backend stream protocol unchanged. Add a small typewriter buffer utility per frontend stack and wire pages/stores to display the typewriter-visible text while retaining the full stream output for saving and final state. + +**Tech Stack:** Vue 3/uni-app, React, Pinia, WebSocket/SSE streaming. + +--- + +### Task 1: Mini Program Typewriter + +**Files:** +- Create: `mini-program/src/composables/useTypewriterStream.js` +- Modify: `mini-program/src/pages/main/ScriptView.vue` +- Modify: `mini-program/src/pages/main/PathView.vue` +- Modify: `mini-program/src/pages/life-event/form.vue` + +- [x] Add a composable with `visibleText`, `isWaiting`, `isStreaming`, `push`, `finish`, `fail`, `reset`, `dispose`. +- [x] In `ScriptView.vue`, push each full stream output to the composable and render `visibleText`. +- [x] Improve the generating panel with themed loading copy and a typing cursor. +- [x] In `PathView.vue` and `life-event/form.vue`, use typewriter visible text for AI output. + +### Task 2: Life Script Typewriter + +**Files:** +- Create: `life-script/src/hooks/useTypewriterStream.js` +- Modify: `life-script/src/views/ScriptView.jsx` +- Modify: `life-script/src/views/PathView.jsx` +- Modify: `life-script/src/views/TimelineView.jsx` + +- [x] Add a React hook with the same buffer behavior. +- [x] Replace direct `setStreamContent(output)` / `setStreamFeedback(output)` calls with `push(output)`. +- [x] Render hook `visibleText` during AI generation. +- [x] Add a subtle glass loading state before the first visible character. + +### Task 3: Web Chat Typewriter + +**Files:** +- Modify: `web/src/stores/chat.ts` + +- [x] Add per-message pending buffers and timer maps inside the chat store. +- [x] On `AI_STREAM_DELTA`, append delta to the buffer and start a timer for the target message. +- [x] On `AI_STREAM_DONE`, mark the message as done only after the pending buffer is empty. +- [x] On error or disconnect, stop timers for affected messages. + +### Task 4: Verification + +**Commands:** +- `cd mini-program && npm run build:mp-weixin` +- `cd life-script && npm run build` +- `cd web && npm run build` +- `git diff --check` + +- [x] Mini-program build passes. +- [x] life-script build passes. +- [x] web build passes. +- [x] Diff check reports no new whitespace errors. diff --git a/docs/superpowers/plans/2026-05-24-mini-program-inspiration-compact-cards.md b/docs/superpowers/plans/2026-05-24-mini-program-inspiration-compact-cards.md new file mode 100644 index 0000000..f855940 --- /dev/null +++ b/docs/superpowers/plans/2026-05-24-mini-program-inspiration-compact-cards.md @@ -0,0 +1,40 @@ +# Mini Program Inspiration Compact Cards Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make inspiration cards on the mini-program script generation page compact, single-line, and ellipsis-truncated while preserving full prompt selection behavior. + +**Architecture:** Reuse the existing `recommendations` data and `useRecommendation(item.text)` click path. Change only the presentation in `ScriptView.vue`: remove visible tag text and tighten the card CSS. + +**Tech Stack:** Vue 3 single-file component, uni-app mini-program build. + +--- + +### Task 1: Compact Inspiration Card Template + +**Files:** +- Modify: `mini-program/src/pages/main/ScriptView.vue` + +- [x] Remove the visible `...` from each `recommend-card`. +- [x] Keep `@click="useRecommendation(item.text)"` unchanged so the full prompt still fills `wishText`. +- [x] Keep the `v-for`, `:key`, `recommend-card`, and `recommend-text` bindings unchanged. + +### Task 2: Single-Line Ellipsis Styling + +**Files:** +- Modify: `mini-program/src/pages/main/ScriptView.vue` + +- [x] Change `.recommend-card` from a tall vertical card to a compact row with `min-height: 68rpx`. +- [x] Use centered row alignment and smaller padding. +- [x] Add `min-width: 0` so text truncation works inside the grid. +- [x] Change `.recommend-text` to one-line ellipsis with `white-space: nowrap`, `overflow: hidden`, and `text-overflow: ellipsis`. +- [x] Remove or neutralize unused `.recommend-tag` styling. + +### Task 3: Validate + +**Commands:** +- `cd mini-program && npm run build:mp-weixin` +- `git diff --check` + +- [x] Mini-program build passes. +- [x] Diff check reports no new whitespace errors. diff --git a/docs/superpowers/plans/2026-05-24-mini-program-script-generation-feedback.md b/docs/superpowers/plans/2026-05-24-mini-program-script-generation-feedback.md new file mode 100644 index 0000000..6851e04 --- /dev/null +++ b/docs/superpowers/plans/2026-05-24-mini-program-script-generation-feedback.md @@ -0,0 +1,60 @@ +# Mini Program Script Generation Feedback Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add animated progress, long-wait guidance, and graceful failure recovery to the mini-program `心愿实现中` screen. + +**Architecture:** Keep all AI stream calls unchanged and add a small local feedback state in `ScriptView.vue`. The screen uses timer thresholds to distinguish normal waiting, slow waiting, very slow waiting, streaming output, and failure. + +**Tech Stack:** Vue 3 single-file component, uni-app mini-program build, existing `useTypewriterStream` composable. + +--- + +### Task 1: Add Generation Feedback State + +**Files:** +- Modify: `mini-program/src/pages/main/ScriptView.vue` + +- [x] Add `generationStatus`, `generationError`, `generationHintIndex`, and `lastSubmitSource` refs. +- [x] Add local timers for rotating hints, 8-second slow state, and 20-second very-slow state. +- [x] Add `startGenerationFeedback`, `clearGenerationFeedbackTimers`, `markGenerationStreaming`, and `markGenerationFailed` helpers. +- [x] Clear timers on success, failure, returning home, and component unmount. + +### Task 2: Update Generation Flow + +**Files:** +- Modify: `mini-program/src/pages/main/ScriptView.vue` + +- [x] Call `startGenerationFeedback()` when `submitWish` enters `viewState = 'generating'`. +- [x] Call `markGenerationStreaming()` in the stream `onDelta` callback before pushing typewriter text. +- [x] On failure, keep `viewState = 'generating'` and show a failure state instead of immediately returning home with only a toast. +- [x] Add `retryGeneration()` and `returnToEdit()` actions for the failure state. + +### Task 3: Update Generation Screen UI + +**Files:** +- Modify: `mini-program/src/pages/main/ScriptView.vue` + +- [x] Replace static `心愿实现中……` copy with computed `generationTitle`. +- [x] Add thinking dots when no stream output has arrived. +- [x] Add a secondary hint line under the main loading copy. +- [x] Add failure actions `再试一次` and `返回修改` when `generationStatus === 'failed'`. + +### Task 4: Add Themed Animations + +**Files:** +- Modify: `mini-program/src/pages/main/ScriptView.vue` + +- [x] Animate `.loading-orbit` with a gentle breathing pulse. +- [x] Animate `.loading-orbit::after` as the small orbiting light. +- [x] Add animated thinking dots inside the system bubble. +- [x] Style the failure copy and actions in the current purple/gold theme. + +### Task 5: Validate + +**Commands:** +- `cd mini-program && npm run build:mp-weixin` +- `git diff --check` + +- [x] Mini-program build passes. +- [x] Diff check reports no new whitespace errors. diff --git a/docs/superpowers/plans/2026-05-24-mini-program-script-home-cosmic-background.md b/docs/superpowers/plans/2026-05-24-mini-program-script-home-cosmic-background.md new file mode 100644 index 0000000..577f877 --- /dev/null +++ b/docs/superpowers/plans/2026-05-24-mini-program-script-home-cosmic-background.md @@ -0,0 +1,49 @@ +# Mini Program Script Home Cosmic Background Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Center the script generation headline and add subtle cosmic background motion without interfering with the page content. + +**Architecture:** Add a decorative background layer inside `ScriptView.vue` and keep all interactive content above it. Use CSS-only planets, star speckles, and meteors so no assets or runtime logic are needed. + +**Tech Stack:** Vue 3 single-file component, uni-app mini-program build, scoped CSS animations. + +--- + +### Task 1: Add Background Layer + +**Files:** +- Modify: `mini-program/src/pages/main/ScriptView.vue` + +- [x] Add a non-interactive `cosmic-background` view as the first child of `.script-view`. +- [x] Add child views for two planets, two star fields, and three meteors. +- [x] Ensure the decorative layer has no event handlers. + +### Task 2: Fix Layering and Headline Alignment + +**Files:** +- Modify: `mini-program/src/pages/main/ScriptView.vue` + +- [x] Set `.script-view` to `position: relative` and `overflow: hidden`. +- [x] Set `.wish-home`, `.generation-view`, and `.result-view` to `position: relative; z-index: 1`. +- [x] Center `.hero-copy` with `justify-content: center` and `text-align: center`. +- [x] Increase the spacing under `.hero-copy` so the inspiration section breathes. + +### Task 3: Add Cosmic Styling + +**Files:** +- Modify: `mini-program/src/pages/main/ScriptView.vue` + +- [x] Style `.cosmic-background` with absolute positioning, low opacity, and `pointer-events: none`. +- [x] Style planet elements with radial gradients and slow drift animations. +- [x] Style meteor elements with diagonal motion and staggered animation delays. +- [x] Style star fields with low-contrast radial gradients. + +### Task 4: Validate + +**Commands:** +- `cd mini-program && npm run build:mp-weixin` +- `git diff --check` + +- [x] Mini-program build passes. +- [x] Diff check reports no new whitespace errors. diff --git a/docs/superpowers/plans/2026-05-24-mini-program-script-home-layout.md b/docs/superpowers/plans/2026-05-24-mini-program-script-home-layout.md new file mode 100644 index 0000000..87d4dac --- /dev/null +++ b/docs/superpowers/plans/2026-05-24-mini-program-script-home-layout.md @@ -0,0 +1,41 @@ +# Mini Program Script Home Layout Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the mini-program script generation home screen keep its headline on one line and place the microphone lower for easier voice input. + +**Architecture:** Reorder existing template blocks in `ScriptView.vue` without changing data flow or event handlers. Adjust the existing hero CSS so the headline is a single non-wrapping row while preserving the current visual theme. + +**Tech Stack:** Vue 3 single-file component, uni-app mini-program build. + +--- + +### Task 1: Reorder Home Screen Blocks + +**Files:** +- Modify: `mini-program/src/pages/main/ScriptView.vue` + +- [x] Move the `inspiration-section` block so it appears immediately after `hero-copy`. +- [x] Keep `wish-input-wrap` after `inspiration-section`. +- [x] Keep `profile-boost` after `wish-input-wrap`. +- [x] Move `orb-wrap` and `voice-copy` after `profile-boost`. +- [x] Verify no event bindings change: `useRecommendation`, `shuffleInspirations`, `submitWish`, `startVoicePress`, `endVoicePress`, `cancelVoicePress`, and `openSocialInsights` remain attached to the same elements. + +### Task 2: Make Headline Single Line + +**Files:** +- Modify: `mini-program/src/pages/main/ScriptView.vue` + +- [x] Update the two headline text nodes into a single row under `.hero-copy`. +- [x] Change `.hero-copy` to a horizontal flex container with `white-space: nowrap`. +- [x] Reduce `.hero-title` font size enough for common mini-program viewport widths. +- [x] Preserve `.hero-highlight` styling. + +### Task 3: Validate + +**Commands:** +- `cd mini-program && npm run build:mp-weixin` +- `git diff --check` + +- [x] Mini-program build passes. +- [x] Diff check reports no new whitespace errors. diff --git a/docs/superpowers/specs/2026-05-24-ai-typewriter-output-design.md b/docs/superpowers/specs/2026-05-24-ai-typewriter-output-design.md new file mode 100644 index 0000000..8512968 --- /dev/null +++ b/docs/superpowers/specs/2026-05-24-ai-typewriter-output-design.md @@ -0,0 +1,50 @@ +# AI 输出逐字显示与等待态优化设计 + +## 目标 + +AI 接口耗时较长时,用户不能在空白页面等待;收到流式输出后,文本不能一大段一大段跳出,而要在当前页面风格下逐字平滑显示。优化范围包括 `mini-program`、`life-script` 和 `web` 中所有现有 AI 流式输出入口。 + +## 范围 + +- 小程序:剧本生成、实现路径生成、人生事件 AI 帮写。 +- life-script:剧本生成、实现路径生成、人生轨迹 AI 反馈。 +- web:聊天 WebSocket AI 流式消息。 +- 不改变后端接口协议,不降低后端流式传输速度。 + +## 方案 + +各端新增轻量 typewriter 控制器。后端仍按当前速度返回 SSE/WebSocket delta,前端把 delta 放入缓冲区,再用固定节奏输出到可见文本。这样既不拖慢真实请求,也能避免一次性展示大段文字。 + +### 输出状态 + +- `waiting`:请求已发出但尚未收到首个 delta,显示动态 loading 和中文提示。 +- `streaming`:已收到输出,逐字显示文本,页面保持可滚动。 +- `draining`:后端已结束但缓冲区还有文字,继续输出剩余文字。 +- `done`:缓冲区清空,进入完成态。 +- `error`:停止逐字输出,保留已显示内容,展示中文错误。 + +### 小程序 + +小程序新增 `useTypewriterStream` composable,返回可见文本、等待状态、输出状态和控制方法。剧本生成页保留当前紫色宇宙风格,生成态增加星轨 loading、阶段文案和逐字正文。路径生成与 AI 帮写复用同一个 composable。 + +### life-script + +life-script 新增 React hook `useTypewriterStream`,把 `onDelta` 的完整输出转为逐字可见输出。剧本、路径、生命事件反馈页面接入该 hook,并保留玻璃拟态视觉风格。 + +### web + +web 聊天 store 在 `AI_STREAM_DELTA` 到达时不再直接追加到 `message.content`,而是写入该消息的 pending buffer。定时器每次取若干字符刷到可见消息,`AI_STREAM_DONE` 只标记后端已完成,等缓冲区清空后再置为 `sent`。 + +## 验证 + +- `mini-program npm run build:mp-weixin` +- `life-script npm run build` +- `web npm run build` +- `git diff --check` + +重点人工验证: + +- AI 首 token 前有动态等待态。 +- 后端一次返回大段文本时,页面仍逐字显示。 +- 后端完成后不会截断缓冲区剩余文本。 +- 出错时保留已显示文本并展示错误。 diff --git a/docs/superpowers/specs/2026-05-24-mini-program-inspiration-compact-cards-design.md b/docs/superpowers/specs/2026-05-24-mini-program-inspiration-compact-cards-design.md new file mode 100644 index 0000000..c8223ef --- /dev/null +++ b/docs/superpowers/specs/2026-05-24-mini-program-inspiration-compact-cards-design.md @@ -0,0 +1,40 @@ +# Mini Program Inspiration Compact Cards Design + +## Goal + +Optimize the mini-program script generation page so inspiration cards use less vertical space while still filling the full selected prompt into the send input. + +## Approved Approach + +Use option A: keep the existing two-column inspiration grid, but make each card a compact single-line candidate. + +## Behavior + +- Each inspiration card displays only `item.text`. +- Displayed text is constrained to one line. +- Overflowing text is hidden with an ellipsis. +- The category/tag chip is not shown in the compact card. +- Clicking a card still calls `useRecommendation(item.text)` and writes the full text into the send input. +- The existing `换一换` action and recommendation source logic remain unchanged. + +## Implementation Notes + +Only `mini-program/src/pages/main/ScriptView.vue` needs to change. + +Update the card template to remove the visible tag node. Update CSS so: + +- `.recommend-card` is a compact row, about `68rpx` high. +- `.recommend-text` uses `white-space: nowrap`, `overflow: hidden`, and `text-overflow: ellipsis`. +- Grid remains two columns. +- Visual theme remains consistent with the current purple glass style. + +## Validation + +Run: + +```bash +cd mini-program +npm run build:mp-weixin +``` + +Expected result: build succeeds with no new errors. diff --git a/docs/superpowers/specs/2026-05-24-mini-program-script-generation-feedback-design.md b/docs/superpowers/specs/2026-05-24-mini-program-script-generation-feedback-design.md new file mode 100644 index 0000000..8c5785b --- /dev/null +++ b/docs/superpowers/specs/2026-05-24-mini-program-script-generation-feedback-design.md @@ -0,0 +1,45 @@ +# Mini Program Script Generation Feedback Design + +## Goal + +Improve the mini-program `心愿实现中` screen so users can clearly tell that generation is running, understand long waits, and recover gently if the AI stream fails or produces no output. + +## Approved Approach + +Use option A: add a scene-friendly loading state with phased guidance and a graceful failure panel. + +## Behavior + +When script generation starts: + +1. The page stays on the current `心愿实现中` screen. +2. The existing chat bubble and orbit motif remain. +3. The orbit gets breathing/rotating animation and small star-dot motion. +4. A rotating hint line appears while no stream text has arrived. +5. If no stream text arrives after about 8 seconds, show a gentle slow-wait message. +6. If no stream text arrives after about 20 seconds, show a warmer network-slow message, while still waiting for the stream. +7. Once stream text arrives, switch to the existing typewriter text output. +8. If the call fails or returns empty output, keep the user on the generation screen and show a soft failure state with `再试一次` and `返回修改`. + +## Implementation Notes + +Only `mini-program/src/pages/main/ScriptView.vue` needs to change. + +Add a small local generation feedback state: + +- `generationStatus`: `idle | waiting | slow | verySlow | streaming | failed` +- `generationError`: user-facing failure copy +- timers for rotating hints and long-wait thresholds + +Do not change backend API calls or stream protocol. `streamAiScene` remains the source of stream output. Existing `useTypewriterStream` continues to render text progressively after the first output arrives. + +## Validation + +Run: + +```bash +cd mini-program +npm run build:mp-weixin +``` + +Expected result: build succeeds with no new errors. diff --git a/docs/superpowers/specs/2026-05-24-mini-program-script-home-cosmic-background-design.md b/docs/superpowers/specs/2026-05-24-mini-program-script-home-cosmic-background-design.md new file mode 100644 index 0000000..aebfd01 --- /dev/null +++ b/docs/superpowers/specs/2026-05-24-mini-program-script-home-cosmic-background-design.md @@ -0,0 +1,42 @@ +# Mini Program Script Home Cosmic Background Design + +## Goal + +Polish the mini-program script generation home page so the headline is centered with comfortable spacing, and the page background gains subtle cosmic motion that matches the current purple/gold style. + +## Approved Approach + +Use option A: a restrained cosmic background. + +## Behavior + +- Center the headline `今天有什么心愿想实现`. +- Keep the headline on one line. +- Add a slightly larger gap between the headline and the inspiration section. +- Add background-only cosmic elements: + - a distant planet near the upper-right edge, + - a faint smaller planet near the lower-left edge, + - a few animated meteors moving diagonally, + - subtle star speckles. +- Background elements must stay behind page content and must not affect tapping inputs, inspiration cards, buttons, or the microphone. + +## Implementation Notes + +Only `mini-program/src/pages/main/ScriptView.vue` needs to change. + +Add a `cosmic-background` layer as the first child of `.script-view`. Set it to `position: absolute`, `inset: 0`, `pointer-events: none`, and a lower `z-index`. + +Set `.script-view` to `position: relative` and `overflow: hidden`. Set `.wish-home`, `.generation-view`, and `.result-view` to `position: relative; z-index: 1;`. + +Use CSS-only animation for meteors and slow planet drift. Keep opacity low so content remains readable. + +## Validation + +Run: + +```bash +cd mini-program +npm run build:mp-weixin +``` + +Expected result: build succeeds with no new errors. diff --git a/docs/superpowers/specs/2026-05-24-mini-program-script-home-layout-design.md b/docs/superpowers/specs/2026-05-24-mini-program-script-home-layout-design.md new file mode 100644 index 0000000..b0ccdfc --- /dev/null +++ b/docs/superpowers/specs/2026-05-24-mini-program-script-home-layout-design.md @@ -0,0 +1,39 @@ +# Mini Program Script Home Layout Design + +## Goal + +Optimize the mini-program script generation home screen so the headline stays on one line and voice input is easier to reach. + +## Approved Layout + +Use the approved A layout: + +1. Keep the headline text `今天有什么 心愿 想实现` on a single line. +2. Move the full `灵感一下` section above the input and voice area. +3. Move the wish text input, social insight binding card, and press-to-talk microphone below the inspiration section. +4. Keep the existing visual theme, colors, copy, analytics behavior, voice events, and generation flow unchanged. + +## Implementation Notes + +Only `mini-program/src/pages/main/ScriptView.vue` needs to change. + +The template should reorder existing blocks instead of recreating them: + +- Header and headline remain at the top. +- Inspiration section follows the headline. +- Wish input follows inspiration. +- Social insight card follows the input. +- Microphone orb and voice copy sit at the bottom of the home content. + +CSS should make the headline a single flex row with no wrapping. The text must remain readable on normal mini-program widths, so the headline font size should be reduced from the current two-line hero scale to a single-line scale with `white-space: nowrap`. + +## Validation + +Run: + +```bash +cd mini-program +npm run build:mp-weixin +``` + +Expected result: build succeeds with no new errors. diff --git a/life-script/src/hooks/useTypewriterStream.js b/life-script/src/hooks/useTypewriterStream.js new file mode 100644 index 0000000..8fa42de --- /dev/null +++ b/life-script/src/hooks/useTypewriterStream.js @@ -0,0 +1,131 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +export const useTypewriterStream = ({ interval = 18, step = 1 } = {}) => { + const [visibleText, setVisibleText] = useState(''); + const [targetText, setTargetText] = useState(''); + const [received, setReceived] = useState(false); + const [backendDone, setBackendDone] = useState(false); + const [failed, setFailed] = useState(false); + const visibleRef = useRef(''); + const targetRef = useRef(''); + const doneRef = useRef(false); + const failedRef = useRef(false); + const timerRef = useRef(null); + const waitersRef = useRef([]); + + const stopTimer = useCallback(() => { + if (timerRef.current) { + window.clearInterval(timerRef.current); + timerRef.current = null; + } + }, []); + + const resolveWaiters = useCallback(() => { + if (!waitersRef.current.length) return; + const waiters = waitersRef.current; + waitersRef.current = []; + waiters.forEach(resolve => resolve(visibleRef.current)); + }, []); + + const isFullyRendered = useCallback(() => { + return doneRef.current && visibleRef.current.length >= targetRef.current.length; + }, []); + + const tick = useCallback(() => { + if (visibleRef.current.length < targetRef.current.length) { + const nextLength = Math.min(targetRef.current.length, visibleRef.current.length + step); + const nextText = targetRef.current.slice(0, nextLength); + visibleRef.current = nextText; + setVisibleText(nextText); + return; + } + if (doneRef.current || failedRef.current) { + stopTimer(); + if (isFullyRendered() || failedRef.current) resolveWaiters(); + } + }, [isFullyRendered, resolveWaiters, step, stopTimer]); + + const ensureTimer = useCallback(() => { + if (!timerRef.current) timerRef.current = window.setInterval(tick, interval); + }, [interval, tick]); + + const reset = useCallback(() => { + stopTimer(); + visibleRef.current = ''; + targetRef.current = ''; + doneRef.current = false; + failedRef.current = false; + setVisibleText(''); + setTargetText(''); + setReceived(false); + setBackendDone(false); + setFailed(false); + resolveWaiters(); + }, [resolveWaiters, stopTimer]); + + const push = useCallback((nextText = '') => { + const next = String(nextText || ''); + if (next.length < visibleRef.current.length) reset(); + targetRef.current = next; + setTargetText(next); + setReceived(true); + ensureTimer(); + }, [ensureTimer, reset]); + + const finish = useCallback((finalText) => { + if (typeof finalText === 'string') { + targetRef.current = finalText; + setTargetText(finalText); + } + doneRef.current = true; + setBackendDone(true); + if (targetRef.current.length > visibleRef.current.length) ensureTimer(); + else { + stopTimer(); + resolveWaiters(); + } + }, [ensureTimer, resolveWaiters, stopTimer]); + + const fail = useCallback((message) => { + failedRef.current = true; + doneRef.current = true; + setFailed(true); + setBackendDone(true); + if (!visibleRef.current && message) { + visibleRef.current = message; + targetRef.current = message; + setVisibleText(message); + setTargetText(message); + } + stopTimer(); + resolveWaiters(); + }, [resolveWaiters, stopTimer]); + + const waitForDone = useCallback(() => { + if (isFullyRendered() || failedRef.current) { + return Promise.resolve(visibleRef.current); + } + return new Promise(resolve => { + waitersRef.current.push(resolve); + ensureTimer(); + }); + }, [ensureTimer, isFullyRendered]); + + useEffect(() => stopTimer, [stopTimer]); + + return useMemo(() => ({ + visibleText, + targetText, + isWaiting: !received && !backendDone && !failed, + isStreaming: received && (visibleText.length < targetText.length || !backendDone), + isDraining: backendDone && visibleText.length < targetText.length, + isDone: backendDone && visibleText.length >= targetText.length && !failed, + reset, + push, + finish, + waitForDone, + fail + }), [backendDone, failed, finish, push, received, reset, targetText, visibleText, waitForDone, fail]); +}; + +export default useTypewriterStream; diff --git a/mini-program/src/composables/useTypewriterStream.js b/mini-program/src/composables/useTypewriterStream.js new file mode 100644 index 0000000..587e776 --- /dev/null +++ b/mini-program/src/composables/useTypewriterStream.js @@ -0,0 +1,125 @@ +import { computed, ref } from 'vue' + +export const useTypewriterStream = ({ interval = 24, step = 1 } = {}) => { + const visibleText = ref('') + const targetText = ref('') + const received = ref(false) + const backendDone = ref(false) + const failed = ref(false) + let timer = null + let waiters = [] + + const isWaiting = computed(() => !received.value && !backendDone.value && !failed.value) + const isStreaming = computed(() => received.value && (visibleText.value.length < targetText.value.length || !backendDone.value)) + const isDraining = computed(() => backendDone.value && visibleText.value.length < targetText.value.length) + const isDone = computed(() => backendDone.value && visibleText.value.length >= targetText.value.length && !failed.value) + + const stopTimer = () => { + if (timer) { + clearInterval(timer) + timer = null + } + } + + const resolveWaiters = () => { + if (!waiters.length) return + const currentWaiters = waiters + waiters = [] + currentWaiters.forEach(resolve => resolve(visibleText.value)) + } + + const isFullyRendered = () => { + return backendDone.value && visibleText.value.length >= targetText.value.length + } + + const tick = () => { + if (visibleText.value.length < targetText.value.length) { + const nextLength = Math.min(targetText.value.length, visibleText.value.length + step) + visibleText.value = targetText.value.slice(0, nextLength) + return + } + if (backendDone.value || failed.value) { + stopTimer() + if (isFullyRendered() || failed.value) { + resolveWaiters() + } + } + } + + const ensureTimer = () => { + if (!timer) { + timer = setInterval(tick, interval) + } + } + + const reset = () => { + stopTimer() + visibleText.value = '' + targetText.value = '' + received.value = false + backendDone.value = false + failed.value = false + resolveWaiters() + } + + const push = (nextText = '') => { + const next = String(nextText || '') + if (next.length < visibleText.value.length) { + reset() + } + received.value = true + targetText.value = next + ensureTimer() + } + + const finish = (finalText) => { + if (typeof finalText === 'string') { + targetText.value = finalText + } + backendDone.value = true + if (targetText.value.length > visibleText.value.length) { + ensureTimer() + } else { + stopTimer() + resolveWaiters() + } + } + + const fail = (message) => { + failed.value = true + backendDone.value = true + if (!visibleText.value && message) { + visibleText.value = message + targetText.value = message + } + stopTimer() + resolveWaiters() + } + + const waitForDone = () => { + if (isFullyRendered() || failed.value) { + return Promise.resolve(visibleText.value) + } + return new Promise(resolve => { + waiters.push(resolve) + ensureTimer() + }) + } + + return { + visibleText, + targetText, + isWaiting, + isStreaming, + isDraining, + isDone, + push, + finish, + waitForDone, + fail, + reset, + dispose: stopTimer + } +} + +export default useTypewriterStream