fix(chat): rút gọn tiêu đề hội thoại không cắt vào giữa từ

Thanh tiêu đề màn Cowork hiện "…tóm tắt từng tệ…" cho một câu dài 61 ký tự:
mốc cắt cứng ở 60 rơi đúng giữa chữ "tệp" và bỏ mất một chữ cái, đọc ra như lỗi
gõ chứ không như một câu bị rút gọn. Dấu ba chấm ở đó còn nói dối — nó báo còn
nhiều chữ nữa trong khi chỉ thiếu đúng một ký tự.

Thêm core.history.shorten_title: viết nốt từ đang dở thay vì cắt ngang nó, và
chỉ thêm dấu ba chấm khi thật sự có chữ bị bỏ. Từ dài bất thường (đường dẫn,
URL) thì lùi về ranh giới từ trước đó để một token dài không kéo tiêu đề dài ra
tuỳ ý.

Dùng chung cho cả hai nơi sinh tiêu đề (derive_title và ChatTurnRunnerMixin) để
tiêu đề trên thanh tiêu đề và tiêu đề lưu vào lịch sử không rút gọn theo hai
kiểu khác nhau.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-22 08:38:59 +09:00
co-authored by Claude Opus 5
parent 10b8379824
commit 35334274eb
3 changed files with 163 additions and 3 deletions
+41 -2
View File
@@ -12,6 +12,7 @@ sort by recency. History can live locally or in a OneDrive folder (resolved by
from __future__ import annotations from __future__ import annotations
import json import json
import re
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List from typing import Any, Dict, List
@@ -33,6 +34,45 @@ def new_session_id() -> str:
return datetime.now().strftime("%Y%m%d-%H%M%S-%f")[:-3] return datetime.now().strftime("%Y%m%d-%H%M%S-%f")[:-3]
#: Độ dài mong muốn của một tiêu đề hội thoại, tính bằng ký tự.
TITLE_MAX_CHARS = 60
#: Số ký tự được phép vượt ``TITLE_MAX_CHARS`` để viết nốt từ đang bị cắt dở.
#: Cỡ một từ tiếng Việt — đủ để cứu chữ cuối, không đủ để kéo dài tiêu đề.
_TITLE_SLACK = 12
_KHOANG_TRANG = re.compile(r"\s")
def shorten_title(text: str, limit: int = TITLE_MAX_CHARS) -> str:
"""Rút gọn tiêu đề mà KHÔNG cắt vào giữa một từ.
Cắt cứng ở ký tự thứ ``limit`` đọc rất khó chịu khi mốc đó rơi vào giữa từ:
"…tóm tắt từng tệp" thành "…tóm tắt từng tệ…" — trông như lỗi gõ chứ không
như một câu bị rút gọn. Nên khi mốc cắt rơi vào giữa từ thì viết nốt từ đó.
Ba lối ra, theo thứ tự ưu tiên:
* Viết nốt từ đang dở, nếu chỉ phải vượt thêm tối đa ``_TITLE_SLACK`` ký tự.
Viết nốt mà vừa hết chuỗi thì **không** thêm dấu ba chấm — không còn chữ
nào bị bỏ thì dấu ba chấm là nói dối.
* Từ dài bất thường (đường dẫn, URL) thì lùi về ranh giới từ ngay trước mốc,
để một token dài không kéo tiêu đề dài ra tuỳ ý.
* Cả tiêu đề chỉ là một từ dài thì đành cắt cứng — không còn ranh giới nào.
"""
if len(text) <= limit:
return text
if text[limit].isspace(): # mốc cắt vốn đã nằm giữa hai từ
return text[:limit].rstrip() + "…"
sau = _KHOANG_TRANG.search(text, limit)
het_tu = sau.start() if sau is not None else len(text)
if het_tu - limit <= _TITLE_SLACK:
return text if het_tu == len(text) else text[:het_tu] + "…"
truoc = [m.start() for m in _KHOANG_TRANG.finditer(text, 0, limit)]
if truoc:
return text[:truoc[-1]] + "…"
return text[:limit] + "…"
def derive_title(messages: List[Dict[str, Any]]) -> str: def derive_title(messages: List[Dict[str, Any]]) -> str:
"""Suy tiêu đề hội thoại từ tin nhắn đầu tiên của người dùng. """Suy tiêu đề hội thoại từ tin nhắn đầu tiên của người dùng.
@@ -40,8 +80,7 @@ def derive_title(messages: List[Dict[str, Any]]) -> str:
""" """
for m in messages: for m in messages:
if m.get("role") == "user" and m.get("content"): if m.get("role") == "user" and m.get("content"):
text = " ".join(m["content"].split()) return shorten_title(" ".join(m["content"].split()))
return text[:60] + ("…" if len(text) > 60 else "")
return "(empty)" return "(empty)"
+6 -1
View File
@@ -13,6 +13,7 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from PySide6.QtCore import Qt, Signal from PySide6.QtCore import Qt, Signal
from ...core.history import shorten_title
from ...core.worker import AgentWorker from ...core.worker import AgentWorker
from ...i18n import tr from ...i18n import tr
from ...state import AppContext from ...state import AppContext
@@ -88,7 +89,11 @@ class ChatTurnRunnerMixin:
prefix = f"{prefix}\n\n{agent_prefix}" if prefix else agent_prefix prefix = f"{prefix}\n\n{agent_prefix}" if prefix else agent_prefix
if not self.title: if not self.title:
base = text or (Path(attachments[0]).name if attachments else "(attachment)") base = text or (Path(attachments[0]).name if attachments else "(attachment)")
self.title = (base[:60] + "…") if len(base) > 60 else base # Rút gọn mà không cắt vào giữa từ: xem shorten_title trong
# core/history.py. Dùng chung với derive_title để tiêu đề trên
# thanh tiêu đề và tiêu đề lưu vào lịch sử không rút gọn theo
# hai kiểu khác nhau.
self.title = shorten_title(base)
self._notify_title() self._notify_title()
# Reset the Plan panel so each message starts from a clean checklist (the # Reset the Plan panel so each message starts from a clean checklist (the
+116
View File
@@ -0,0 +1,116 @@
"""Tiêu đề hội thoại không được cắt vào giữa một từ.
Triệu chứng người dùng báo: thanh tiêu đề màn Cowork hiện
Đọc các tệp trong thư mục của project này và tóm tắt từng tệ…
Câu gốc dài 61 ký tự, mốc cắt cứng ở 60 rơi đúng vào giữa chữ "tệp" và bỏ mất
đúng một chữ cái. Người đọc thấy "tệ…" chứ không thấy "tệp", nên nó đọc ra như
lỗi gõ chứ không như một câu bị rút gọn.
``shorten_title`` viết nốt từ đang dở thay vì cắt ngang nó, và chỉ thêm dấu ba
chấm khi thật sự có chữ bị bỏ đi.
"""
from __future__ import annotations
import pytest
from cowork_local.core.history import (
TITLE_MAX_CHARS, _TITLE_SLACK, derive_title, shorten_title,
)
#: Đúng câu trong ảnh người dùng gửi — 61 ký tự, vượt giới hạn đúng 1.
CAU_TRONG_ANH = "Đọc các tệp trong thư mục của project này và tóm tắt từng tệp"
def test_dung_ca_nguoi_dung_bao():
"""Bài đỏ trước khi sửa: cắt cứng cho ra "…từng tệ…"."""
assert len(CAU_TRONG_ANH) == TITLE_MAX_CHARS + 1
ket_qua = shorten_title(CAU_TRONG_ANH)
assert ket_qua.endswith("tệp"), ket_qua
assert "tệ…" not in ket_qua
# Không chữ nào bị bỏ thì không được thêm dấu ba chấm — dấu đó là nói dối.
assert ket_qua == CAU_TRONG_ANH
def test_ngan_hon_gioi_han_thi_giu_nguyen():
assert shorten_title("Tiêu đề ngắn") == "Tiêu đề ngắn"
def test_dung_bang_gioi_han_thi_giu_nguyen():
text = "x" * TITLE_MAX_CHARS
assert shorten_title(text) == text
def test_moc_cat_roi_dung_giua_hai_tu_thi_cat_ngay_do():
text = "x" * TITLE_MAX_CHARS + " còn nữa"
assert shorten_title(text) == "x" * TITLE_MAX_CHARS + "…"
def test_viet_not_tu_roi_van_con_chu_phia_sau_thi_co_ba_cham():
text = "x" * 57 + " abcdefgh ijk"
ket_qua = shorten_title(text)
assert ket_qua == "x" * 57 + " abcdefgh…"
def test_tu_dai_bat_thuong_thi_lui_ve_ranh_gioi_truoc():
"""Một đường dẫn hay URL dài không được kéo tiêu đề dài ra tuỳ ý."""
text = "x" * 57 + " " + "y" * 40 + " z"
ket_qua = shorten_title(text)
assert ket_qua == "x" * 57 + "…"
assert len(ket_qua) <= TITLE_MAX_CHARS + 1
def test_ca_tieu_de_chi_la_mot_tu_dai_thi_danh_cat_cung():
"""Không còn ranh giới từ nào để bám — cắt cứng là lối ra duy nhất."""
text = "y" * 100
assert shorten_title(text) == "y" * TITLE_MAX_CHARS + "…"
def test_khong_bao_gio_vuot_qua_gioi_han_cong_slack():
text = "x" * 55 + " " + "y" * 11 + " phần đuôi còn dài nữa"
assert len(shorten_title(text)) <= TITLE_MAX_CHARS + _TITLE_SLACK + 1
@pytest.mark.parametrize("text", [
CAU_TRONG_ANH,
"Phân tích bảng tính doanh thu quý bốn và lập báo cáo tổng hợp cho ban giám đốc",
"Tóm tắt toàn bộ tài liệu kỹ thuật trong thư mục rồi xuất ra một tệp markdown",
"a bb ccc dddd eeeee ffffff ggggggg hhhhhhhh iiiiiiiii jjjjjjjjjj kkkkkkkkkkk",
])
def test_ket_qua_luon_ket_thuc_o_ranh_gioi_tu(text):
"""Bất biến của cả hàm: phần chữ giữ lại phải là một tiền tố kết thúc đúng
chỗ một từ kết thúc trong câu gốc — không bao giờ là nửa từ."""
ket_qua = shorten_title(text)
giu_lai = ket_qua[:-1] if ket_qua.endswith("…") else ket_qua
assert text.startswith(giu_lai), "kết quả không còn là tiền tố của câu gốc"
assert len(giu_lai) == len(text) or text[len(giu_lai)].isspace(), (
f"cắt vào giữa từ: ...{giu_lai[-12:]!r} | còn lại {text[len(giu_lai):][:8]!r}"
)
def test_derive_title_dung_cung_mot_luat():
"""Tiêu đề lưu vào lịch sử và tiêu đề trên thanh tiêu đề phải khớp nhau."""
messages = [{"role": "user", "content": CAU_TRONG_ANH}]
assert derive_title(messages) == shorten_title(CAU_TRONG_ANH)
def test_derive_title_van_gom_khoang_trang_thua():
"""Hành vi cũ phải giữ: xuống dòng và khoảng trắng thừa gộp về một dấu cách."""
tin_nhan = """ Dòng một
Dòng hai """
messages = [{"role": "user", "content": tin_nhan}]
assert derive_title(messages) == "Dòng một Dòng hai"