## Summary epic r04 - begin refactor ## Change Type - [x] Cowork feature - [ ] Bug fix - [ ] Core AI contribution - [ ] Test / hardening - [ ] Performance - [ ] Documentation ## Related Work Cowork Task: Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets Core AI Issue: Core Task: Related PR: ## Scope What is intentionally included? What is intentionally NOT included? ## Validation - [ ] Unit tests - [ ] Integration tests - [ ] Manual verification - [ ] Regression check Commands / evidence: ## Security Impact Permission / credential / network / customer data impact: ## Compatibility - [ ] No breaking change - [ ] Breaking change documented ## Reviewer Notes Anything Cowork reviewers should pay attention to. --------- Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com> Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com> Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com> Co-authored-by: Vu Dam Tuan <vudt15@fpt.com> Co-authored-by: Hiep Ha Van <hiephv3@fpt.com> Co-authored-by: Lam Hoang Van <lamhv7@fpt.com> Reviewed-on: #7 Co-authored-by: Duy Le Huu <duylh19@fpt.com>
This commit was merged in pull request #7.
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
"""Dashboard screen, split into single-responsibility widgets (R08-T13):
|
||||
``token_usage_card_widget``, ``usage_chart_widget``, ``habits_widget``,
|
||||
assembled by the ``dashboard_tab`` shell."""
|
||||
@@ -0,0 +1,108 @@
|
||||
"""DashboardTab shell (R08-T13) — assembles
|
||||
``token_usage_card_widget.py::TokenUsageCardWidget``,
|
||||
``usage_chart_widget.py::UsageChartWidget`` and
|
||||
``habits_widget.py::HabitsWidget`` behind the scroll area / header / 30s
|
||||
auto-refresh timer that used to be inline in
|
||||
``ui/dashboard_tab.py::DashboardTab.__init__`` (lines 40-193 of the original
|
||||
437-line file).
|
||||
|
||||
The one ``DashboardQueryService`` (R08-T13) instance is built here and
|
||||
shared by all three children so pricing/currency stay consistent across the
|
||||
whole screen.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QTimer, Signal
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QScrollArea, QVBoxLayout, QWidget
|
||||
|
||||
from cowork_local.application.monitoring import DashboardQueryService
|
||||
from cowork_local.i18n import on_language_changed, tr
|
||||
from cowork_local.presentation.dashboard.habits_widget import HabitsWidget
|
||||
from cowork_local.presentation.dashboard.token_usage_card_widget import TokenUsageCardWidget
|
||||
from cowork_local.presentation.dashboard.usage_chart_widget import UsageChartWidget
|
||||
from cowork_local.state import AppContext
|
||||
from cowork_local.ui.icons import icon
|
||||
|
||||
|
||||
class DashboardTab(QWidget):
|
||||
"""Màn Bảng điều khiển: ghép thẻ số liệu, biểu đồ và bảng thói quen dùng model.
|
||||
|
||||
Một ``DashboardQueryService`` duy nhất được dựng ở đây và dùng chung cho cả
|
||||
ba thẻ con, nên đơn giá và tiền tệ luôn nhất quán trên toàn màn hình.
|
||||
"""
|
||||
status_message = Signal(str)
|
||||
|
||||
def __init__(self, ctx: AppContext):
|
||||
"""Dựng vỏ Bảng điều khiển và ghép ba thẻ con, dùng chung một
|
||||
``DashboardQueryService``.
|
||||
"""
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
self._query = DashboardQueryService(ctx)
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFrameShape(QScrollArea.NoFrame)
|
||||
content = QWidget()
|
||||
scroll.setWidget(content)
|
||||
outer.addWidget(scroll)
|
||||
root = QVBoxLayout(content)
|
||||
|
||||
head = QHBoxLayout()
|
||||
self._title = QLabel()
|
||||
self._title.setStyleSheet("font-weight:700; font-size:15px;")
|
||||
self.refresh_btn = QPushButton("")
|
||||
self.refresh_btn.setIcon(icon("refresh"))
|
||||
self.refresh_btn.setFixedWidth(34)
|
||||
self.refresh_btn.clicked.connect(self.refresh)
|
||||
head.addWidget(self._title, 1)
|
||||
head.addWidget(self.refresh_btn)
|
||||
root.addLayout(head)
|
||||
|
||||
self.chart = UsageChartWidget(ctx, self._query)
|
||||
self.chart.period_changed.connect(self.refresh)
|
||||
self.chart.currency_changed.connect(self.refresh)
|
||||
|
||||
# Hàng bộ chọn kỳ đặt NGAY DƯỚI TIÊU ĐỀ, trên các thẻ số liệu — đúng
|
||||
# chỗ của nó trước refactor. Nó lọc cả ba thẻ con chứ không riêng biểu đồ,
|
||||
# nên để nó nằm dưới các thẻ số liệu là bắt người dùng đọc con số trước
|
||||
# khi thấy con số đó tính cho kỳ nào (F-07).
|
||||
root.addWidget(self.chart.detach_controls_bar())
|
||||
|
||||
self.token_cards = TokenUsageCardWidget(ctx, self._query)
|
||||
root.addWidget(self.token_cards)
|
||||
|
||||
root.addWidget(self.chart)
|
||||
|
||||
self.habits = HabitsWidget(ctx, self._query)
|
||||
self.habits.status_message.connect(self.status_message.emit)
|
||||
root.addWidget(self.habits, 1)
|
||||
|
||||
# Auto-refresh every 30s so numbers follow ongoing work.
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setInterval(30_000)
|
||||
self._timer.timeout.connect(self.refresh)
|
||||
self._timer.start()
|
||||
|
||||
on_language_changed(self._retranslate)
|
||||
self.refresh()
|
||||
|
||||
def _retranslate(self) -> None:
|
||||
"""Áp lại chữ theo ngôn ngữ đang chọn, rồi vẽ lại số liệu."""
|
||||
self._title.setText(tr("dashboard.title"))
|
||||
self.refresh_btn.setToolTip(tr("dashboard.refresh_tooltip"))
|
||||
self.token_cards.retranslate()
|
||||
self.chart.retranslate()
|
||||
self.habits.retranslate()
|
||||
self.refresh()
|
||||
|
||||
def refresh(self, *_a) -> None:
|
||||
"""Đọc lại số liệu cho kỳ đang chọn và cập nhật cả ba thẻ con."""
|
||||
start, end = self.chart.period_range()
|
||||
self.token_cards.refresh(start, end)
|
||||
self.chart.refresh()
|
||||
self.habits.refresh(start, end)
|
||||
|
||||
|
||||
__all__ = ["DashboardTab"]
|
||||
@@ -0,0 +1,188 @@
|
||||
"""HabitsWidget — the usage-habits summary + AI recommendations panel of the
|
||||
Dashboard (R08-T13, extracted from ``ui/dashboard_tab.py::DashboardTab``,
|
||||
lines 154-184/348-372/376-438 of the original 437-line file: the habits/AI
|
||||
layout, ``refresh()``'s habits-HTML section, ``_apply_saving_strategy``,
|
||||
``_ai_analyze``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import List, Optional
|
||||
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QTextBrowser, QVBoxLayout, QWidget
|
||||
from PySide6.QtCore import Signal
|
||||
|
||||
from cowork_local.application.monitoring import DashboardQueryService
|
||||
from cowork_local.core.worker import AgentWorker
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.ui.icons import icon
|
||||
from cowork_local.ui.widgets import fmt_tokens
|
||||
|
||||
|
||||
class HabitsWidget(QWidget):
|
||||
"""Thẻ "Thói quen dùng model" của Dashboard, kèm phần AI phân tích.
|
||||
|
||||
Chỉ gửi SỐ LIỆU đã tổng hợp lên provider, không bao giờ gửi nội dung
|
||||
prompt thật — xem :meth:`_ai_analyze`.
|
||||
"""
|
||||
status_message = Signal(str)
|
||||
|
||||
def __init__(self, ctx, query: DashboardQueryService, parent=None):
|
||||
"""Bảng thói quen dùng model, kèm nút nhờ model tự nhận xét.
|
||||
|
||||
Kỳ đang xem được nhớ lại ở mỗi lần ``refresh()`` để nút nhận xét dùng đúng
|
||||
khoảng thời gian đang hiện trên màn hình.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
self._query = query
|
||||
self._ai_worker: Optional[AgentWorker] = None
|
||||
self._period_range = (None, None) # set on each refresh(); _ai_analyze reuses it
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
self._habits_title = QLabel()
|
||||
self._habits_title.setStyleSheet("font-weight:600;")
|
||||
habits_head = QHBoxLayout()
|
||||
self.ai_analyze_btn = QPushButton()
|
||||
self.ai_analyze_btn.setIcon(icon("sparkle"))
|
||||
self.ai_analyze_btn.clicked.connect(self._ai_analyze)
|
||||
# Apply an AI-suggested cost-saving strategy — only after the user
|
||||
# clicks to approve it.
|
||||
self.apply_strategy_btn = QPushButton()
|
||||
self.apply_strategy_btn.setIcon(icon("bolt"))
|
||||
self.apply_strategy_btn.setVisible(False)
|
||||
self.apply_strategy_btn.clicked.connect(self._apply_saving_strategy)
|
||||
habits_head.addWidget(self._habits_title, 1)
|
||||
habits_head.addWidget(self.apply_strategy_btn)
|
||||
habits_head.addWidget(self.ai_analyze_btn)
|
||||
root.addLayout(habits_head)
|
||||
self.habits = QTextBrowser()
|
||||
self.habits.setOpenExternalLinks(False)
|
||||
self.habits.setMinimumHeight(160)
|
||||
root.addWidget(self.habits, 1)
|
||||
self._ai_title = QLabel()
|
||||
self._ai_title.setStyleSheet("font-weight:600;")
|
||||
self._ai_title.setVisible(False)
|
||||
root.addWidget(self._ai_title)
|
||||
self.ai_advice = QTextBrowser()
|
||||
self.ai_advice.setOpenExternalLinks(False)
|
||||
self.ai_advice.setMinimumHeight(140)
|
||||
self.ai_advice.setVisible(False)
|
||||
root.addWidget(self.ai_advice, 1)
|
||||
|
||||
self.retranslate()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
"""Áp lại chữ theo ngôn ngữ đang chọn."""
|
||||
self.ai_analyze_btn.setText(tr("dashboard.ai_analyze_btn"))
|
||||
self.ai_analyze_btn.setToolTip(tr("dashboard.ai_analyze_tooltip"))
|
||||
self.apply_strategy_btn.setText(tr("dashboard.strategy_btn"))
|
||||
self.apply_strategy_btn.setToolTip(tr("dashboard.strategy_tooltip"))
|
||||
self._habits_title.setText(tr("dashboard.habits_title"))
|
||||
|
||||
def refresh(self, start: date, end: date) -> None:
|
||||
"""Vẽ lại bảng thói quen dùng model cho một kỳ, và nhớ kỳ đó cho nút AI phân tích."""
|
||||
self._period_range = (start, end)
|
||||
summary = self._query.summary(start, end)
|
||||
s, events = summary["stats"], summary["events"]
|
||||
|
||||
lines: List[str] = []
|
||||
if not events:
|
||||
lines.append(f"<i>{tr('dashboard.no_data')}</i>")
|
||||
else:
|
||||
lines.append(f"<b>{tr('dashboard.h_top')}</b>")
|
||||
lines.append("<ol>")
|
||||
for label, tok in s["top_labels"]:
|
||||
pct = int(tok * 100 / s["total"]) if s["total"] else 0
|
||||
lines.append(f"<li>{label[:60]} — {fmt_tokens(tok)} tokens ({pct}%)</li>")
|
||||
lines.append("</ol>")
|
||||
src_parts = ", ".join(
|
||||
f"{tr(f'app.tab.{k}') if k in ('cowork', 'code') else k}: {fmt_tokens(v)}"
|
||||
for k, v in s["by_source"])
|
||||
lines.append(f"<b>{tr('dashboard.h_by_source')}</b>: {src_parts}<br>")
|
||||
lines.append(f"<b>{tr('dashboard.h_avg')}</b>: "
|
||||
f"{fmt_tokens(s['avg_per_turn'])} tokens<br>")
|
||||
if s["busiest_day"]:
|
||||
lines.append(f"<b>{tr('dashboard.h_busiest_day')}</b>: {s['busiest_day']}<br>")
|
||||
if s["busiest_hour"] is not None:
|
||||
lines.append(f"<b>{tr('dashboard.h_busiest_hour')}</b>: "
|
||||
f"{s['busiest_hour']:02d}:00–{s['busiest_hour']:02d}:59<br>")
|
||||
if s["estimated_share"] > 0:
|
||||
lines.append(f"<i>{tr('dashboard.estimated_note', pct=int(s['estimated_share'] * 100))}</i>")
|
||||
self.habits.setHtml("".join(lines))
|
||||
|
||||
def _apply_saving_strategy(self) -> None:
|
||||
"""Apply an AI-suggested cost-saving strategy AFTER the user
|
||||
approves: turn on auto-compress and compress earlier (lower
|
||||
threshold) + compress content before sending it to the agent."""
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
if QMessageBox.question(self, tr("dashboard.strategy_title"),
|
||||
tr("dashboard.strategy_confirm")) != QMessageBox.Yes:
|
||||
return
|
||||
cx = self.ctx.config.data.setdefault("context", {})
|
||||
cx["auto_compact"] = True
|
||||
cx["compact_threshold"] = 0.6 # compress at 60% of the window (was ~80%)
|
||||
cx["compress_before_send"] = True # digest context before each turn
|
||||
self.ctx.save()
|
||||
self.status_message.emit(tr("dashboard.strategy_applied"))
|
||||
|
||||
def _ai_analyze(self) -> None:
|
||||
"""✨ Send the aggregated numbers (never raw prompt text) to the
|
||||
active provider and show habit feedback + token-saving
|
||||
recommendations."""
|
||||
if self._ai_worker is not None:
|
||||
return
|
||||
start, end = self._period_range
|
||||
if start is None:
|
||||
return
|
||||
summary = self._query.summary(start, end)
|
||||
if not summary["events"]:
|
||||
self.status_message.emit(tr("dashboard.no_data"))
|
||||
return
|
||||
stats = summary["stats"]
|
||||
self.ai_analyze_btn.setEnabled(False)
|
||||
self.ai_analyze_btn.setText(tr("dashboard.ai_analyzing"))
|
||||
ctx = self.ctx
|
||||
|
||||
def job(worker: AgentWorker):
|
||||
"""Chạy nền: gửi SỐ LIỆU đã tổng hợp (không bao giờ gửi nội dung prompt thật)
|
||||
cho model và xin nhận xét cùng gợi ý tiết kiệm token.
|
||||
"""
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
from cowork_local.i18n import get_language
|
||||
|
||||
prompt = ut.build_ai_analysis_prompt(stats, get_language())
|
||||
provider = ctx.build_active_provider()
|
||||
reply = provider.chat([{"role": "user", "content": prompt}],
|
||||
cancel=worker.stop_event)
|
||||
return {"text": (reply.get("content") or "").strip()}
|
||||
|
||||
def done(result: dict) -> None:
|
||||
"""Hiện nhận xét của AI và mở nút "Áp dụng chiến lược tiết kiệm"."""
|
||||
self._ai_worker = None
|
||||
self.ai_analyze_btn.setEnabled(True)
|
||||
self.ai_analyze_btn.setText(tr("dashboard.ai_analyze_btn"))
|
||||
text = result.get("text") or ""
|
||||
if text:
|
||||
self._ai_title.setText(tr("dashboard.ai_advice_title"))
|
||||
self._ai_title.setVisible(True)
|
||||
self.ai_advice.setMarkdown(text)
|
||||
self.ai_advice.setVisible(True)
|
||||
self.apply_strategy_btn.setVisible(True)
|
||||
|
||||
def failed(err: str) -> None:
|
||||
"""Phân tích lỗi: hiện lý do và mở khoá lại nút."""
|
||||
self._ai_worker = None
|
||||
self.ai_analyze_btn.setEnabled(True)
|
||||
self.ai_analyze_btn.setText(tr("dashboard.ai_analyze_btn"))
|
||||
self.status_message.emit(str(err))
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(done)
|
||||
w.failed.connect(failed)
|
||||
self._ai_worker = w
|
||||
w.start()
|
||||
|
||||
|
||||
__all__ = ["HabitsWidget"]
|
||||
@@ -0,0 +1,116 @@
|
||||
"""TokenUsageCardWidget — the stat-card grid + budget card of the Dashboard
|
||||
(R08-T13, extracted from ``ui/dashboard_tab.py::DashboardTab``, lines
|
||||
116-141/226-254/337-346 of the original 437-line file: the card grid layout,
|
||||
``_apply_budget``, ``_refresh_budget``, and ``refresh()``'s card-filling
|
||||
section).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from PySide6.QtWidgets import QGridLayout, QWidget
|
||||
|
||||
from cowork_local.application.monitoring import DashboardQueryService
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.ui.icons import icon
|
||||
from cowork_local.ui.widgets import BudgetCard, StatCard, fmt_tokens
|
||||
|
||||
|
||||
class TokenUsageCardWidget(QWidget):
|
||||
"""Cost is the headline this screen exists for, so it gets a card twice
|
||||
the height of the rest instead of being the fifth of five identical
|
||||
tiles — with six equal cards nothing said which number mattered."""
|
||||
|
||||
def __init__(self, ctx, query: DashboardQueryService, parent=None):
|
||||
"""Lưới các thẻ số liệu token và chi phí."""
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
self._query = query
|
||||
|
||||
cards_grid = QGridLayout(self)
|
||||
# Lề 0 như hai thẻ con còn lại (``habits_widget``, ``usage_chart_widget``):
|
||||
# bản trước refactor đổ lưới thẻ THẲNG vào layout của màn hình, nên bọc nó
|
||||
# vào một widget kèm lề mặc định 9px đẩy cả hàng thẻ xuống 9px so với bản vẽ.
|
||||
cards_grid.setContentsMargins(0, 0, 0, 0)
|
||||
cards_grid.setSpacing(8)
|
||||
self.card_total = StatCard()
|
||||
self.card_in = StatCard()
|
||||
self.card_out = StatCard()
|
||||
self.card_cache = StatCard()
|
||||
self.card_cost = StatCard().as_hero()
|
||||
# Hero on the left, spanning both rows; the four supporting figures
|
||||
# fill a 2x2 block beside it.
|
||||
cards_grid.addWidget(self.card_cost, 0, 0, 2, 1)
|
||||
for i, card in enumerate((self.card_total, self.card_in,
|
||||
self.card_out, self.card_cache)):
|
||||
cards_grid.addWidget(card, i // 2, 1 + i % 2)
|
||||
# Budget: remaining/budget, direct entry, auto-warns red past 85% used.
|
||||
self.budget_card = BudgetCard()
|
||||
self.budget_card.apply_btn.setIcon(icon("check"))
|
||||
self.budget_card.apply_btn.clicked.connect(self._apply_budget)
|
||||
cards_grid.addWidget(self.budget_card, 0, 3, 2, 1)
|
||||
for col, stretch in ((0, 3), (1, 2), (2, 2), (3, 3)):
|
||||
cards_grid.setColumnStretch(col, stretch)
|
||||
|
||||
self.retranslate()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
"""Áp lại chữ theo ngôn ngữ đang chọn."""
|
||||
self.budget_card.apply_btn.setToolTip(tr("usage.budget_apply_tooltip"))
|
||||
self.budget_card.budget_spin.setToolTip(tr("usage.budget_spin_tooltip"))
|
||||
|
||||
def refresh(self, start: date, end: date) -> None:
|
||||
"""Cập nhật mọi thẻ theo số liệu của một kỳ."""
|
||||
summary = self._query.summary(start, end)
|
||||
s, pricing, costs = summary["stats"], summary["pricing"], summary["costs"]
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
|
||||
est_note = (tr("dashboard.estimated_note", pct=int(s["estimated_share"] * 100))
|
||||
if s["estimated_share"] > 0 else "")
|
||||
self.card_total.set(tr("dashboard.card_total"), fmt_tokens(s["total"]),
|
||||
tr("dashboard.card_turns", n=s["turns"]))
|
||||
self.card_in.set(tr("dashboard.card_in"), fmt_tokens(s["in"]),
|
||||
ut.format_cost(costs["in"], pricing))
|
||||
self.card_out.set(tr("dashboard.card_out"), fmt_tokens(s["out"]),
|
||||
ut.format_cost(costs["out"], pricing))
|
||||
self.card_cache.set(tr("dashboard.card_cache"), fmt_tokens(s["cache"]),
|
||||
ut.format_cost(costs["cache"], pricing))
|
||||
self.card_cost.set(tr("dashboard.card_cost"),
|
||||
ut.format_cost(summary["total_cost"], pricing, digits=2), est_note)
|
||||
self._refresh_budget()
|
||||
|
||||
def _apply_budget(self) -> None:
|
||||
"""Persist the spin box's value as the new budget — starts a fresh
|
||||
remaining-balance window (spend before now is no longer counted)."""
|
||||
ccy = (self.ctx.config.data.get("usage") or {}).get("currency", "USD")
|
||||
self._query.set_budget(self.budget_card.budget_spin.value(), ccy)
|
||||
self.ctx.save()
|
||||
self._refresh_budget()
|
||||
|
||||
def _refresh_budget(self) -> None:
|
||||
"""Cập nhật thẻ ngân sách: còn lại / tổng, phần trăm đã dùng, cảnh báo khi vượt."""
|
||||
from cowork_local.core import model_pricing as mp
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
|
||||
pricing = self._query.pricing()
|
||||
status = self._query.budget_status()
|
||||
if status is None:
|
||||
self.budget_card.set(tr("usage.budget_title"), "—", tr("usage.budget_no_budget"))
|
||||
self.budget_card.budget_spin.setValue(0.0)
|
||||
return
|
||||
remaining_disp = mp.convert(status["remaining_usd"], "USD",
|
||||
pricing.get("currency", "USD"), self.ctx.config)
|
||||
amount_disp = mp.convert(status["amount_usd"], "USD",
|
||||
pricing.get("currency", "USD"), self.ctx.config)
|
||||
value = (f"{ut.format_cost(status['remaining_usd'], pricing, digits=2)}"
|
||||
f" / {ut.format_cost(status['amount_usd'], pricing, digits=2)}")
|
||||
pct = int(round(status["pct_used"] * 100))
|
||||
sub = tr("usage.budget_over_warning") if status["over_85"] else tr("usage.budget_used_pct", pct=pct)
|
||||
self.budget_card.set(tr("usage.budget_title"), value, sub, warn=status["over_85"])
|
||||
# keep the entry field showing the CURRENT budget (in display currency)
|
||||
# — only when it doesn't already have unsaved focus/edits from the user.
|
||||
if not self.budget_card.budget_spin.hasFocus():
|
||||
self.budget_card.budget_spin.setValue(round(amount_disp, 2))
|
||||
|
||||
|
||||
__all__ = ["TokenUsageCardWidget"]
|
||||
@@ -0,0 +1,221 @@
|
||||
"""UsageChartWidget — the period pager + granularity/metric/currency
|
||||
controls + spline chart of the Dashboard (R08-T13, extracted from
|
||||
``ui/dashboard_tab.py::DashboardTab``, lines 53-114/143-152/201-207/
|
||||
263-323 of the original 437-line file).
|
||||
|
||||
Owns the period SELECTOR (granularity + prev/next offset) that the whole
|
||||
screen follows — ``token_usage_card_widget.py`` and ``habits_widget.py``
|
||||
read :meth:`period_range`/:meth:`granularity` rather than keeping their own
|
||||
copy, and the shell re-refreshes them on :attr:`period_changed`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Tuple
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import QComboBox, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
from cowork_local.application.monitoring import DashboardQueryService
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.theme import current_palette
|
||||
from cowork_local.ui.icons import icon
|
||||
from cowork_local.ui.spline_chart import SplineChart
|
||||
from cowork_local.ui.widgets import fmt_tokens
|
||||
|
||||
|
||||
class UsageChartWidget(QWidget):
|
||||
"""Thẻ biểu đồ chi phí/token của Dashboard: hàng điều khiển + đồ thị spline.
|
||||
|
||||
Đây cũng là nơi giữ *kỳ đang xem* (độ mịn tuần/tháng/năm + độ lệch kỳ)
|
||||
cho cả màn hình: các thẻ khác không tự chọn kỳ mà nghe tín hiệu
|
||||
``period_changed`` rồi hỏi lại :meth:`period_range`.
|
||||
"""
|
||||
period_changed = Signal() # granularity or offset changed — re-run every widget
|
||||
currency_changed = Signal() # display currency changed — same, cost text depends on it
|
||||
|
||||
def __init__(self, ctx, query: DashboardQueryService, parent=None):
|
||||
"""Dựng hàng điều khiển kỳ và biểu đồ spline."""
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
self._query = query
|
||||
self._chart_offset = 0 # 0 = current period; <0 = a past period
|
||||
|
||||
self._root = QVBoxLayout(self)
|
||||
self._root.setContentsMargins(0, 0, 0, 0)
|
||||
# Hàng điều khiển nằm trong một widget riêng chứ không đổ thẳng vào layout,
|
||||
# để vỏ ngoài gỡ được nó ra và đặt lên đầu màn hình — xem
|
||||
# :meth:`detach_controls_bar`.
|
||||
self.controls_bar = QWidget(self)
|
||||
controls = QHBoxLayout(self.controls_bar)
|
||||
controls.setContentsMargins(0, 0, 0, 0)
|
||||
controls.setSpacing(6)
|
||||
self.chart_prev_btn = QPushButton()
|
||||
self.chart_prev_btn.setIcon(icon("chevron-left"))
|
||||
self.chart_prev_btn.setFixedWidth(30)
|
||||
self.chart_prev_btn.clicked.connect(self._chart_prev)
|
||||
controls.addWidget(self.chart_prev_btn)
|
||||
self._chart_period_lbl = QLabel()
|
||||
self._chart_period_lbl.setObjectName("hint")
|
||||
self._chart_period_lbl.setAlignment(Qt.AlignCenter)
|
||||
self._chart_period_lbl.setMinimumWidth(170)
|
||||
controls.addWidget(self._chart_period_lbl)
|
||||
self.chart_next_btn = QPushButton()
|
||||
self.chart_next_btn.setIcon(icon("chevron-right"))
|
||||
self.chart_next_btn.setFixedWidth(30)
|
||||
self.chart_next_btn.clicked.connect(self._chart_next)
|
||||
controls.addWidget(self.chart_next_btn)
|
||||
controls.addSpacing(12)
|
||||
self.gran_combo = QComboBox()
|
||||
for g in ("week", "month", "year"):
|
||||
self.gran_combo.addItem(tr(f"dashboard.gran_{g}"), g)
|
||||
self.gran_combo.currentIndexChanged.connect(self._on_gran_changed)
|
||||
controls.addWidget(self.gran_combo)
|
||||
self.metric_combo = QComboBox()
|
||||
for m in ("cost", "tokens"):
|
||||
self.metric_combo.addItem(tr(f"dashboard.metric_{m}"), m)
|
||||
self.metric_combo.currentIndexChanged.connect(self.refresh)
|
||||
controls.addWidget(self.metric_combo)
|
||||
controls.addStretch(1)
|
||||
# Display-currency picker — both Dashboard and Monitoring read/write
|
||||
# the same usage.currency config key, so changing it here updates
|
||||
# cost text everywhere.
|
||||
self.currency_lbl = QLabel()
|
||||
self.currency_lbl.setObjectName("hint")
|
||||
controls.addWidget(self.currency_lbl)
|
||||
self.currency_combo = QComboBox()
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
for cur in ut.SUPPORTED_CURRENCIES:
|
||||
self.currency_combo.addItem(cur, cur)
|
||||
idx = self.currency_combo.findData(
|
||||
(self.ctx.config.data.get("usage") or {}).get("currency", "USD"))
|
||||
self.currency_combo.setCurrentIndex(max(0, idx))
|
||||
self.currency_combo.currentIndexChanged.connect(self._on_currency_changed)
|
||||
controls.addWidget(self.currency_combo)
|
||||
self._root.addWidget(self.controls_bar)
|
||||
|
||||
chart_head = QHBoxLayout()
|
||||
self._chart_title = QLabel()
|
||||
self._chart_title.setStyleSheet("font-weight:600;")
|
||||
chart_head.addWidget(self._chart_title, 1)
|
||||
self._root.addLayout(chart_head)
|
||||
self.chart = SplineChart()
|
||||
self._root.addWidget(self.chart)
|
||||
|
||||
self.retranslate()
|
||||
|
||||
def detach_controls_bar(self) -> QWidget:
|
||||
"""Gỡ hàng điều khiển ra khỏi thẻ biểu đồ và trả về cho chỗ khác đặt.
|
||||
|
||||
Hàng này chọn kỳ cho **cả màn hình** chứ không riêng biểu đồ: thẻ số
|
||||
liệu và bảng thói quen đều lọc theo nó. Bản trước refactor vì thế đặt nó
|
||||
ngay dưới tiêu đề, trên các thẻ số liệu; đợt tách widget (R08-T13) kéo
|
||||
nó xuống theo biểu đồ, thành ra người dùng đọc các thẻ số liệu trước khi
|
||||
nhìn thấy thứ quyết định những con số ấy (F-07).
|
||||
|
||||
Trạng thái kỳ và toàn bộ dây tín hiệu vẫn thuộc widget này — chỉ có chỗ
|
||||
đặt là đổi. Gọi hay không gọi đều chạy đúng: không gọi thì hàng điều khiển
|
||||
ở nguyên trong thẻ biểu đồ (widget dùng độc lập trong test vẫn đủ bộ).
|
||||
"""
|
||||
self._root.removeWidget(self.controls_bar)
|
||||
return self.controls_bar
|
||||
|
||||
def retranslate(self) -> None:
|
||||
"""Áp lại chữ theo ngôn ngữ đang chọn cho nhãn và tooltip."""
|
||||
self.currency_lbl.setText(tr("monitoring.overview_currency"))
|
||||
self.currency_combo.setToolTip(tr("dashboard.currency_tooltip"))
|
||||
self._chart_title.setText(tr("dashboard.chart_title"))
|
||||
self.chart_prev_btn.setToolTip(tr("dashboard.chart_prev"))
|
||||
self.chart_next_btn.setToolTip(tr("dashboard.chart_next"))
|
||||
|
||||
# ---- public: the period selector every other widget follows ------------- #
|
||||
def granularity(self) -> str:
|
||||
"""Độ mịn đang chọn: 'week' | 'month' | 'year'. Mặc định 'week'."""
|
||||
return self.gran_combo.currentData() or "week"
|
||||
|
||||
@property
|
||||
def chart_offset(self) -> int:
|
||||
"""Độ lệch kỳ: 0 là kỳ hiện tại, số âm là lùi về quá khứ."""
|
||||
return self._chart_offset
|
||||
|
||||
def period_range(self) -> Tuple:
|
||||
"""Khoảng thời gian (đầu, cuối) của kỳ đang xem — thứ mọi thẻ khác dùng để lọc dữ liệu."""
|
||||
return self._query.period_range(self.granularity(), self._chart_offset)
|
||||
|
||||
# ---- navigation ------------------------------------------------------------ #
|
||||
def _on_gran_changed(self, *_a) -> None:
|
||||
"""Đổi độ mịn thì nhảy về kỳ hiện tại: giữ nguyên độ lệch cũ sẽ nhảy sang
|
||||
một mốc thời gian khác hẳn (lùi 3 tuần ≠ lùi 3 tháng).
|
||||
"""
|
||||
self._chart_offset = 0 # period size changed → back to current
|
||||
self.period_changed.emit()
|
||||
|
||||
def _chart_prev(self) -> None:
|
||||
"""Lùi một kỳ."""
|
||||
self._chart_offset -= 1
|
||||
self.period_changed.emit()
|
||||
|
||||
def _chart_next(self) -> None:
|
||||
"""Tiến một kỳ, chặn ở kỳ hiện tại — không cho xem tương lai."""
|
||||
self._chart_offset = min(0, self._chart_offset + 1) # never past the present
|
||||
self.period_changed.emit()
|
||||
|
||||
def _on_currency_changed(self, _idx: int) -> None:
|
||||
"""Đổi tiền tệ hiển thị và ghi vào cấu hình ngay.
|
||||
|
||||
Dashboard và Giám sát dùng chung khoá ``usage.currency`` nên đổi ở đây
|
||||
là mọi chỗ hiện tiền đều đổi theo.
|
||||
"""
|
||||
cur = self.currency_combo.currentData()
|
||||
if not cur:
|
||||
return
|
||||
self.ctx.config.data.setdefault("usage", {})["currency"] = cur
|
||||
self.ctx.save()
|
||||
self.currency_changed.emit()
|
||||
|
||||
@staticmethod
|
||||
def _delta_txt(cur: float, prev: float) -> str:
|
||||
"""▲/▼ percent change of ``cur`` vs ``prev`` (empty if no baseline)."""
|
||||
if not prev:
|
||||
return ""
|
||||
pct = (cur - prev) / prev * 100
|
||||
arrow = "▲" if pct > 0.5 else ("▼" if pct < -0.5 else "•")
|
||||
return f"{arrow}{abs(pct):.0f}%"
|
||||
|
||||
# ---- rendering --------------------------------------------------------------- #
|
||||
def refresh(self, *_a) -> None:
|
||||
"""Break the SELECTED period into its parts: WEEK -> 7 days (Mon-Sun)
|
||||
- MONTH -> weeks W1..Wn - YEAR -> 12 months. A dashed line marks the
|
||||
previous same-granularity period's average per point with the %
|
||||
change of the totals."""
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
|
||||
gran = self.granularity()
|
||||
metric = self.metric_combo.currentData() or "cost"
|
||||
pts = self._query.chart_series(gran, self._chart_offset, metric)
|
||||
pricing = self._query.pricing()
|
||||
mi = 0 if metric == "tokens" else 1
|
||||
# Compact cost format (2 decimals, K/M above 1,000/1,000,000) — the
|
||||
# chart's y-axis label box is narrow; format_cost's full precision
|
||||
# overflowed it, clipping/obscuring the amount.
|
||||
fmt = fmt_tokens if metric == "tokens" else (lambda v: ut.format_cost_compact(v, pricing))
|
||||
|
||||
cur = self._query.period_totals(gran, self._chart_offset)
|
||||
prev = self._query.period_totals(gran, self._chart_offset - 1)
|
||||
ref_key = {"week": "dashboard.ref_last_week",
|
||||
"month": "dashboard.ref_last_month",
|
||||
"year": "dashboard.ref_last_year"}.get(gran, "dashboard.ref_last_week")
|
||||
n_points = max(1, len(pts))
|
||||
refs = []
|
||||
if prev[mi] > 0:
|
||||
# Muted on purpose: the comparison line is a reference, not the
|
||||
# series — it must not compete with the accent-coloured spline.
|
||||
refs.append((prev[mi] / n_points,
|
||||
f"{tr(ref_key)} {self._delta_txt(cur[mi], prev[mi])}",
|
||||
current_palette().text_muted))
|
||||
self.chart.set_reference_lines(refs)
|
||||
self.chart.set_data(pts, fmt, tr(f"dashboard.metric_{metric}"))
|
||||
self._chart_period_lbl.setText(self._query.period_range_label(gran, self._chart_offset))
|
||||
self.chart_next_btn.setEnabled(self._chart_offset < 0)
|
||||
|
||||
|
||||
__all__ = ["UsageChartWidget"]
|
||||
Reference in New Issue
Block a user