feat(chat): màn giới thiệu 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ó, và là trạng thái duy nhất người dùng nhìn thấy trước khi gõ chữ đầu tiên.

Gồm lời chào theo tên, dòng bối cảnh (project · số tệp · số skill đang bật), và
bốn thẻ gợi ý.

Hai quyết định:
- Thẻ ĐIỀN câu gợi ý vào ô nhập chứ 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.
- Dấu phía trên lời chào không bấm được — nó là dấu hiệu thị giác. Một nút không
  làm gì tệ hơn không có nút.

Dòng bối cảnh phân biệt KHÔNG BIẾT với 0: đếm được 0 tệp thì hiện "0 tệp", còn
không đọc được thư mục thì bỏ hẳn mảnh đó — 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à thiếu một mảnh.

show_welcome() được móc ở ba chỗ: new_session(), load_conversation() (theo số
tin nhắn đã lưu), và TRƯỚC mọi nhánh add_user trong turn runner — đặt sau từng
add_user() thì dễ sót đúng một nhánh, và nhánh đó sẽ hiện cả hai thứ cùng lúc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-07 19:55:43 +09:00
co-authored by Claude Opus 5
parent b2e2791ff6
commit 64900a2504
7 changed files with 460 additions and 0 deletions
+65
View File
@@ -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}
+4
View File
@@ -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", "")
+4
View File
@@ -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)
+171
View File
@@ -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()