Files
cowork-local/core/history.py
T
anhtnm1andClaude Opus 5 d656c5c834 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>
2026-09-07 19:55:02 +09:00

225 lines
9.0 KiB
Python

"""Conversation persistence with a per-conversation file model.
Each conversation is stored as one JSON file::
{"kind": "cowork"|"code", "session_id": str, "title": str,
"created": ISO8601, "messages": [...canonical...]}
File name: ``<kind>__<session_id>.json`` so the sidebar can group by kind and
sort by recency. History can live locally or in a OneDrive folder (resolved by
``AppConfig.history_dir``).
"""
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List
def new_session_id() -> str:
"""Id phiên mới theo mốc thời gian, chính xác tới mili giây."""
return datetime.now().strftime("%Y%m%d-%H%M%S-%f")[:-3]
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.
Dùng khi người dùng chưa tự đặt tên — cắt gọn cho vừa một dòng danh sách.
"""
for m in messages:
if m.get("role") == "user" and m.get("content"):
text = " ".join(m["content"].split())
return text[:60] + ("…" if len(text) > 60 else "")
return "(empty)"
def save_conversation(
directory: Path,
kind: str,
session_id: str,
messages: List[Dict[str, Any]],
title: str = "",
created: str = "",
inputs: List[str] | None = None,
outputs: List[str] | None = None,
project_id: str = "",
) -> Path:
"""Ghi một hội thoại xuống ``<kind>__<session_id>.json``.
Ghi nguyên tử (R06-T02). Cờ ghim và project_id của lần lưu trước được GIỮ
LẠI: hàm này bị gọi tự động sau mỗi lượt chat, ghi đè chúng sẽ âm thầm bỏ
ghim và đẩy hội thoại ra khỏi project của nó.
"""
directory.mkdir(parents=True, exist_ok=True)
path = directory / f"{kind}__{session_id}.json"
pinned = False # preserve pin flag + project across autosaves
prev_project = ""
if path.exists():
try:
prev = json.loads(path.read_text(encoding="utf-8"))
pinned = bool(prev.get("pinned", False))
prev_project = prev.get("project_id", "")
except (OSError, json.JSONDecodeError):
pinned = False
payload = {
"kind": kind,
"session_id": session_id,
"title": title or derive_title(messages),
"created": created or datetime.now().isoformat(timespec="seconds"),
"pinned": pinned,
# A conversation belongs to a project (Claude-Projects style); legacy
# files without one fall back to the default project.
"project_id": project_id or prev_project or "default",
"inputs": list(inputs or []),
"outputs": list(outputs or []),
"messages": messages,
}
# R06-T02: atomic write - see infrastructure/persistence/json/atomic_write.py.
from ..infrastructure.persistence.json.atomic_write import write_json
write_json(path, payload)
return path
def delete_conversation(path) -> None:
"""Xoá file hội thoại; không có thì bỏ qua."""
try:
Path(path).unlink()
except OSError:
pass
def rename_conversation(path, new_title: str) -> None:
"""Đổi tiêu đề một hội thoại và ghi lại (nguyên tử)."""
from ..infrastructure.persistence.json.atomic_write import write_json
data = load_conversation(path)
data["title"] = new_title
write_json(Path(path), data)
def set_pinned(path, pinned: bool) -> None:
"""Ghim/bỏ ghim một hội thoại để nó nằm trên đầu danh sách."""
from ..infrastructure.persistence.json.atomic_write import write_json
data = load_conversation(path)
data["pinned"] = bool(pinned)
write_json(Path(path), data)
def load_conversation(path: Path) -> Dict[str, Any]:
"""Đọc một hội thoại; file hỏng hoặc không đọc được thì trả về dict rỗng thay
vì ném lỗi — một file hỏng không được phép làm chết cả danh sách lịch sử.
"""
try:
data = json.loads(Path(path).read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {"kind": "", "title": "(read error)", "messages": []}
if isinstance(data, list): # tolerate legacy format
data = {"kind": "", "title": derive_title(data), "messages": data}
return data
def _matches_query(query: str, title: str, messages: List[Dict[str, Any]]) -> bool:
"""True if ``query`` (already lowercased) appears in the title or in any
message's text content — a conversation "matches" by title OR content."""
if query in (title or "").lower():
return True
for m in messages or []:
content = m.get("content")
if isinstance(content, str) and query in content.lower():
return True
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).
``query`` (from the sidebar's search box), when non-empty, keeps only
conversations whose title OR any message's content contains it
(case-insensitive) — since every file is already parsed to build the
metadata below, this search costs no extra I/O over listing alone."""
if directory is None:
from ..config import HISTORY_DIR
directory = HISTORY_DIR
if not directory or not directory.exists():
return []
q = (query or "").strip().lower()
items: List[Dict[str, Any]] = []
for path in directory.glob("*.json"):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
if isinstance(data, list):
data = {"kind": "", "title": derive_title(data), "messages": data}
title = data.get("title", path.stem)
if q and not _matches_query(q, title, data.get("messages", [])):
continue
items.append({
"path": path,
"kind": data.get("kind", ""),
"title": title,
"created": data.get("created", ""),
"session_id": data.get("session_id", path.stem),
"pinned": bool(data.get("pinned", False)),
"project_id": data.get("project_id", "") or "default",
"count": len(data.get("messages", [])),
"mtime": path.stat().st_mtime,
})
# pinned first, then most recent
items.sort(key=lambda d: (not d["pinned"], -d["mtime"]))
return items