Grow the companion, fix idle fallback, add update banner
Companion: bump the corner mascot ~12% (128→144) and make animation resolution robust — fall back to the idle clip when a mood has no clip of its own, and pin the always-asleep cat to one pose so its Lottie never reloads. This stops a bare emoji from replacing the cute companion on idle. Update notifications: serve a build id at /api/version (a hash of the embedded dist/index.html, which changes on every frontend deploy). The client records its load-time id, polls every 90s (and on tab focus), and floats a Mandarin-first "new version available" banner offering a refresh when the id changes. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
@@ -34,12 +36,26 @@ func main() {
|
|||||||
r.Use(middleware.Logger)
|
r.Use(middleware.Logger)
|
||||||
r.Use(middleware.Recoverer)
|
r.Use(middleware.Recoverer)
|
||||||
|
|
||||||
|
// Build version: a hash of the embedded SPA shell. Vite rewrites index.html
|
||||||
|
// with content-hashed asset names on every build, so this string changes
|
||||||
|
// exactly when a new frontend is deployed — the client polls it to know when
|
||||||
|
// to offer a refresh.
|
||||||
|
version := buildVersion()
|
||||||
|
log.Printf("frontend build version %s", version)
|
||||||
|
|
||||||
r.Route("/api", func(api chi.Router) {
|
r.Route("/api", func(api chi.Router) {
|
||||||
api.Get("/health", func(w http.ResponseWriter, _ *http.Request) {
|
api.Get("/health", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
api.Get("/version", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
// Never cache: a stale cached version would defeat the whole check.
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
_, _ = w.Write([]byte(`{"version":"` + version + `"}`))
|
||||||
|
})
|
||||||
|
|
||||||
llmClient := llm.NewLLMClient(cfg)
|
llmClient := llm.NewLLMClient(cfg)
|
||||||
sug := suggestions.New(database, llmClient)
|
sug := suggestions.New(database, llmClient)
|
||||||
|
|
||||||
@@ -63,6 +79,19 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildVersion derives a short, stable identifier for the currently embedded
|
||||||
|
// frontend by hashing dist/index.html. Vite stamps content-hashed asset names
|
||||||
|
// into that file each build, so the digest is a reliable "did the deploy
|
||||||
|
// change?" signal. Falls back to "dev" when the frontend hasn't been built.
|
||||||
|
func buildVersion() string {
|
||||||
|
data, err := fs.ReadFile(web.DistFS, "dist/index.html")
|
||||||
|
if err != nil {
|
||||||
|
return "dev"
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
return hex.EncodeToString(sum[:])[:12]
|
||||||
|
}
|
||||||
|
|
||||||
// spaHandler serves the embedded web/dist as a single-page app: static files
|
// spaHandler serves the embedded web/dist as a single-page app: static files
|
||||||
// when they exist, falling back to index.html for unknown paths. If the
|
// when they exist, falling back to index.html for unknown paths. If the
|
||||||
// frontend hasn't been built yet, it returns a friendly dev hint instead.
|
// frontend hasn't been built yet, it returns a friendly dev hint instead.
|
||||||
|
|||||||
@@ -7,8 +7,11 @@ import { DocList } from './components/DocList/DocList'
|
|||||||
import { EditorCore, type EditorChange } from './components/Editor/EditorCore'
|
import { EditorCore, type EditorChange } from './components/Editor/EditorCore'
|
||||||
import { StatusBar } from './components/StatusBar/StatusBar'
|
import { StatusBar } from './components/StatusBar/StatusBar'
|
||||||
import { PetalCompanion } from './components/Companion/PetalCompanion'
|
import { PetalCompanion } from './components/Companion/PetalCompanion'
|
||||||
|
import { UpdateBanner } from './components/UpdateBanner/UpdateBanner'
|
||||||
|
import { useVersionWatch } from './hooks/useVersionWatch'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const updateAvailable = useVersionWatch()
|
||||||
const [docs, setDocs] = useState<DocSummary[]>([])
|
const [docs, setDocs] = useState<DocSummary[]>([])
|
||||||
const [currentDoc, setCurrentDoc] = useState<Document | null>(null)
|
const [currentDoc, setCurrentDoc] = useState<Document | null>(null)
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
@@ -250,6 +253,8 @@ export default function App() {
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{updateAvailable && <UpdateBanner />}
|
||||||
|
|
||||||
<PetalCompanion
|
<PetalCompanion
|
||||||
wordCount={wordCount}
|
wordCount={wordCount}
|
||||||
saveStatus={status}
|
saveStatus={status}
|
||||||
|
|||||||
@@ -82,6 +82,10 @@ export const api = {
|
|||||||
req<void>(`/suggestions/${id}/accept`, { method: 'POST' }),
|
req<void>(`/suggestions/${id}/accept`, { method: 'POST' }),
|
||||||
dismissSuggestion: (id: string) =>
|
dismissSuggestion: (id: string) =>
|
||||||
req<void>(`/suggestions/${id}/dismiss`, { method: 'POST' }),
|
req<void>(`/suggestions/${id}/dismiss`, { method: 'POST' }),
|
||||||
|
|
||||||
|
// Current deployed build id — changes whenever a new frontend ships. The
|
||||||
|
// app polls this to offer a refresh. Bypasses any cache so the answer is live.
|
||||||
|
version: () => req<{ version: string }>('/version', { cache: 'no-store' }),
|
||||||
}
|
}
|
||||||
|
|
||||||
// One turn in an Ask Petal conversation. History lives only in the component —
|
// One turn in an Ask Petal conversation. History lives only in the component —
|
||||||
|
|||||||
@@ -44,10 +44,20 @@ export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick }:
|
|||||||
// dozes them, keep their normal idle pose instead of a sleepy face. Only a
|
// dozes them, keep their normal idle pose instead of a sleepy face. Only a
|
||||||
// mascot with a real sleep animation (the always-asleep cat) shows the zzz.
|
// mascot with a real sleep animation (the always-asleep cat) shows the zzz.
|
||||||
const hasSleepClip = Boolean(companion.animations.sleeping)
|
const hasSleepClip = Boolean(companion.animations.sleeping)
|
||||||
const renderMood: Mood =
|
// An always-asleep mascot (the cat) stays pinned to one pose so its Lottie
|
||||||
mood === 'sleeping' && !companion.alwaysAsleep && !hasSleepClip ? 'idle' : mood
|
// never reloads as the engine flips moods underneath it.
|
||||||
|
const renderMood: Mood = companion.alwaysAsleep
|
||||||
|
? 'idle'
|
||||||
|
: mood === 'sleeping' && !hasSleepClip
|
||||||
|
? 'idle'
|
||||||
|
: mood
|
||||||
const napping = companion.alwaysAsleep || (mood === 'sleeping' && hasSleepClip)
|
const napping = companion.alwaysAsleep || (mood === 'sleeping' && hasSleepClip)
|
||||||
|
|
||||||
|
// Never swap the cute companion for a bare emoji on an idle nap: fall back to
|
||||||
|
// the idle animation when a mood has no clip of its own, and only reach for
|
||||||
|
// the emoji if the companion ships no animations at all.
|
||||||
|
const animationData = companion.animations[renderMood] ?? companion.animations.idle
|
||||||
|
|
||||||
function choose(id: string) {
|
function choose(id: string) {
|
||||||
setCompanionId(id)
|
setCompanionId(id)
|
||||||
try {
|
try {
|
||||||
@@ -153,8 +163,8 @@ export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick }:
|
|||||||
aria-label="Choose a companion"
|
aria-label="Choose a companion"
|
||||||
className={`petal-companion pointer-events-auto select-none ${napping ? 'petal-companion-sleep' : ''}`}
|
className={`petal-companion pointer-events-auto select-none ${napping ? 'petal-companion-sleep' : ''}`}
|
||||||
style={{
|
style={{
|
||||||
width: 128,
|
width: 144,
|
||||||
height: 128,
|
height: 144,
|
||||||
padding: 0,
|
padding: 0,
|
||||||
borderRadius: 'var(--radius-pill)',
|
borderRadius: 'var(--radius-pill)',
|
||||||
background: 'var(--color-surface-alt)',
|
background: 'var(--color-surface-alt)',
|
||||||
@@ -167,9 +177,9 @@ export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick }:
|
|||||||
>
|
>
|
||||||
<LottiePlayer
|
<LottiePlayer
|
||||||
key={companion.id}
|
key={companion.id}
|
||||||
animationData={companion.animations[renderMood]}
|
animationData={animationData}
|
||||||
className="h-28 w-28"
|
className="h-32 w-32"
|
||||||
fallback={<span style={{ fontSize: 64, lineHeight: 1 }}>{MOOD_EMOJI[renderMood]}</span>}
|
fallback={<span style={{ fontSize: 72, lineHeight: 1 }}>{MOOD_EMOJI[renderMood]}</span>}
|
||||||
/>
|
/>
|
||||||
{napping && (
|
{napping && (
|
||||||
<span className="petal-zzz absolute" aria-hidden style={{ color: 'var(--color-muted)' }}>
|
<span className="petal-zzz absolute" aria-hidden style={{ color: 'var(--color-muted)' }}>
|
||||||
|
|||||||
64
web/src/components/UpdateBanner/UpdateBanner.tsx
Normal file
64
web/src/components/UpdateBanner/UpdateBanner.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
// UpdateBanner gently floats down from the top when a newer build has been
|
||||||
|
// deployed, inviting a refresh. Mandarin-first copy (north star Note #17), soft
|
||||||
|
// rose styling, and a clear primary action. Dismissable — if she dismisses it,
|
||||||
|
// the next deploy (or reload) will surface a fresh one.
|
||||||
|
export function UpdateBanner() {
|
||||||
|
const [dismissed, setDismissed] = useState(false)
|
||||||
|
if (dismissed) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none fixed inset-x-0 top-4 z-50 flex justify-center px-4">
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className="petal-update pointer-events-auto flex items-center gap-3 py-2.5 pl-4 pr-2.5"
|
||||||
|
style={{
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-pill)',
|
||||||
|
boxShadow: 'var(--shadow-soft)',
|
||||||
|
fontFamily: 'var(--font-ui)',
|
||||||
|
maxWidth: 'min(92vw, 460px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span aria-hidden style={{ fontSize: 20, lineHeight: 1 }}>
|
||||||
|
🌸
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p
|
||||||
|
className="truncate text-sm font-bold leading-snug"
|
||||||
|
style={{ color: 'var(--color-plum)' }}
|
||||||
|
>
|
||||||
|
有新版本啦
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-xs leading-snug" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
A new version is ready — refresh to update.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="shrink-0 rounded-full px-3.5 py-1.5 text-sm font-bold text-white transition-colors"
|
||||||
|
style={{ background: 'var(--color-accent)' }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
||||||
|
>
|
||||||
|
刷新 · Refresh
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDismissed(true)}
|
||||||
|
aria-label="稍后再说 · Dismiss"
|
||||||
|
title="稍后再说 · Dismiss"
|
||||||
|
className="shrink-0 rounded-full px-1.5 text-lg leading-none transition-colors"
|
||||||
|
style={{ color: 'var(--color-muted)' }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-plum)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--color-muted)')}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
52
web/src/hooks/useVersionWatch.ts
Normal file
52
web/src/hooks/useVersionWatch.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { api } from '../api/client'
|
||||||
|
|
||||||
|
// How often to ask the server whether a newer frontend has shipped. Gentle —
|
||||||
|
// a deploy is rare, and the check is a tiny no-store GET.
|
||||||
|
const POLL_MS = 90_000
|
||||||
|
|
||||||
|
// useVersionWatch records the build id the app loaded with, then quietly polls
|
||||||
|
// /api/version. When the server reports a different id, a new version has been
|
||||||
|
// deployed and `updateAvailable` flips true (and stays true) so the UI can
|
||||||
|
// offer a refresh. Network blips are ignored — it only ever reacts to a real,
|
||||||
|
// confirmed change. Returns false until the baseline is established.
|
||||||
|
export function useVersionWatch(): boolean {
|
||||||
|
const [updateAvailable, setUpdateAvailable] = useState(false)
|
||||||
|
// The version we're currently running. Null until the first successful fetch.
|
||||||
|
const baseline = useRef<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
|
||||||
|
const check = async () => {
|
||||||
|
try {
|
||||||
|
const { version } = await api.version()
|
||||||
|
if (!active || !version) return
|
||||||
|
if (baseline.current === null) {
|
||||||
|
baseline.current = version // first read: this is "us"
|
||||||
|
} else if (version !== baseline.current) {
|
||||||
|
setUpdateAvailable(true)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* offline / server bounce — try again next tick, never alarm the user */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
check()
|
||||||
|
const id = setInterval(check, POLL_MS)
|
||||||
|
// Re-check the moment she returns to the tab, so a deploy that happened while
|
||||||
|
// she was away surfaces right away instead of up to POLL_MS later.
|
||||||
|
const onVisible = () => {
|
||||||
|
if (document.visibilityState === 'visible') check()
|
||||||
|
}
|
||||||
|
document.addEventListener('visibilitychange', onVisible)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
clearInterval(id)
|
||||||
|
document.removeEventListener('visibilitychange', onVisible)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return updateAvailable
|
||||||
|
}
|
||||||
@@ -239,6 +239,15 @@ button, a, input {
|
|||||||
100% { opacity: 0; transform: translateY(-14px) scale(1.1); }
|
100% { opacity: 0; transform: translateY(-14px) scale(1.1); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* "New version available" banner — drifts down from the top like a petal. */
|
||||||
|
.petal-update {
|
||||||
|
animation: petal-drop 360ms cubic-bezier(0.2, 0.8, 0.3, 1.2) both;
|
||||||
|
}
|
||||||
|
@keyframes petal-drop {
|
||||||
|
from { opacity: 0; transform: translateY(-14px) scale(0.96); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
/* Blinking caret in the Ask Petal bubble while awaiting the first token. */
|
/* Blinking caret in the Ask Petal bubble while awaiting the first token. */
|
||||||
.petal-chat-caret {
|
.petal-chat-caret {
|
||||||
animation: petal-breathe 1s ease-in-out infinite;
|
animation: petal-breathe 1s ease-in-out infinite;
|
||||||
|
|||||||
Reference in New Issue
Block a user