fix(history): "Tất cả project" và số liệu mỗi project đọc đủ mọi thư mục
Lịch sử hội thoại KHÔNG nằm chung một chỗ: _bind_project đặt config._project_history_dir thành <workspace của project>/.cowork_history mỗi lần chọn project khác — cố ý, để chia sẻ thư mục project là chia sẻ cả lịch sử. Nhưng hai chỗ đọc lại chỉ đọc MỘT thư mục, gây hai triệu chứng cùng gốc: - ui/sidebar.py đọc history_dir() (thư mục của project ĐANG mở), nên khung "Tất cả project…" dựng đủ tiêu đề nhóm mà mọi nhóm trừ một đều rỗng; - _project_counts gọi list_conversations() KHÔNG tham số, tức đọc HISTORY_DIR toàn cục nơi không có hội thoại nào của project, nên mọi dòng project đều đếm "0 đoạn chat". Thêm history_dirs() + list_conversations_by_project(), giữ đúng thứ tự cũ (ghim trước, mới nhất trước) và chống trùng. Thư mục là chủ sở hữu có thẩm quyền: hội thoại nằm trong workspace của project nào thì thuộc project đó, kể cả khi trường project_id trong file đã cũ vì project bị đổi thư mục. HISTORY_SUBDIR + project_history_dir() gom đường dẫn về một định nghĩa duy nhất — chuỗi ".cowork_history" từng nằm rải trong ui/workspace_tab.py. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -132,6 +132,58 @@ def _matches_query(query: str, title: str, messages: List[Dict[str, Any]]) -> bo
|
||||
return False
|
||||
|
||||
|
||||
def history_dirs() -> list:
|
||||
"""Các cặp ``(project_id, thư mục lịch sử)`` của MỌI project, cộng thư mục
|
||||
mặc định cho hội thoại chưa thuộc project nào.
|
||||
|
||||
Có hàm này vì lịch sử KHÔNG nằm chung một chỗ, mà nằm trong thư mục làm việc
|
||||
của từng project. Ai chỉ gọi ``list_conversations()`` một lần sẽ chỉ thấy
|
||||
hội thoại của project đang mở — hoặc, nếu gọi không tham số, không thấy cái
|
||||
nào cả. Đó chính là hai lỗi đã xảy ra: khung "Tất cả project…" hiện nhóm
|
||||
rỗng cho mọi project trừ một, và mọi dòng project đều đếm "0 đoạn chat".
|
||||
"""
|
||||
from ..config import HISTORY_DIR
|
||||
from .projects import list_projects, project_history_dir
|
||||
|
||||
pairs = [("default", HISTORY_DIR)]
|
||||
for project in list_projects():
|
||||
pairs.append((project.project_id, project_history_dir(project)))
|
||||
return pairs
|
||||
|
||||
|
||||
def list_conversations_by_project(pairs, query: str = "") -> List[Dict[str, Any]]:
|
||||
"""Gộp lịch sử hội thoại của NHIỀU project. ``pairs`` là các cặp
|
||||
``(project_id, directory)``.
|
||||
|
||||
Lịch sử KHÔNG nằm chung một chỗ: ``WorkspaceTab`` đặt
|
||||
``config._project_history_dir`` thành ``<workspace của project>/.cowork_history``
|
||||
mỗi lần người dùng chọn project khác, nên ``config.history_dir()`` chỉ trả về
|
||||
thư mục của project ĐANG mở. Một lần gọi :func:`list_conversations` vì thế
|
||||
chỉ thấy được hội thoại của project đó — khung "Tất cả project…" dựng đủ
|
||||
tiêu đề nhóm cho mọi project nhưng mọi nhóm trừ một đều rỗng.
|
||||
|
||||
Thư mục là chủ sở hữu có thẩm quyền: hội thoại nằm trong thư mục làm việc của
|
||||
project nào thì thuộc project đó, kể cả khi trường ``project_id`` ghi trong
|
||||
file đã cũ (project bị đổi thư mục chẳng hạn).
|
||||
"""
|
||||
seen: set = set()
|
||||
items: List[Dict[str, Any]] = []
|
||||
for project_id, directory in pairs:
|
||||
if directory is None:
|
||||
continue
|
||||
for meta in list_conversations(directory, query=query):
|
||||
key = str(meta["path"])
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
if project_id:
|
||||
meta["project_id"] = project_id
|
||||
items.append(meta)
|
||||
# Cùng thứ tự mà list_conversations dùng: ghim lên đầu, rồi mới nhất trước.
|
||||
items.sort(key=lambda d: (not d["pinned"], -d["mtime"]))
|
||||
return items
|
||||
|
||||
|
||||
def list_conversations(directory: Optional[Path] = None, query: str = "") -> List[Dict[str, Any]]:
|
||||
"""List saved conversations, most recent first (pinned always on top).
|
||||
|
||||
|
||||
@@ -103,6 +103,16 @@ def _slugify(name: str) -> str:
|
||||
return s or "project"
|
||||
|
||||
|
||||
#: Lich su hoi thoai cua mot project nam TRONG thu muc lam viec cua no, de chia
|
||||
#: se thu muc do la chia se ca lich su (may khac xem va tiep tuc duoc).
|
||||
HISTORY_SUBDIR = ".cowork_history"
|
||||
|
||||
|
||||
def project_history_dir(project) -> Path:
|
||||
"""Thư mục lịch sử hội thoại của một project."""
|
||||
return project.workspace_dir() / HISTORY_SUBDIR
|
||||
|
||||
|
||||
def new_project(name: str, description: str = "", instructions: str = "",
|
||||
output_dir: str = "", directory: Path = None) -> Project:
|
||||
"""Create + persist a new project with a unique id derived from the name."""
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Lịch sử hội thoại nằm trong thư mục của TỪNG project, không nằm chung.
|
||||
|
||||
``WorkspaceTab._bind_project`` đặt ``config._project_history_dir`` thành
|
||||
``<workspace của project>/.cowork_history`` mỗi lần người dùng chọn project khác.
|
||||
Hệ quả: một lần gọi ``list_conversations()`` chỉ thấy hội thoại của project đang
|
||||
mở — và gọi KHÔNG tham số thì không thấy cái nào cả, vì nó đọc ``HISTORY_DIR``
|
||||
toàn cục.
|
||||
|
||||
Hai lỗi đã xảy ra vì đúng chuyện này:
|
||||
|
||||
* Khung "Tất cả project…" dựng đủ tiêu đề nhóm cho mọi project nhưng mọi nhóm
|
||||
trừ một đều rỗng.
|
||||
* Mọi dòng project đều đếm "0 đoạn chat · 0 task" dù người dùng đã chat.
|
||||
|
||||
Test không chạm ``~/.cowork_local``: ``core/projects.py`` gắn ``PROJECTS_DIR``
|
||||
vào thư mục dữ liệu THẬT, nên mọi thứ ở đây dùng ``tmp_path`` và fake.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.core.history import (
|
||||
list_conversations, list_conversations_by_project,
|
||||
)
|
||||
|
||||
|
||||
def _viet_hoi_thoai(directory: Path, session_id: str, title: str,
|
||||
project_id: str = "", mtime: float | None = None) -> Path:
|
||||
"""Ghi một file hội thoại tối thiểu mà ``list_conversations`` đọc được."""
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
path = directory / f"{session_id}.json"
|
||||
path.write_text(json.dumps({
|
||||
"kind": "cowork",
|
||||
"title": title,
|
||||
"created": "2026-09-07T10:00:00",
|
||||
"session_id": session_id,
|
||||
"project_id": project_id,
|
||||
"messages": [{"role": "user", "content": title}],
|
||||
}), encoding="utf-8")
|
||||
if mtime is not None:
|
||||
import os
|
||||
os.utime(path, (mtime, mtime))
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hai_project(tmp_path):
|
||||
"""Hai project, mỗi cái một đoạn chat — đúng tình huống người dùng báo."""
|
||||
a = tmp_path / "test" / ".cowork_history"
|
||||
b = tmp_path / "test1" / ".cowork_history"
|
||||
_viet_hoi_thoai(a, "s-a", "chat cua test", mtime=1000)
|
||||
_viet_hoi_thoai(b, "s-b", "chat cua test1", mtime=2000)
|
||||
return [("p-test", a), ("p-test1", b)]
|
||||
|
||||
|
||||
# ---- triệu chứng gốc -----------------------------------------------------
|
||||
|
||||
def test_mot_lan_goi_chi_thay_mot_project(hai_project):
|
||||
"""Chốt lại chính nguyên nhân, để nó không bị coi là chuyện đương nhiên."""
|
||||
_pid_a, dir_a = hai_project[0]
|
||||
|
||||
chi_mot = list_conversations(dir_a)
|
||||
|
||||
assert len(chi_mot) == 1
|
||||
assert chi_mot[0]["title"] == "chat cua test"
|
||||
|
||||
|
||||
def test_gop_nhieu_thu_muc_thi_thay_du(hai_project):
|
||||
tat_ca = list_conversations_by_project(hai_project)
|
||||
|
||||
assert {c["title"] for c in tat_ca} == {"chat cua test", "chat cua test1"}
|
||||
|
||||
|
||||
def test_moi_hoi_thoai_thuoc_dung_project(hai_project):
|
||||
"""Thư mục là chủ sở hữu có thẩm quyền, không phải trường project_id trong file."""
|
||||
theo_pid = {c["project_id"]: c["title"] for c in list_conversations_by_project(hai_project)}
|
||||
|
||||
assert theo_pid == {"p-test": "chat cua test", "p-test1": "chat cua test1"}
|
||||
|
||||
|
||||
def test_project_id_cu_trong_file_bi_ghi_de(tmp_path):
|
||||
"""Project bị đổi thư mục thì trường trong file đã cũ — thư mục vẫn đúng."""
|
||||
d = tmp_path / "moi" / ".cowork_history"
|
||||
_viet_hoi_thoai(d, "s1", "x", project_id="pid-cu-roi")
|
||||
|
||||
ket_qua = list_conversations_by_project([("pid-that", d)])
|
||||
|
||||
assert ket_qua[0]["project_id"] == "pid-that"
|
||||
|
||||
|
||||
# ---- thứ tự và trùng lặp -------------------------------------------------
|
||||
|
||||
def test_giu_dung_thu_tu_moi_nhat_truoc(hai_project):
|
||||
tat_ca = list_conversations_by_project(hai_project)
|
||||
|
||||
assert [c["title"] for c in tat_ca] == ["chat cua test1", "chat cua test"]
|
||||
|
||||
|
||||
def test_ghim_len_dau_bat_ke_thoi_gian(tmp_path):
|
||||
d1 = tmp_path / "a" / ".cowork_history"
|
||||
d2 = tmp_path / "b" / ".cowork_history"
|
||||
_viet_hoi_thoai(d1, "cu", "cu ma ghim", mtime=1000)
|
||||
_viet_hoi_thoai(d2, "moi", "moi ma khong ghim", mtime=9000)
|
||||
data = json.loads((d1 / "cu.json").read_text(encoding="utf-8"))
|
||||
data["pinned"] = True
|
||||
(d1 / "cu.json").write_text(json.dumps(data), encoding="utf-8")
|
||||
|
||||
tat_ca = list_conversations_by_project([("a", d1), ("b", d2)])
|
||||
|
||||
assert tat_ca[0]["title"] == "cu ma ghim"
|
||||
|
||||
|
||||
def test_cung_mot_thu_muc_hai_lan_khong_dem_doi(hai_project):
|
||||
"""``history_dirs()`` có thể trả về trùng thư mục khi cấu hình chồng nhau."""
|
||||
_pid, d = hai_project[0]
|
||||
|
||||
assert len(list_conversations_by_project([("x", d), ("y", d)])) == 1
|
||||
|
||||
|
||||
# ---- đầu vào xấu ---------------------------------------------------------
|
||||
|
||||
def test_thu_muc_chua_ton_tai_thi_bo_qua(tmp_path, hai_project):
|
||||
tat_ca = list_conversations_by_project(
|
||||
hai_project + [("rong", tmp_path / "chua-he-co")])
|
||||
|
||||
assert len(tat_ca) == 2
|
||||
|
||||
|
||||
def test_thu_muc_None_thi_bo_qua(hai_project):
|
||||
assert len(list_conversations_by_project(hai_project + [("x", None)])) == 2
|
||||
|
||||
|
||||
def test_khong_co_project_nao_thi_tra_rong():
|
||||
assert list_conversations_by_project([]) == []
|
||||
|
||||
|
||||
# ---- tìm kiếm vẫn hoạt động khi đã gộp -----------------------------------
|
||||
|
||||
def test_tim_kiem_ap_cho_moi_thu_muc(hai_project):
|
||||
assert len(list_conversations_by_project(hai_project, query="test1")) == 1
|
||||
assert len(list_conversations_by_project(hai_project, query="chat cua")) == 2
|
||||
assert list_conversations_by_project(hai_project, query="khong-he-co") == []
|
||||
|
||||
|
||||
# ---- một định nghĩa duy nhất cho đường dẫn -------------------------------
|
||||
|
||||
def test_duong_dan_lich_su_chi_dinh_nghia_mot_cho():
|
||||
"""Chuỗi ".cowork_history" từng nằm rải ở ``ui/workspace_tab.py``."""
|
||||
from cowork_local.core.projects import HISTORY_SUBDIR
|
||||
|
||||
repo = Path(__file__).resolve().parents[1]
|
||||
src = (repo / "ui" / "workspace_tab.py").read_text(encoding="utf-8")
|
||||
|
||||
assert HISTORY_SUBDIR == ".cowork_history"
|
||||
assert ".cowork_history" not in src, "phải dùng project_history_dir()"
|
||||
+10
-1
@@ -241,8 +241,17 @@ class HistorySidebar(QWidget):
|
||||
for p in projects:
|
||||
groups[p.project_id] = _make_group(p.name)
|
||||
|
||||
# Loc theo mot project -> doc dung thu muc cua no. Khong loc ("Tat ca
|
||||
# project…") -> phai doc thu muc lich su cua TUNG project roi gop lai:
|
||||
# lich su nam trong thu muc lam viec cua project, nen mot lan goi
|
||||
# list_conversations chi thay duoc project dang mo, va moi nhom con lai
|
||||
# hien ra rong tuy nguoi dung da chat trong do.
|
||||
try:
|
||||
convos = list_conversations(self.ctx.config.history_dir(), query=query)
|
||||
if self._project_filter:
|
||||
convos = list_conversations(self.ctx.config.history_dir(), query=query)
|
||||
else:
|
||||
from ..core.history import history_dirs, list_conversations_by_project
|
||||
convos = list_conversations_by_project(history_dirs(), query=query)
|
||||
except Exception:
|
||||
convos = []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user