Select2 submits raw text as the value for new tags typed into the tag
box (tags: true + createTag in link_form.html). The previous
ModelMultipleChoiceField silently dropped those values because they
were not PKs of existing tags, so new tags were never persisted.
Add TagInputField, a tolerant ModelMultipleChoiceField subclass:
- existing tag PKs resolve as before
- raw text is treated as a new (or case-insensitive-existing) tag
- names are stripped and lowercased before get_or_create so the tag
namespace stays consistent and case-insensitive duplicates are
avoided. The Tag.save() hook still derives the slug.
LinkForm now uses TagInputField for its tags field, so both
LinkCreateView and LinkUpdateView pick up the new behaviour. The
Select2 createTag callback in link_form.html now lowercases the term
so users see immediately what will be stored.
Add 7 regression tests in TestLinkTagAutoCreate covering: new tag
created + lowercased; multiple new tags; existing tag reused (no
duplicate); mixed PK + new-text submission; no tags submitted; empty
or whitespace-only submission ignored; whitespace stripped.
Add integration tests covering the price-drop → Telegram alert flow:
- Sends Telegram MarkdownV2 message to global SiteSettings chat_id when
detected price <= watcher.alert_price_threshold.
- No alert when global creds missing.
- No alert when price above threshold.
- No alert when threshold unset on watcher.
- Non-recurring watcher with an undismissed alert is not re-notified.
- Telegram API failure does not break the check (snapshot + alert row
still persist, last_price updates).
AlertsPartialView (htmx-polled every 30s from the dashboard) returned
500 because _alerts.html used {% trans %} without {% load i18n %}.
Added the load tag so the partial renders correctly.
The dashboard's inline {% include 'pricemon/_alerts.html' %} also
relied on an undefined 'alerts' context var (DashboardView sets
'active_alerts'), so the alerts panel rendered empty even when alerts
existed. Pass alerts=active_alerts explicitly via the include.
Add regression tests covering both the dashboard render with an alert
and the alerts partial endpoint.
- Add responsive card layout for mobile (<640px) as alternative to desktop table
- Desktop: table hidden below sm breakpoint, improved col widths
- Mobile: each file as a card with filename, metadata grid (2x2), public status, actions
- Keep table headers accessible via <caption>
- Fix video seek-bar reset bug: change <template x-if> to x-show so
video/audio elements stay mounted and playback state isn't lost
on Alpine re-render
- Use a thinking/running face while speech audio is being generated
- Switch to speaking only when playback actually begins
- Add quota-safe history persistence that strips audio from older entries
instead of crashing localStorage writes
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Show a dedicated '生成语音中…' state while TTS is being generated
- Only switch to '播放中…' once audio is actually ready/playing
- Strip markdown formatting from the spoken text while preserving the raw
assistant message in the chat UI
- Render assistant messages as lightweight markdown in the bubble view
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Fixes:
1. Race condition: after await startRec(), check isSpaceDownRef again —
fast-tap released before mic ready was leaving phase stuck at 'listening'
2. Global window mouseup/touchend/touchcancel listeners as fallback so
releasing outside the button boundary always triggers handleSpaceUp
3. Show ✕ cancel button during ALL non-idle phases (was only thinking/speaking)
4. handleCancel now stops the recorder if called during listening/acquiring
5. Safety auto-cancel timeout: if stuck in 'listening' > 60s, auto-stop
6. Add stopRec to handleSpaceDown dep array
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- VoiceButton gets a red ✕ cancel button that appears during thinking/speaking
- AbortController per pipeline run — ✕ aborts the in-flight apiChat fetch,
stops TTS playback, and returns to idle cleanly
- Hermes connection-refused error shows readable message instead of raw stack
- apiChat() accepts optional AbortSignal (4th arg), passed from App.jsx
- handleSpaceUp dep array updated
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
In Hermes mode JBOT is a pure voice UI — memory is owned by the Agent:
- Backend: sys prompt excludes local memories when backend_mode=hermes
- Backend: config_view now returns backendMode so the frontend can adapt
- Frontend: App.jsx loads backendMode on mount via apiConfig()
- Frontend: memories not passed to apiChat() when isHermesMode
- Frontend: memory extraction (apiExtractMemory) skipped when isHermesMode
- Frontend: MemoryPanel hidden in Hermes mode; replaced by subtle
'🧠 Hermes Agent 模式 · 记忆由 Agent 管理' badge
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- New DB fields: hermes_url, hermes_api_key, backend_mode (openrouter/hermes)
- Migration 0002 adds the three fields to JbotApiConfig
- _do_hermes_chat(): streams /v1/chat/completions from Hermes Agent,
collects hermes.tool.progress SSE events, returns (final_text, tool_calls)
- chat_view branches on backend_mode; Hermes response includes toolCalls list
- Settings page: new '🧠 AI 后端模式' section — radio to toggle mode,
conditional Hermes URL + API key fields, LLM model field moved under
OpenRouter mode
- ChatPanel: ToolCallsList component renders collapsible '🔧 Hermes 使用了
N 个工具' widget before the bot reply — tool calls are NOT spoken via TTS
- CSS: .tool-calls, .tool-calls-* styles for the tool progress widget
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
right-footer served no purpose after hint text was removed. vbtn-wrap
is now a direct flex child of right-col: white bar at the bottom on
desktop, position:fixed on mobile — no stray white boxes anywhere.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Merged hint text + VoiceButton into a single .right-footer div
(flex column, white bg, border-top) so they form one solid footer bar
visually anchored to the bottom of the right column — no more floating
- .right-col gets min-height:0 so flex children can shrink properly
- .vbtn-wrap is now a transparent width:100% wrapper inside right-footer
(desktop); mobile keeps its own fixed-position overlay
- .right-footer { display: none } on mobile (fixed vbtn-wrap takes over)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Remove 'overflow:hidden' from html/body in index.css — it was blocking
scroll on all pages (Settings, History). The chat page is self-contained
via .app {height:100dvh; overflow:hidden} so body lock is unnecessary.
.app reverted back to height:100dvh (self-sufficient, not body-relative)
- Remove duplicate 历史记录 link from the right-column hint bar; it
already appears in the ChatPanel header, so three links was redundant
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Move MemoryPanel from bottom of left column to top of right column
(above chat messages, with a clean border-bottom separator)
- Move hint text ('按住 Space 或按钮说话 · 音色') from left column
to a compact bar on the right column between chat and voice button;
hidden on mobile to save space
- Remove 切换间隔 slider from left column; add it to Settings page
under 🤖 机器人身份 section with proper slider+value layout
- Add switchIntervalMs:10000 to DEFAULT_SETTINGS in settingsStore so
the value persists across sessions; App.jsx reads it on mount
- Left column is now much leaner: face + interaction buttons + micro
expression grid + auto-rotate toggle only
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Left column: scrollbar-width:none + ::-webkit-scrollbar:none to
hide the scrollbar chrome while keeping overflow-y:auto for tall
viewports; tightened padding (14→10px top, 20→14px bottom) and
panel/section gaps (14→10px, 12→8px, h3 margin 7→5px) to help
content fit without scrolling on typical laptop heights
- Header: added 🏠 home anchor linking to '/' (Links homepage) next
to the JBOT logo; styled as a small icon button
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- index.css targeted #root but app mounts to #jbot-root — viewport
height was never constrained, causing full page scroll
- Fix: rename #root → #jbot-root, add html/body {height:100%;overflow:hidden}
- .app now uses height:100% relative to #jbot-root instead of
duplicate 100dvh, so the layout is a true single-screen SPA
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
.chat-panel was using height:100% which consumed all right-col space,
pushing VoiceButton out of view (clipped by overflow:hidden).
Fix: switch .chat-panel to flex:1 + min-height:0 so it participates
in flex layout and leaves room for the VoiceButton below it.
Also moved VoiceButton component outside App() to avoid React
component-identity churn on every render.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Added VoiceButton component (inline in App.jsx) with full
mousedown/mouseup/mouseleave + touchstart/touchend/touchcancel support
- Shows context-aware label: 🎙 按住说话 / ⏳ 准备中 / 🔴 松开发送 / ⚙ 思考中 / 🔊 播放中
- Pulsing red animation while recording, amber pulse while acquiring mic
- Disabled (gray) while AI is thinking or speaking
- Desktop: pill button docked at bottom of chat panel (max-width 380px)
- Mobile: fixed to bottom of viewport with safe-area-inset support,
height 60px for easy thumb tap; right-col padded so messages aren't hidden
- Updated hint text and listening status to be input-agnostic
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Volcengine ASR server sends a WS close frame (opcode=8, code 1000,
'finish last sequence') after receiving the final audio chunk.
websocket-client fires on_error with this frame data instead of
(or in addition to) on_close — treat opcode=8 and 'already closed'
as normal termination, not an error.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
WebSocketApp callbacks receive the WebSocketApp object, not WebSocket.
WebSocketApp has no send_binary() method — must use:
ws.send(data, ws_module.ABNF.OPCODE_BINARY)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>