diff --git a/i18n/composer.py b/i18n/composer.py index 7211d70..1c44781 100644 --- a/i18n/composer.py +++ b/i18n/composer.py @@ -9,6 +9,64 @@ from __future__ import annotations from typing import Dict STRINGS: Dict[str, Dict[str, str]] = { + # ---- man gioi thieu cua khung chat (trang thai rong) ------------------ + "welcome.greeting": { + "en": "Hi {name} — where would you like to start?", + "ja": "{name} さん、どこから始めましょうか?", + "vi": "Chào {name} — bắt đầu từ đâu?"}, + "welcome.greeting_anon": { + "en": "Where would you like to start?", "ja": "どこから始めましょうか?", + "vi": "Bắt đầu từ đâu?"}, + "welcome.meta_project": { + "en": "Working in {name}", "ja": "{name} で作業中", + "vi": "Đang làm trong {name}"}, + "welcome.meta_files": { + "en": "{n} file(s) in the local folder", "ja": "ローカルフォルダに {n} 件", + "vi": "{n} tệp trong thư mục local"}, + "welcome.meta_skills": { + "en": "Skills: {n} on", "ja": "スキル: {n} 個オン", "vi": "Skills: {n} bật"}, + + "welcome.card_docs": { + "en": "Summarise documents", "ja": "ドキュメントを要約", "vi": "Tóm tắt tài liệu"}, + "welcome.card_docs_sub": { + "en": "Read the files in the local folder", "ja": "ローカルフォルダのファイルを読む", + "vi": "Đọc các tệp trong thư mục local"}, + "welcome.prompt_docs": { + "en": "Read the files in this project's folder and summarise each one.", + "ja": "このプロジェクトのフォルダにあるファイルを読み、それぞれ要約してください。", + "vi": "Đọc các tệp trong thư mục của project này và tóm tắt từng tệp."}, + + "welcome.card_data": { + "en": "Analyse data", "ja": "データを分析", "vi": "Phân tích dữ liệu"}, + "welcome.card_data_sub": { + "en": "Spreadsheets, CSV, logs", "ja": "表計算、CSV、ログ", + "vi": "Bảng tính, CSV, log"}, + "welcome.prompt_data": { + "en": "Analyse the spreadsheet/CSV/log files in this folder and report what stands out.", + "ja": "このフォルダの表計算/CSV/ログを分析し、目立つ点を報告してください。", + "vi": "Phân tích các tệp bảng tính/CSV/log trong thư mục này và nêu những điểm đáng chú ý."}, + + "welcome.card_schedule": { + "en": "Set up a schedule", "ja": "スケジュールを作成", "vi": "Dựng lịch chạy"}, + "welcome.card_schedule_sub": { + "en": "Create a daily Schedule Task", "ja": "毎日実行する Schedule Task を作成", + "vi": "Tạo Schedule Task hàng ngày"}, + "welcome.prompt_schedule": { + "en": "Help me set up a Schedule Task that runs every day. Ask me what it should do.", + "ja": "毎日実行する Schedule Task の作成を手伝ってください。何をするか質問してください。", + "vi": "Giúp tôi dựng một Schedule Task chạy hàng ngày. Hỏi tôi nó cần làm gì."}, + + "welcome.card_graph": { + "en": "Ask GraphRAG", "ja": "GraphRAG に質問", "vi": "Hỏi GraphRAG"}, + "welcome.card_graph_sub": { + "en": "Query this project's knowledge graph", + "ja": "このプロジェクトの知識グラフを検索", + "vi": "Truy vấn đồ thị tri thức của project"}, + "welcome.prompt_graph": { + "en": "Using this project's knowledge graph, explain how the main pieces fit together.", + "ja": "このプロジェクトの知識グラフを使って、主要な要素の関係を説明してください。", + "vi": "Dùng đồ thị tri thức của project này, giải thích các phần chính ghép với nhau thế nào."}, + "chatpanel.agent_tooltip": { "en": "Model/agent for THIS tab — independent of the other tab", "ja": "このタブ専用のモデル/エージェント(他のタブとは独立)", diff --git a/presentation/chat/chat_panel_layout.py b/presentation/chat/chat_panel_layout.py index c8988d8..0f659cd 100644 --- a/presentation/chat/chat_panel_layout.py +++ b/presentation/chat/chat_panel_layout.py @@ -27,6 +27,7 @@ from ...state import AppContext from ...theme import current_palette from .chat_bubble_style import ThinkingIndicator from .chat_history_widget import ChatView +from .chat_welcome import ChatWelcome from .composer_widget import Composer from ...ui.icons import collapse_right_icon, icon as app_icon from ...ui.osutil import is_image, open_path @@ -43,7 +44,13 @@ class ChatPanelLayoutMixin: cc = QVBoxLayout(chat_col) cc.setContentsMargins(0, 0, 0, 0) cc.setSpacing(0) + # Man gioi thieu chiem dung cho cua khung chat va thay the no khi hoi + # thoai con rong — hai thu khong bao gio cung hien. + self.welcome = ChatWelcome() + self.welcome.suggestion_picked.connect(self._use_suggestion) + cc.addWidget(self.welcome, 1) cc.addWidget(self.chat_view, 1) + self.chat_view.hide() # phien moi thi rong -> man gioi thieu di truoc self.thinking = ThinkingIndicator() # animated "working…" line while we wait cc.addWidget(self.thinking) self.center_split = QSplitter(Qt.Horizontal) @@ -148,3 +155,61 @@ class ChatPanelLayoutMixin: self.center_split.setChildrenCollapsible(False) self.center_split.setSizes([820, 220]) on_language_changed(self._retranslate_base) + + # ---- man gioi thieu ---------------------------------------------------- + def _use_suggestion(self, text: str) -> None: + """Thẻ gợi ý được bấm: ĐIỀN vào ô nhập, không gửi luôn. + + Câu gợi ý là điểm bắt đầu — người dùng gần như luôn cần thêm chi tiết + của riêng họ, và gửi ngay sẽ tiêu một lượt gọi model cho một câu hỏi + chung chung. + """ + self.composer.input.setPlainText(text) + self.composer.input.setFocus() + + def show_welcome(self, show: bool) -> None: + """Bật màn giới thiệu (hội thoại rỗng) hoặc khung chat (đã có tin).""" + welcome = getattr(self, "welcome", None) + if welcome is None: + return + welcome.setVisible(show) + self.chat_view.setVisible(not show) + if show: + welcome.refresh(**self._welcome_context()) + + def _welcome_context(self) -> dict: + """Dữ liệu cho dòng bối cảnh. Không biết thì trả -1, KHÔNG trả 0. + + Hiện "0 tệp" khi người dùng vừa nhìn thấy tệp trong thư mục còn tệ hơn + là bỏ mảnh đó khỏi dòng meta. + """ + from pathlib import Path as _P + + ten = "" + try: + from ...core.projects import load_project + project = load_project(self.project_id) if getattr(self, "project_id", "") else None + ten = project.name if project is not None else "" + except Exception: # noqa: BLE001 + ten = "" + + so_tep = -1 + try: + folder = self.workspace_dir() + if folder is not None and _P(folder).is_dir(): + so_tep = sum(1 for f in _P(folder).rglob("*") + if f.is_file() and f.suffix.lower() in self._INPUT_EXTS) + except Exception: # noqa: BLE001 + so_tep = -1 + + so_skill = -1 + try: + from ...core.skills import list_skills + so_skill = sum(1 for sk in list_skills() if getattr(sk, "enabled", False)) + except Exception: # noqa: BLE001 + so_skill = -1 + + # Ten nguoi dung do cua so chinh giu (app.py truyen xuong MainWindow). + window = self.window() + return {"user_name": getattr(window, "_user_name", "") or "", + "project": ten, "files": so_tep, "skills": so_skill} diff --git a/presentation/chat/chat_session_store.py b/presentation/chat/chat_session_store.py index ed8c9b0..e9f150d 100644 --- a/presentation/chat/chat_session_store.py +++ b/presentation/chat/chat_session_store.py @@ -194,6 +194,8 @@ class ChatSessionMixin: """ from ...core.history import new_session_id + self.show_welcome(True) # hội thoại rỗng -> màn giới thiệu + # Allowed while work is running: current turns keep going in the background. self._detach_live_turns() self.messages = [] @@ -230,6 +232,8 @@ class ChatSessionMixin: # turn must NOT tear down its live rendering — just no-op. if sid == self.session_id and self._view_busy(): return + # Hoi thoai da luu thi co tin nhan -> khung chat, khong phai man gioi thieu. + self.show_welcome(not (conv.get("messages") or [])) self._detach_live_turns() self.session_id = sid self.title = conv.get("title", "") diff --git a/presentation/chat/chat_turn_runner.py b/presentation/chat/chat_turn_runner.py index 3dfe33d..215b3c7 100644 --- a/presentation/chat/chat_turn_runner.py +++ b/presentation/chat/chat_turn_runner.py @@ -65,6 +65,10 @@ class ChatTurnRunnerMixin: attachments = attachments or [] typed = text prefix, request, info = self._apply_skill_command(text) + # Moi duong tra ve som duoi day cung them mot bong nguoi dung vao khung, + # nen man gioi thieu phai nhuong cho ngay tai day — dat sau tung + # add_user() thi de sot dung mot nhanh, va nhanh do se hien ca hai thu. + self.show_welcome(False) if info is not None: # A local /skill command (list / select / error) — answer inline. self.chat_view.add_user(typed) diff --git a/presentation/chat/chat_welcome.py b/presentation/chat/chat_welcome.py new file mode 100644 index 0000000..1b44a72 --- /dev/null +++ b/presentation/chat/chat_welcome.py @@ -0,0 +1,171 @@ +"""Màn giới thiệu của khung chat khi hội thoại còn rỗng. + +Bấm "Cuộc trò chuyện mới" trước đây để lại một khung trắng: không có gì nói +người dùng đang làm trong project nào, thư mục có bao nhiêu tệp, hay bắt đầu từ +đâu. Đây là trạng thái RỖNG — một trong bốn trạng thái mà mọi khung dữ liệu phải +có (xem ``agent/checklist/ux_review.md``), và là trạng thái duy nhất người dùng +nhìn thấy trước khi họ gõ chữ đầu tiên. + +Bốn thẻ gợi ý ĐIỀN vào ô nhập chứ không gửi luôn: câu gợi ý là điểm bắt đầu, và +người dùng gần như luôn cần thêm chi tiết của riêng họ trước khi gửi. Gửi ngay +sẽ tiêu một lượt gọi model cho một câu hỏi chung chung. + +Dấu trang trí phía trên lời chào không bấm được — nó là một dấu hiệu thị giác, +không phải nút. Một nút không làm gì tệ hơn không có nút. +""" +from __future__ import annotations + +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import ( + QGridLayout, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget, +) + +from ...i18n import on_language_changed, tr +from ...ui.icons import icon + +#: (khoá tiêu đề, khoá mô tả, khoá câu gợi ý, tên icon) cho từng thẻ. +_CARDS = ( + ("welcome.card_docs", "welcome.card_docs_sub", "welcome.prompt_docs", "file"), + ("welcome.card_data", "welcome.card_data_sub", "welcome.prompt_data", "table"), + ("welcome.card_schedule", "welcome.card_schedule_sub", "welcome.prompt_schedule", "schedule"), + ("welcome.card_graph", "welcome.card_graph_sub", "welcome.prompt_graph", "graph"), +) + + +class _Card(QPushButton): + """Một thẻ gợi ý: icon, tiêu đề, và một dòng mô tả bên dưới.""" + + def __init__(self, title_key: str, sub_key: str, icon_name: str, + parent: QWidget | None = None) -> None: + """Thẻ gợi ý. Là ``QPushButton`` để có sẵn hover, focus và bàn phím.""" + super().__init__(parent) + self._title_key = title_key + self._sub_key = sub_key + self.setObjectName("welcomeCard") + self.setCursor(Qt.PointingHandCursor) + + row = QHBoxLayout(self) + row.setContentsMargins(12, 10, 12, 10) + row.setSpacing(10) + + self._icon = QLabel() + self._icon.setPixmap(icon(icon_name).pixmap(18, 18)) + self._icon.setAlignment(Qt.AlignTop) + row.addWidget(self._icon) + + col = QVBoxLayout() + col.setContentsMargins(0, 0, 0, 0) + col.setSpacing(2) + self.title_label = QLabel() + self.title_label.setObjectName("welcomeCardTitle") + self.sub_label = QLabel() + self.sub_label.setObjectName("hint") + self.sub_label.setWordWrap(True) + col.addWidget(self.title_label) + col.addWidget(self.sub_label) + row.addLayout(col, 1) + + self.retranslate() + + def retranslate(self) -> None: + """Áp lại chữ theo ngôn ngữ đang chọn.""" + self.title_label.setText(tr(self._title_key)) + self.sub_label.setText(tr(self._sub_key)) + # Nhãn của chính QPushButton để rỗng — chữ do hai QLabel bên trong vẽ, + # đặt cả hai chỗ sẽ in đè lên nhau. + self.setAccessibleName(tr(self._title_key)) + + +class ChatWelcome(QWidget): + """Trạng thái rỗng của khung chat: lời chào, dòng bối cảnh, bốn thẻ gợi ý.""" + + #: Người dùng chọn một thẻ. Mang theo câu gợi ý để chỗ nhận điền vào ô nhập. + suggestion_picked = Signal(str) + + def __init__(self, parent: QWidget | None = None) -> None: + """Dựng màn giới thiệu. Chữ được điền qua :meth:`refresh`.""" + super().__init__(parent) + self.setObjectName("chatWelcome") + self._user_name = "" + self._meta_parts: list = [] + + root = QVBoxLayout(self) + root.setContentsMargins(24, 24, 24, 24) + root.addStretch(1) + + mark_row = QHBoxLayout() + mark_row.addStretch(1) + self._mark = QLabel() + self._mark.setObjectName("welcomeMark") + self._mark.setPixmap(icon("sparkle").pixmap(20, 20)) + self._mark.setAlignment(Qt.AlignCenter) + self._mark.setFixedSize(38, 38) + mark_row.addWidget(self._mark) + mark_row.addStretch(1) + root.addLayout(mark_row) + root.addSpacing(14) + + self.greeting_label = QLabel() + self.greeting_label.setObjectName("welcomeGreeting") + self.greeting_label.setAlignment(Qt.AlignCenter) + root.addWidget(self.greeting_label) + + self.meta_label = QLabel() + self.meta_label.setObjectName("hint") + self.meta_label.setAlignment(Qt.AlignCenter) + root.addWidget(self.meta_label) + root.addSpacing(18) + + grid_row = QHBoxLayout() + grid_row.addStretch(1) + grid_host = QWidget() + grid_host.setMaximumWidth(460) + grid = QGridLayout(grid_host) + grid.setContentsMargins(0, 0, 0, 0) + grid.setSpacing(10) + self.cards: list = [] + for i, (title_key, sub_key, prompt_key, icon_name) in enumerate(_CARDS): + card = _Card(title_key, sub_key, icon_name) + card.clicked.connect( + lambda _checked=False, key=prompt_key: self.suggestion_picked.emit(tr(key))) + grid.addWidget(card, i // 2, i % 2) + self.cards.append(card) + grid_row.addWidget(grid_host) + grid_row.addStretch(1) + root.addLayout(grid_row) + + root.addStretch(2) + + on_language_changed(self._retranslate) + + # ---- nội dung ---------------------------------------------------------- + + def refresh(self, user_name: str = "", project: str = "", + files: int = -1, skills: int = -1) -> None: + """Cập nhật lời chào và dòng bối cảnh. + + ``files`` / ``skills`` bằng ``-1`` nghĩa là KHÔNG BIẾT, và phần đó bị bỏ + khỏi dòng meta — thà thiếu một mảnh còn hơn hiện số 0 mà người dùng vừa + thấy có tệp trong thư mục. + """ + self._user_name = (user_name or "").strip() + parts = [] + if (project or "").strip(): + parts.append(tr("welcome.meta_project", name=project.strip())) + if files >= 0: + parts.append(tr("welcome.meta_files", n=files)) + if skills >= 0: + parts.append(tr("welcome.meta_skills", n=skills)) + self._meta_parts = parts + self._retranslate() + + def _retranslate(self) -> None: + """Áp lại chữ theo ngôn ngữ đang chọn.""" + if self._user_name: + self.greeting_label.setText(tr("welcome.greeting", name=self._user_name)) + else: + self.greeting_label.setText(tr("welcome.greeting_anon")) + self.meta_label.setText(" · ".join(self._meta_parts)) + self.meta_label.setVisible(bool(self._meta_parts)) + for card in self.cards: + card.retranslate() diff --git a/tests/ui/test_chat_welcome.py b/tests/ui/test_chat_welcome.py new file mode 100644 index 0000000..47d2ee6 --- /dev/null +++ b/tests/ui/test_chat_welcome.py @@ -0,0 +1,143 @@ +"""Màn giới thiệu của khung chat khi hội thoại còn rỗng. + +Bấm "Cuộc trò chuyện mới" trước đây để lại một khung trắng: không có gì nói +người dùng đang làm trong project nào, thư mục có bao nhiêu tệp, hay bắt đầu từ +đâu. Đây là trạng thái RỖNG — trạng thái duy nhất người dùng nhìn thấy trước khi +họ gõ chữ đầu tiên. +""" +from __future__ import annotations + +import pytest + +pytest.importorskip("PySide6", reason="cần PySide6 để dựng widget thật") + + +@pytest.fixture +def welcome(qapp): + from cowork_local.presentation.chat.chat_welcome import ChatWelcome + w = ChatWelcome() + yield w + w.deleteLater() + + +# ---- bốn thẻ gợi ý ------------------------------------------------------- + +def test_co_dung_bon_the(welcome): + assert len(welcome.cards) == 4 + + +def test_bam_the_thi_phat_cau_goi_y(welcome): + nhan = [] + welcome.suggestion_picked.connect(nhan.append) + + welcome.cards[0].click() + + assert len(nhan) == 1 + assert len(nhan[0]) > 20, "câu gợi ý phải là một câu thật, không phải nhãn thẻ" + + +def test_bon_the_cho_bon_cau_khac_nhau(welcome): + nhan = [] + welcome.suggestion_picked.connect(nhan.append) + + for card in welcome.cards: + card.click() + + assert len(set(nhan)) == 4 + + +def test_the_co_tieu_de_va_mo_ta(welcome): + for card in welcome.cards: + assert card.title_label.text().strip() + assert card.sub_label.text().strip() + # Nhãn của chính QPushButton phải rỗng — chữ do hai QLabel bên trong vẽ, + # đặt cả hai chỗ sẽ in đè lên nhau. + assert card.text() == "" + + +# ---- dòng bối cảnh: KHÔNG BIẾT khác 0 ------------------------------------ + +def test_khong_biet_so_tep_thi_bo_manh_do(welcome): + """Hiện "0 tệp" khi người dùng vừa thấy có tệp trong thư mục còn tệ hơn là + bỏ mảnh đó khỏi dòng meta.""" + welcome.refresh(user_name="local", project="p", files=-1, skills=-1) + + meta = welcome.meta_label.text() + assert "p" in meta + assert "0" not in meta + + +def test_khong_co_tep_that_thi_van_hien_so_0(welcome): + """Khác với KHÔNG BIẾT: thư mục rỗng thật thì nói rõ là rỗng.""" + welcome.refresh(user_name="local", project="p", files=0, skills=0) + + assert "0" in welcome.meta_label.text() + + +def test_khong_co_gi_de_noi_thi_an_dong_meta(welcome): + welcome.refresh() + + assert welcome.meta_label.isHidden() is True + + +def test_co_ten_thi_chao_ten(welcome): + welcome.refresh(user_name="Mynt4") + + assert "Mynt4" in welcome.greeting_label.text() + + +def test_khong_co_ten_thi_khong_chao_rong(welcome): + """Không được ra "Chào — bắt đầu từ đâu?" với một khoảng trống.""" + from cowork_local.i18n import tr + + welcome.refresh(user_name="") + + assert welcome.greeting_label.text() == tr("welcome.greeting_anon") + + +# ---- i18n ---------------------------------------------------------------- + +@pytest.mark.parametrize("key", [ + "welcome.greeting", "welcome.greeting_anon", "welcome.meta_project", + "welcome.meta_files", "welcome.meta_skills", + "welcome.card_docs", "welcome.card_docs_sub", "welcome.prompt_docs", + "welcome.card_data", "welcome.card_data_sub", "welcome.prompt_data", + "welcome.card_schedule", "welcome.card_schedule_sub", "welcome.prompt_schedule", + "welcome.card_graph", "welcome.card_graph_sub", "welcome.prompt_graph", +]) +def test_key_co_du_ba_ngon_ngu(key): + from cowork_local import i18n + + for lang in ("en", "ja", "vi"): + assert i18n.STRINGS[key].get(lang), f"{key} thiếu {lang}" + + +def test_doi_ngon_ngu_thi_the_doi_theo(welcome): + from cowork_local import i18n + + truoc = i18n.get_language() + try: + i18n.set_language("vi") + welcome.refresh(user_name="x", project="p") + vi = welcome.cards[0].title_label.text() + i18n.set_language("ja") + ja = welcome.cards[0].title_label.text() + finally: + i18n.set_language(truoc) + + assert vi != ja, "thẻ không được áp lại khi đổi ngôn ngữ" + + +# ---- màu lấy từ theme ---------------------------------------------------- + +def test_duoc_style_bang_token_trong_theme(): + """Guardrail G4: ngoài theme/ không file nào được đặt tên một màu.""" + from pathlib import Path + from cowork_local.theme.qss import _TEMPLATE + + for rule in ("QWidget#chatWelcome", "QPushButton#welcomeCard", + "QLabel#welcomeGreeting"): + assert rule in _TEMPLATE.template, f"{rule} chưa được style trong theme" + src = (Path(__file__).resolve().parents[2] + / "presentation" / "chat" / "chat_welcome.py").read_text(encoding="utf-8") + assert "setStyleSheet" not in src diff --git a/theme/qss_controls.py b/theme/qss_controls.py index 7a9adf2..ce822ad 100644 --- a/theme/qss_controls.py +++ b/theme/qss_controls.py @@ -303,6 +303,21 @@ QPushButton#detailCopyBtn:hover { background: rgba(255,255,255,0.25); } QLabel { background: transparent; } QLabel#hint { color: $text_muted; } +/* Man gioi thieu cua khung chat (trang thai rong). */ +QWidget#chatWelcome { background: $bg; } +QLabel#welcomeGreeting { color: $text; font-size: 19px; font-weight: 600; } +QLabel#welcomeMark { + background: $accent_soft; border: 1px solid $border_strong; + border-radius: ${radius}px; +} +QPushButton#welcomeCard { + background: $surface; border: 1px solid $border; border-radius: ${radius}px; + text-align: left; padding: 0px; +} +QPushButton#welcomeCard:hover { background: $hover; border-color: $border_strong; } +QPushButton#welcomeCard:focus { border: 1px solid $focus_ring; } +QLabel#welcomeCardTitle { color: $text; font-weight: 600; } + /* Panel "dang tai" phu len khung do thi GraphRAG. Dung khung QWebEngineView la viec DONG BO tren GUI thread (~1-2s), nen thanh tien trinh o day khong chay duoc trong giai doan do — panel van phai nhin thay duoc, va do la ly do no co