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.
- 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>
- jbot/index.html: <script type="module"> required for Vite ES module output
(jbot.js uses import statements for the shared client chunk)
- base.html: add JBOT menu item with robot icon after Invest in dropdown
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add JbotApiConfig Django model (DB singleton) for storing
Volcengine/OpenRouter credentials set via Settings UI
- New endpoint GET/POST /api/jbot/api-config/ — tokens masked as ***
on read; only non-placeholder values are updated on write
- views.py: _get_cfg() helper reads DB first, env vars as fallback
- Settings.jsx: new 🔑 API 配置 section with show/hide token fields
for VOLC App ID, Access Token, OpenRouter API Key, LLM Model, ASR Resource
- api.js: apiGetApiConfig() / apiSaveApiConfig() client helpers
- Dockerfile: COPY jbot/ in both builder + production stages (was missing)
- entrypoint.sh: runs migrate --noinput before gunicorn (auto-creates table)
- k8s/manifest.yaml: remove jbot-credentials secretKeyRef (no Secret needed);
keep LLM_MODEL/VOLC_TTS_VOICE/VOLC_ASR_RESOURCE as optional env defaults
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Rewrite nginxmon/geo.py to use maxminddb + DB-IP City Lite MMDB
(https://cdn.jsdelivr.net/npm/dbip-city-lite/dbip-city-lite.mmdb.gz)
instead of ip-api.com HTTP API — eliminates all network timeouts and
rate-limit issues; lookups are now sub-millisecond in-process
- Keep _lookup_batch() signature unchanged so routermon receiver
requires no changes
- MMDB downloaded on first use (lazy) and refreshed monthly via
APScheduler; reader cached module-level (thread-safe for reads)
- Add management command: manage.py download_geo_db
- Update k8s init container to download MMDB on first deploy if absent
- Add maxminddb==3.1.1 dependency
CC BY 4.0 — DB-IP (https://db-ip.com)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Stop sending further API requests mid-batch once the backoff is
triggered, saving remaining missing IPs as empty placeholders
immediately instead of waiting for them to timeout one-by-one.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
After _GEO_MISS_THRESHOLD (2) consecutive large all-empty API responses,
assume ip-api.com is unreachable and enter a 5-minute backoff.
During backoff all missing IPs are immediately saved as empty IPGeoCache
placeholders so they are not re-queued. Resets automatically when the
backoff expires and any result is returned.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add WanGeoStatsView (api/wan/geo/) returning country choropleth +
bubble data for WAN source IPs using IPGeoCache lat/lon
- Add wanGeoMap (420px) in dashboard between ports chart and live table:
red colour scale by country, rose bubbles sized by attempt count,
top-5 country legend
- Rewrite _geo_worker_loop to drain up to 200 queue items per cycle,
bulk DB cache check, batch API calls (100 IPs per request) instead
of 1 request per IP — fixes ip-api.com timeout pile-up under scan
volumes
- Add _geo_skip in-memory set: IPs that return no geo data get an
empty IPGeoCache placeholder saved to DB so they are never retried;
skip set also prevents re-queuing known-empty IPs during flush
- Remove _fetch_geo / _enrich_dns_geo / _enrich_wan_geo helpers
(logic consolidated into the new batch worker)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Remove SO_REUSEADDR from UDP socket so only one gunicorn worker binds
port 5514 (second worker intentionally gets EADDRINUSE and skips)
- Add print() fallback for bind confirmation/errors so startup is visible
even when Django logging config is not fully set up
- Fix duplicate LOGGING dict in settings.py: second assignment was
overriding the first and removing the root logger handler, silently
swallowing all routermon/core startup logs
- Add sys.stderr fallback in core/apps.py ready() for startup exceptions
- Fix JS SyntaxError: {{ top_domains_json }} needs |safe filter since
Django auto-escapes quotes to " inside script tags
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The knowledge graph feature (links graph) was not functioning correctly
and was slowing down the application. This commit removes it entirely:
- Delete knowledge_graph_urls.py, knowledge_graph_views.py, llm_client.py
- Delete knowledge_graph.html template
- Remove KnowledgeGraphSnapshot model and all llm_*/kg_* fields from
SiteSettings (migration 0048)
- Remove build_knowledge_graph() and schedule_kg_build() from tasks.py
- Remove KG settings save logic and bulk delete action from views.py
- Remove knowledge_graph_urls include from links/urls.py
- Remove schedule_kg_build scheduler job from core/apps.py
- Remove KG snapshot job registry from links/apps.py
- Remove Knowledge Graph nav item from base.html
- Remove KG settings cards and llmSettingsHelper JS from settings.html