token_usage_card_widget.py 93 5 thẻ số liệu + thẻ Ngân sách
usage_chart_widget.py 119 biểu đồ tuần/tháng/năm + đường so sánh
habits_widget.py 171 thói quen dùng token + nhận xét của AI
Ba widget THẬT, không phải mixin — khác với shell và Co4E, ba mảng này tách
bạch trên màn hình và không đọc state của nhau. Giao tiếp bằng signal:
budget_applied, filter_changed, status_message.
Điểm cần biết: các nút lật khoảng và hai ô chọn thuộc về UsageChartWidget
nhưng được Dashboard nhấc lên hàng điều khiển ở trên. Chúng là control của
biểu đồ, chỉ hiển thị ở chỗ khác.
Giữ 18 cầu tương thích cho tên cũ vì check_dashboard, check_design_parity và
check_controls_alive đọc thẳng self.card_total, self._chart_period_lbl...
756 test xanh. check_dashboard, check_design_parity, check_controls_alive qua.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
172 lines
6.9 KiB
Python
172 lines
6.9 KiB
Python
"""Phần thói quen dùng token, và nhận xét của AI — R08-T13.
|
||
|
||
Hai phần chồng lên nhau:
|
||
|
||
* **Tóm tắt thói quen** — dựng tại chỗ từ số liệu đã gộp: việc nào tốn token
|
||
nhất, chia theo nguồn, trung bình mỗi lượt, ngày và giờ bận nhất.
|
||
* **Nhận xét của AI** — chỉ chạy khi người dùng bấm. Gửi đi **các con số đã
|
||
gộp**, không bao giờ gửi nội dung câu chat gốc; đó là ranh giới cố ý.
|
||
|
||
Nút "áp dụng chiến lược tiết kiệm" chỉ hiện sau khi có nhận xét, và vẫn hỏi
|
||
lại trước khi đổi cấu hình — nó bật nén tự động và hạ ngưỡng nén, tức là đổi
|
||
hành vi của mọi lượt chat sau đó.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from typing import List
|
||
|
||
from PySide6.QtCore import Signal
|
||
from PySide6.QtWidgets import (
|
||
QHBoxLayout, QLabel, QMessageBox, QPushButton, QTextBrowser, QVBoxLayout,
|
||
QWidget,
|
||
)
|
||
|
||
from ...core import usage_tracker as ut
|
||
from ...core.worker import AgentWorker
|
||
from ...i18n import tr
|
||
from ...ui.icons import icon
|
||
from ...ui.widgets import fmt_tokens as _fmt_tokens
|
||
|
||
|
||
class HabitsWidget(QWidget):
|
||
#: Có gì cần nói với người dùng (lỗi, đã áp dụng xong…).
|
||
status_message = Signal(str)
|
||
#: Người dùng đồng ý áp dụng chiến lược tiết kiệm.
|
||
strategy_approved = Signal()
|
||
|
||
def __init__(self, ctx, parent=None):
|
||
super().__init__(parent)
|
||
self.ctx = ctx
|
||
self._worker = None
|
||
|
||
v = QVBoxLayout(self)
|
||
v.setContentsMargins(0, 0, 0, 0)
|
||
|
||
head = QHBoxLayout()
|
||
self.title = QLabel()
|
||
self.title.setStyleSheet("font-weight:600;")
|
||
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._ap_dung)
|
||
self.ai_analyze_btn = QPushButton()
|
||
self.ai_analyze_btn.setIcon(icon("sparkle"))
|
||
self.ai_analyze_btn.clicked.connect(self.phan_tich)
|
||
head.addWidget(self.title, 1)
|
||
head.addWidget(self.apply_strategy_btn)
|
||
head.addWidget(self.ai_analyze_btn)
|
||
v.addLayout(head)
|
||
|
||
self.habits = QTextBrowser()
|
||
self.habits.setOpenExternalLinks(False)
|
||
v.addWidget(self.habits)
|
||
|
||
self.ai_title = QLabel()
|
||
self.ai_title.setStyleSheet("font-weight:600;")
|
||
self.ai_title.setVisible(False)
|
||
v.addWidget(self.ai_title)
|
||
self.ai_advice = QTextBrowser()
|
||
self.ai_advice.setVisible(False)
|
||
v.addWidget(self.ai_advice)
|
||
|
||
# ---- tóm tắt ---------------------------------------------------------
|
||
|
||
def set_summary(self, s: dict, co_du_lieu: bool) -> None:
|
||
lines: List[str] = []
|
||
if not co_du_lieu:
|
||
lines.append("<i>%s</i>" % tr("dashboard.no_data"))
|
||
self.habits.setHtml("".join(lines))
|
||
return
|
||
|
||
lines.append("<b>%s</b>" % tr("dashboard.h_top"))
|
||
lines.append("<ol>")
|
||
for label, tok in s["top_labels"]:
|
||
pct = int(tok * 100 / s["total"]) if s["total"] else 0
|
||
lines.append("<li>%s — %s tokens (%d%%)</li>"
|
||
% (label[:60], _fmt_tokens(tok), pct))
|
||
lines.append("</ol>")
|
||
src_parts = ", ".join(
|
||
"%s: %s" % (tr("app.tab.%s" % k) if k in ("cowork", "code") else k,
|
||
_fmt_tokens(v))
|
||
for k, v in s["by_source"])
|
||
lines.append("<b>%s</b>: %s<br>" % (tr("dashboard.h_by_source"), src_parts))
|
||
lines.append("<b>%s</b>: %s tokens<br>"
|
||
% (tr("dashboard.h_avg"), _fmt_tokens(s["avg_per_turn"])))
|
||
if s["busiest_day"]:
|
||
lines.append("<b>%s</b>: %s<br>"
|
||
% (tr("dashboard.h_busiest_day"), s["busiest_day"]))
|
||
if s["busiest_hour"] is not None:
|
||
h = s["busiest_hour"]
|
||
lines.append("<b>%s</b>: %02d:00–%02d:59<br>"
|
||
% (tr("dashboard.h_busiest_hour"), h, h))
|
||
if s["estimated_share"] > 0:
|
||
lines.append("<i>%s</i>" % tr("dashboard.estimated_note",
|
||
pct=int(s["estimated_share"] * 100)))
|
||
self.habits.setHtml("".join(lines))
|
||
|
||
# ---- nhận xét của AI -------------------------------------------------
|
||
|
||
def phan_tich(self) -> None:
|
||
"""Gửi CÁC CON SỐ ĐÃ GỘP (không bao giờ gửi nội dung chat) cho provider
|
||
đang chọn, rồi hiện nhận xét về thói quen và cách tiết kiệm token."""
|
||
if self._worker is not None:
|
||
return
|
||
events = ut.load_events(*self._khoang())
|
||
if not events:
|
||
self.status_message.emit(tr("dashboard.no_data"))
|
||
return
|
||
|
||
summary = ut.summarize(events)
|
||
self.ai_analyze_btn.setEnabled(False)
|
||
self.ai_analyze_btn.setText(tr("dashboard.ai_analyzing"))
|
||
ctx = self.ctx
|
||
|
||
def job(worker: AgentWorker):
|
||
from ...i18n import get_language
|
||
prompt = ut.build_ai_analysis_prompt(summary, get_language())
|
||
reply = ctx.build_active_provider().chat(
|
||
[{"role": "user", "content": prompt}], cancel=worker.stop_event)
|
||
return {"text": (reply.get("content") or "").strip()}
|
||
|
||
def done(result: dict) -> None:
|
||
self._xong()
|
||
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:
|
||
self._xong()
|
||
self.status_message.emit(str(err))
|
||
|
||
w = AgentWorker(job)
|
||
w.finished_ok.connect(done)
|
||
w.failed.connect(failed)
|
||
self._worker = w
|
||
w.start()
|
||
|
||
def _xong(self) -> None:
|
||
self._worker = None
|
||
self.ai_analyze_btn.setEnabled(True)
|
||
self.ai_analyze_btn.setText(tr("dashboard.ai_analyze_btn"))
|
||
|
||
#: Dashboard gán hàm trả về (start, end) của khoảng đang lọc.
|
||
_khoang = staticmethod(lambda: (None, None))
|
||
|
||
# ---- áp dụng chiến lược tiết kiệm ------------------------------------
|
||
|
||
def _ap_dung(self) -> None:
|
||
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 # nén ở 60% cửa sổ (trước ~80%)
|
||
cx["compress_before_send"] = True # gộp ngữ cảnh trước mỗi lượt
|
||
self.ctx.save()
|
||
self.strategy_approved.emit()
|
||
self.status_message.emit(tr("dashboard.strategy_applied"))
|