CI / test (push) Canceled after 0s
## Summary epic r04 - begin refactor ## Change Type - [x] Cowork feature - [ ] Bug fix - [ ] Core AI contribution - [ ] Test / hardening - [ ] Performance - [ ] Documentation ## Related Work Cowork Task: Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets Core AI Issue: Core Task: Related PR: ## Scope What is intentionally included? What is intentionally NOT included? ## Validation - [ ] Unit tests - [ ] Integration tests - [ ] Manual verification - [ ] Regression check Commands / evidence: ## Security Impact Permission / credential / network / customer data impact: ## Compatibility - [ ] No breaking change - [ ] Breaking change documented ## Reviewer Notes Anything Cowork reviewers should pay attention to. --------- Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com> Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com> Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com> Co-authored-by: Vu Dam Tuan <vudt15@fpt.com> Co-authored-by: Hiep Ha Van <hiephv3@fpt.com> Co-authored-by: Lam Hoang Van <lamhv7@fpt.com> Reviewed-on: #7 Co-authored-by: Duy Le Huu <duylh19@fpt.com>
173 lines
6.5 KiB
Python
173 lines
6.5 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 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
|