presentation/chat/
chat_history_widget.py 348 T01 mạch hội thoại (từ ui/chat_view.py)
chat_bubble_style.py 202 T01 cách vẽ bong bóng, diff, đường thời gian
composer_widget.py 364 T02 thanh công cụ quanh ô nhập
chat_input_box.py 328 T02 ô nhập: Ctrl+Enter, dán ảnh, popup /skill
attachment_picker.py 215 T03 đọc tệp đính kèm + chặn theo chính sách
chat_output_panel.py 186 T05 theo dõi thư mục output, hiện tệp mới
chat_turn_runner.py 281 T06 chạy một lượt
chat_event_stream.py 228 T06 nhận sự kiện phát về từ luồng nền
chat_session_store.py 413 T06 lưu/nạp phiên, đếm token, nối lại lượt
chat_agents.py 246 T06 chọn agent, skill, định tuyến model
chat_panel_layout.py 148 T06 bố cục hai cột
chat_helpers.py 53 T06 hàm và bảng tra dùng chung
ui/chat_panel.py 345 __init__ + trạng thái
ui/chat_view.py 10 vỏ chuyển tiếp
ui/composer.py 11 vỏ chuyển tiếp
R08-T04 KHÔNG LÀM ĐƯỢC: plan đòi audio_recorder_widget.py, nhưng trong repo
KHÔNG CÓ chức năng ghi âm nào — grep 'audio|record|voice|micro' toàn ui/ chỉ
ra chữ 'record' trong nghĩa 'ghi lại transcript'. Không có gì để tách, và tôi
không dựng một widget mới nhân danh refactor. Giống hệt trường hợp
connector_settings_widget.py ở T07.
_start_turn (144 dòng) và _on_event (127) để nguyên có chủ ý: cái đầu dựng
trọn ngữ cảnh một lượt rồi giao cho luồng nền, cái sau phân nhánh theo loại sự
kiện. Cắt nhỏ thì phải chuyền hàng chục biến trạng thái qua lại, đọc khó hơn.
Hai lỗi tự gây, cả hai đều do script:
* regex bỏ import cũ chỉ cắt DÒNG ĐẦU của một import nhiều dòng, để lại phần
đuôi mồ côi -> IndentationError.
* _build_layout dùng biến 'root' vốn cục bộ trong __init__. Bộ test bắt được
cái này (2 bài integration đỏ), không phải checker — vì nó là lỗi dựng
widget, không phải lỗi hình học.
756 test xanh. 24/24 checker qua.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
329 lines
14 KiB
Python
329 lines
14 KiB
Python
"""Ô nhập của khung chat — R08-T02.
|
|
|
|
Tự giãn cao theo nội dung, Ctrl+Enter để gửi, dán ảnh từ clipboard thành tệp
|
|
đính kèm, và popup gợi ý khi gõ ``/skill`` hoặc ``/agent``.
|
|
|
|
Tách khỏi ``composer_widget.py`` vì đây là phần bắt phím và chuột; phần kia
|
|
là thanh công cụ quanh nó.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Dict, List
|
|
from PySide6.QtCore import Qt, Signal
|
|
from PySide6.QtGui import QImage, QKeyEvent
|
|
from PySide6.QtWidgets import (
|
|
QFileDialog, QHBoxLayout, QLabel, QListView, QListWidget, QListWidgetItem,
|
|
QPlainTextEdit, QPushButton, QVBoxLayout, QWidget,
|
|
)
|
|
from ...config import CONFIG_DIR
|
|
from ...i18n import on_language_changed, tr
|
|
from ...theme import current_palette
|
|
from ...ui.icons import icon, IconLabel
|
|
|
|
|
|
class _SkillPopup(QListWidget):
|
|
"""The ``/skill`` picker.
|
|
|
|
Shown as a NON-activating overlay (``WA_ShowWithoutActivating``) — crucially it
|
|
does NOT grab the keyboard, so the input keeps focus and the user can keep
|
|
typing their request after ``/skill``. Navigation / accept / Esc are handled by
|
|
the parent ``_Input``'s key handler (which still receives every key); clicking
|
|
an item selects it; the popup auto-hides when the input loses focus."""
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self.setWindowFlags(Qt.Tool | Qt.FramelessWindowHint
|
|
| Qt.WindowStaysOnTopHint | Qt.NoDropShadowWindowHint)
|
|
self.setAttribute(Qt.WA_ShowWithoutActivating, True)
|
|
self.setFocusPolicy(Qt.NoFocus)
|
|
|
|
|
|
class _Input(QPlainTextEdit):
|
|
"""Plain text edit: submits on Enter, accepts pasted/dropped images & files."""
|
|
|
|
submit = Signal()
|
|
media_added = Signal(list)
|
|
manage_skills = Signal() # user picked "Manage skills…" in the /skill popup
|
|
|
|
MIN_HEIGHT = 64 # ~2 lines
|
|
MAX_HEIGHT = 220 # ~8 lines, then it scrolls
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.setAcceptDrops(True)
|
|
# Use a clean Latin/Vietnamese-friendly UI font for the input (the global
|
|
# '*' rule falls back to Japanese faces, which mis-render some glyphs).
|
|
self.setStyleSheet(
|
|
"font-family: 'Segoe UI', 'Helvetica Neue', 'Arial', sans-serif; font-size: 14px;"
|
|
)
|
|
# Grow with the text (up to MAX_HEIGHT), then scroll instead.
|
|
self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
|
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
|
self.textChanged.connect(self._adjust_height)
|
|
# "/skill" + "/agent" command popup — lists skills / agents inline.
|
|
self._skill_popup = _SkillPopup(self)
|
|
self._popup_kind = "skill" # which command the popup is showing
|
|
self._skill_popup.itemClicked.connect(self._accept_item)
|
|
self.textChanged.connect(self._maybe_show_skills)
|
|
self._adjust_height()
|
|
|
|
# ---- /skill autocomplete ----------------------------------------
|
|
def _skill_token(self):
|
|
"""Locate a ``/skill[:partial]`` command the cursor is currently typing —
|
|
ANYWHERE in the message, not just at the start (so "dùng /skill:foo …"
|
|
with text typed before it still triggers the picker). Mirrors
|
|
``core.skills.parse_skill_command``'s whitespace-boundary rule.
|
|
|
|
Returns ``(start_offset, partial_filter)`` — ``start_offset`` is where the
|
|
``/skill`` token begins in the document, ``partial_filter`` is the text
|
|
typed after ``:`` (``''`` while still typing the command word itself) — or
|
|
``None`` when the cursor isn't inside a ``/skill`` token."""
|
|
import re
|
|
pos = self.textCursor().position()
|
|
before = self.toPlainText()[:pos]
|
|
# The token is the whitespace-delimited word ending at the cursor; its
|
|
# start must be the document start or follow whitespace (same boundary
|
|
# parse_skill_command enforces with its (?<!\S) lookbehind).
|
|
start = re.search(r"\S*$", before).start()
|
|
token = before[start:]
|
|
if len(token) >= 2 and "/skill".startswith(token):
|
|
return start, "" # typing "/s", "/sk", … "/skill" → show the whole list
|
|
m = re.match(r"^/skill:?([\w\-.]*)$", token)
|
|
return (start, m.group(1)) if m else None
|
|
|
|
def _skill_filter(self):
|
|
"""Return the partial filter while a '/skill' command is being typed
|
|
(anywhere in the message), or None."""
|
|
tok = self._skill_token()
|
|
return tok[1] if tok else None
|
|
|
|
def _agent_token(self):
|
|
"""Locate a ``/agent[:partial]`` command the cursor is typing (mirror of
|
|
``_skill_token``). Returns ``(start_offset, partial)`` or None."""
|
|
import re
|
|
pos = self.textCursor().position()
|
|
before = self.toPlainText()[:pos]
|
|
start = re.search(r"\S*$", before).start()
|
|
token = before[start:]
|
|
if len(token) >= 2 and "/agent".startswith(token):
|
|
return start, ""
|
|
m = re.match(r"^/agent:?([\w\-.]*)$", token)
|
|
return (start, m.group(1)) if m else None
|
|
|
|
def _maybe_show_skills(self) -> None:
|
|
# One popup serves both commands: show skills while typing /skill, agents
|
|
# while typing /agent (Cowork parity with the Co4E chat).
|
|
stok = self._skill_token()
|
|
if stok is not None:
|
|
self._popup_kind = "skill"
|
|
self._populate_skill_popup(stok[1])
|
|
self._show_cmd_popup()
|
|
return
|
|
atok = self._agent_token()
|
|
if atok is not None:
|
|
self._popup_kind = "agent"
|
|
self._populate_agent_popup(atok[1])
|
|
self._show_cmd_popup()
|
|
return
|
|
self._skill_popup.hide()
|
|
|
|
def _populate_skill_popup(self, filt: str) -> None:
|
|
try:
|
|
from ..core.skills import builtin_skills, list_skills
|
|
# Include always-on built-ins so the picker is usable before the user
|
|
# has created any custom skill.
|
|
skills = list_skills() + builtin_skills()
|
|
except Exception:
|
|
skills = []
|
|
f = (filt or "").lower()
|
|
matches = [s for s in skills
|
|
if f in s.name.lower() or f in s.slug.lower() or f in (s.description or "").lower()]
|
|
self._skill_popup.clear()
|
|
for s in matches:
|
|
text = ("✓ " if s.enabled else " ") + s.name
|
|
if s.description:
|
|
text += f" — {s.description}"
|
|
item = QListWidgetItem(text)
|
|
item.setData(Qt.UserRole, s.slug)
|
|
self._skill_popup.addItem(item)
|
|
if not matches:
|
|
empty = QListWidgetItem(tr("composer.no_skills"))
|
|
empty.setFlags(Qt.NoItemFlags)
|
|
self._skill_popup.addItem(empty)
|
|
manage = QListWidgetItem(tr("composer.manage_skills"))
|
|
manage.setData(Qt.UserRole, "__manage__")
|
|
self._skill_popup.addItem(manage)
|
|
self._skill_popup.setCurrentRow(0 if matches else self._skill_popup.count() - 1)
|
|
|
|
def _populate_agent_popup(self, filt: str) -> None:
|
|
try:
|
|
from ..core.agent_command import collect_agents
|
|
agents = collect_agents("") # built-ins + local admin + custom agents
|
|
except Exception:
|
|
agents = []
|
|
f = (filt or "").lower()
|
|
matches = [a for a in agents
|
|
if f in a["slug"].lower() or f in a["name"].lower() or f in (a.get("desc") or "").lower()]
|
|
self._skill_popup.clear()
|
|
for a in matches:
|
|
text = a["name"] + (f" — {a['desc']}" if a.get("desc") else "")
|
|
item = QListWidgetItem(text)
|
|
item.setData(Qt.UserRole, a["slug"])
|
|
self._skill_popup.addItem(item)
|
|
if not matches:
|
|
empty = QListWidgetItem(tr("composer.no_agents"))
|
|
empty.setFlags(Qt.NoItemFlags)
|
|
self._skill_popup.addItem(empty)
|
|
self._skill_popup.setCurrentRow(0 if matches else self._skill_popup.count() - 1)
|
|
|
|
def _show_cmd_popup(self) -> None:
|
|
rows = min(7, self._skill_popup.count())
|
|
h = 10 + rows * 22
|
|
self._skill_popup.resize(max(300, self.width()), h)
|
|
top_left = self.mapToGlobal(self.rect().topLeft())
|
|
self._skill_popup.move(top_left.x(), top_left.y() - h - 2)
|
|
self._skill_popup.show()
|
|
|
|
def _dismiss_skill_popup(self) -> None:
|
|
"""Hide the /skill picker (Esc)."""
|
|
self._skill_popup.hide()
|
|
|
|
def focusOutEvent(self, e) -> None: # noqa: N802
|
|
# The popup never grabs focus, so a click away lands here → dismiss it
|
|
# (unless the click is on the popup itself, e.g. picking an item).
|
|
if not self._skill_popup.underMouse():
|
|
self._skill_popup.hide()
|
|
super().focusOutEvent(e)
|
|
|
|
def _accept_item(self, item=None) -> None:
|
|
"""Dispatch popup selection to the right handler based on which command
|
|
(``/skill`` or ``/agent``) the popup is currently showing."""
|
|
if self._popup_kind == "agent":
|
|
self._accept_agent(item)
|
|
else:
|
|
self._accept_skill(item)
|
|
|
|
def _replace_token(self, tok, replacement: str) -> None:
|
|
pos = self.textCursor().position()
|
|
start = tok[0] if tok else pos
|
|
full = self.toPlainText()
|
|
new_text = full[:start] + replacement + full[pos:]
|
|
new_pos = start + len(replacement)
|
|
self.blockSignals(True)
|
|
self.setPlainText(new_text)
|
|
self.blockSignals(False)
|
|
cur = self.textCursor()
|
|
cur.setPosition(min(new_pos, len(new_text)))
|
|
self.setTextCursor(cur)
|
|
self._adjust_height()
|
|
self.setFocus()
|
|
|
|
def _accept_skill(self, item=None) -> None:
|
|
item = item or self._skill_popup.currentItem()
|
|
self._skill_popup.hide()
|
|
if item is None:
|
|
return
|
|
slug = item.data(Qt.UserRole)
|
|
if slug == "__manage__":
|
|
self.manage_skills.emit() # open the Skills manager
|
|
return
|
|
if not slug:
|
|
return
|
|
# Replace ONLY the /skill token the cursor is on — text typed before it
|
|
# ("dùng …") and after it is preserved, so the command can sit mid-sentence.
|
|
self._replace_token(self._skill_token(), f"/skill:{slug} ")
|
|
|
|
def _accept_agent(self, item=None) -> None:
|
|
item = item or self._skill_popup.currentItem()
|
|
self._skill_popup.hide()
|
|
if item is None:
|
|
return
|
|
slug = item.data(Qt.UserRole)
|
|
if not slug:
|
|
return
|
|
self._replace_token(self._agent_token(), f"/agent:{slug} ")
|
|
|
|
def _adjust_height(self, *_a) -> None:
|
|
# QPlainTextEdit reports the document height in LINES (not pixels), so
|
|
# convert via line spacing to get the real pixel height.
|
|
lines = self.document().size().height() or 1
|
|
line_px = self.fontMetrics().lineSpacing()
|
|
h = int(lines * line_px + 2 * self.frameWidth() + 12)
|
|
h = max(self.MIN_HEIGHT, min(self.MAX_HEIGHT, h))
|
|
if h != self.height():
|
|
self.setFixedHeight(h)
|
|
|
|
def keyPressEvent(self, e: QKeyEvent) -> None: # noqa: N802
|
|
if self._skill_popup.isVisible():
|
|
k = e.key()
|
|
if k in (Qt.Key_Down, Qt.Key_Up):
|
|
n = self._skill_popup.count()
|
|
if n:
|
|
step = 1 if k == Qt.Key_Down else -1
|
|
self._skill_popup.setCurrentRow((self._skill_popup.currentRow() + step) % n)
|
|
return
|
|
if k == Qt.Key_Tab:
|
|
self._accept_item() # Tab = autocomplete the highlighted item
|
|
return
|
|
if k == Qt.Key_Escape:
|
|
self._dismiss_skill_popup()
|
|
return
|
|
if k in (Qt.Key_Return, Qt.Key_Enter):
|
|
item = self._skill_popup.currentItem()
|
|
slug = item.data(Qt.UserRole) if item else None
|
|
is_agent = self._popup_kind == "agent"
|
|
tok = self._agent_token() if is_agent else self._skill_token()
|
|
prefix = "/agent:" if is_agent else "/skill:"
|
|
token = self.toPlainText()[tok[0]:self.textCursor().position()] if tok else ""
|
|
exact = bool(slug) and slug != "__manage__" and token == f"{prefix}{slug}"
|
|
if slug and slug != "__manage__" and not exact:
|
|
# A suggestion is highlighted but not yet fully typed —
|
|
# Enter completes it into the box first (same as Tab),
|
|
# instead of submitting a partial/mistyped slug that
|
|
# the parser would just reject as "not found".
|
|
self._accept_item(item)
|
|
return
|
|
# Slug already fully typed (or nothing usable is highlighted,
|
|
# e.g. the "no skills found" placeholder) — Enter RUNS the
|
|
# /skill command as typed: hide the popup and fall through to
|
|
# the normal submit below.
|
|
self._skill_popup.hide()
|
|
if e.key() in (Qt.Key_Return, Qt.Key_Enter) and not (e.modifiers() & Qt.ShiftModifier):
|
|
self.submit.emit()
|
|
return
|
|
super().keyPressEvent(e)
|
|
|
|
def insertFromMimeData(self, source) -> None: # noqa: N802 - paste
|
|
paths = _paths_from_mime(source)
|
|
if paths:
|
|
self.media_added.emit(paths)
|
|
return
|
|
super().insertFromMimeData(source)
|
|
|
|
def canInsertFromMimeData(self, source) -> bool: # noqa: N802
|
|
if source.hasImage() or source.hasUrls():
|
|
return True
|
|
return super().canInsertFromMimeData(source)
|
|
|
|
def dragEnterEvent(self, e) -> None: # noqa: N802
|
|
if e.mimeData().hasUrls() or e.mimeData().hasImage():
|
|
e.acceptProposedAction()
|
|
return
|
|
super().dragEnterEvent(e)
|
|
|
|
def dragMoveEvent(self, e) -> None: # noqa: N802
|
|
if e.mimeData().hasUrls() or e.mimeData().hasImage():
|
|
e.acceptProposedAction()
|
|
return
|
|
super().dragMoveEvent(e)
|
|
|
|
def dropEvent(self, e) -> None: # noqa: N802
|
|
paths = _paths_from_mime(e.mimeData())
|
|
if paths:
|
|
self.media_added.emit(paths)
|
|
e.acceptProposedAction()
|
|
return
|
|
super().dropEvent(e)
|