refactor: vá 4 hồi quy, tách 4 file chạm trần LOC, docstring lên 100%

Hồi quy đã vá
-------------
F-12  Kéo–thả hoặc dán tệp vào ô chat ném NameError. R08 tách `_Input` sang
      `chat_input_box.py` nhưng để `_paths_from_mime()` ở lại
      `composer_widget.py`, nên hai hàm sự kiện Qt gọi một cái tên không tồn
      tại. Bốn hàm dùng chung chuyển sang `composer_mime.py` — module thứ ba
      là chỗ duy nhất không lặp lại được lỗi này. Đo lại: cả thả lẫn dán đều
      gắn 1 tệp, khớp bản trước refactor.

F-01  Đổi provider thì bộ chọn model AI-Edit không làm gì. Hook cũ kiểm
      `folder.ai_model_combo`, thuộc tính R08-T12 đã dời sang
      `ai_panel.resolver`. Làm mới vô điều kiện, đúng như tab cũ: lần lấy đầu
      tiên hỏng thì đổi provider chính là lúc phải thử lại.

F-07  Hàng chọn kỳ của Dashboard bị đẩy xuống dưới các thẻ số liệu. Hàng này
      lọc CẢ BA thẻ con chứ không riêng biểu đồ, nên để nó nằm dưới là bắt
      người dùng đọc con số trước khi thấy con số đó tính cho kỳ nào. Kèm
      theo: `TokenUsageCardWidget` bị bỏ sót `setContentsMargins(0,0,0,0)`
      mà hai thẻ con còn lại đã có, đẩy cả hàng thẻ lệch 9px.
      `check_layout_geometry` nay khớp TỪNG BYTE với bản trước refactor.

F-11  Hai lớp khai trùng tên phương thức; Python giữ bản sau nên bản đầu là
      mã chết. `co4e_tab.py::showEvent` bản đầu gọi `_narrow_guard.attach()`
      và không bao giờ chạy.

Tách file (F-09)
----------------
Bốn file chạm trần 400 dòng, mỗi lần cắt ra một trách nhiệm thật:

    graph_renderer.py         -> graph_scene_builder.py + graph_export.py
    co4e_workflow_service.py  -> co4e_run_history.py
    json_config_repository.py -> config_sections.py
    agents_admin_tab.py       -> shared/agent_kind_visuals.py

File cuối còn xoá 3 bản sao của hàm đã có trong `shared/formatters.py`,
giống hệt đến từng dòng — nay định dạng thời gian và avatar không lệch nhau
giữa các bảng Giám sát nữa.

Docstring
---------
41,6% -> 100% (3.478/3.478 định nghĩa production), kể cả module dormant và
phương thức dunder. Toàn bộ phần bổ sung viết bằng tiếng Việt; comment tiếng
Anh có sẵn giữ nguyên — dịch ngược là một đợt riêng.

Seam chưa nối dây (F-05)
------------------------
9 seam mang nhãn `SEAM · dựng <ngày>` kèm hai câu: được nối khi nào, và để
dormant thì hỏng gì. Ngày lấy từ lịch sử git, không phải hạn tự đặt. Gate O
đọc nhãn đó và nhắc khi quá 30 ngày.

859 test xanh · 4/4 cổng CASAN · 19/24 checker khớp từng byte bản cũ.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 10:41:45 +09:00
co-authored by Claude Opus 5
parent d20306be08
commit e29a0ccdbd
264 changed files with 4593 additions and 359 deletions
+28
View File
@@ -15,14 +15,24 @@ _MAX_RETRIES = 6 # auto-retry on rate-limit (429) / overloaded
class AnthropicProvider(Provider):
"""Adapter cho API Messages của Anthropic.
Khác OpenAI ở ba chỗ: prompt hệ thống nằm ở tham số ``system`` riêng chứ
không phải một tin nhắn, xác thực bằng header ``x-api-key``, và khối
nội dung là danh sách block chứ không phải chuỗi.
"""
name = "anthropic"
supports_vision = True
def _url(self) -> str:
"""Endpoint ``/v1/messages``; mặc định là api.anthropic.com nếu không đặt ``base_url``."""
base = str(self.conf.get("base_url") or "https://api.anthropic.com").rstrip("/")
return f"{base}/v1/messages"
def _headers(self) -> Dict[str, str]:
"""Header cho một lượt gọi. Thiếu khoá thì báo lỗi ngay — Anthropic không
có chế độ chạy cục bộ không cần khoá như Ollama.
"""
key = self.conf.get("api_key")
if not key:
raise ProviderError("Anthropic API key is not configured.")
@@ -35,6 +45,12 @@ class AnthropicProvider(Provider):
_FALLBACK_MODELS = ["claude-opus-4-8", "claude-sonnet-4-6", "claude-haiku-4-5-20251001"]
def list_models(self):
"""Danh sách model; hỏi được API thì dùng, không thì rơi về danh sách dựng sẵn.
Không bao giờ trả về rỗng: người dùng luôn phải chọn được một model, kể
cả khi mạng nội bộ chặn ``/v1/models``. Lý do thất bại ghi vào
``last_error`` để giao diện hiện ra.
"""
self.last_error = ""
base = str(self.conf.get("base_url") or "https://api.anthropic.com").rstrip("/")
try:
@@ -58,6 +74,11 @@ class AnthropicProvider(Provider):
@staticmethod
def _split(messages: List[Dict[str, Any]]):
"""Tách lịch sử thành (prompt hệ thống, danh sách tin nhắn) theo khuôn Anthropic.
Anthropic nhận prompt hệ thống ở một tham số riêng, nên mọi tin nhắn
role ``system`` phải được gom lại và bỏ khỏi danh sách.
"""
system_parts: List[str] = []
api: List[Dict[str, Any]] = []
for m in messages:
@@ -117,6 +138,12 @@ class AnthropicProvider(Provider):
cancel: Optional[CancelFn] = None,
on_reasoning: Optional[TextCallback] = None,
) -> Dict[str, Any]:
"""Chạy một lượt chat có stream, có gọi tool, tự thử lại khi bị giới hạn tốc độ
hoặc máy chủ quá tải.
Giữ bản sao ``work`` của lịch sử để cắt bớt và gửi lại được khi tràn
context — không đụng vào danh sách của chỗ gọi.
"""
work = list(messages) # local copy we can trim on context overflow
payload: Dict[str, Any] = {
"model": self.model,
@@ -324,6 +351,7 @@ class AnthropicProvider(Provider):
@staticmethod
def _error_text(resp: requests.Response) -> str:
"""Rút câu lỗi dễ đọc từ phản hồi lỗi của Anthropic, kèm mã HTTP."""
try:
body = resp.json()
msg = body.get("error", {}).get("message") or json.dumps(body)
+42
View File
@@ -52,6 +52,9 @@ MODEL_NOT_FOUND_HINT = "\n→ Hãy chọn model khác trong ⚙ Settings rồi g
def is_model_not_found_error(err: str) -> bool:
"""``True`` khi thông báo lỗi là loại "không có model này" — chỗ gọi dựa vào
đây để gợi ý đổi model thay vì báo lỗi chung chung.
"""
return MODEL_NOT_FOUND_HINT in (err or "")
@@ -76,6 +79,7 @@ class CancelWatchdog:
treats as a cancelled stream."""
def __init__(self, resp, cancel: Optional[CancelFn], poll_secs: float = 0.15):
"""Canh cờ huỷ trong lúc một lượt gọi HTTP đang chờ."""
self._resp = resp
self._cancel = cancel
self._poll_secs = poll_secs
@@ -83,12 +87,21 @@ class CancelWatchdog:
self._thread: Optional[threading.Thread] = None
def __enter__(self) -> "CancelWatchdog":
"""Bắt đầu canh. Không có hàm huỷ thì không dựng luồng nào — đây là đường đi
thường gặp nhất, không đáng tốn một luồng.
"""
if self._cancel is not None:
self._thread = threading.Thread(target=self._watch, daemon=True)
self._thread.start()
return self
def _watch(self) -> None:
"""Luồng canh: thấy cờ huỷ là đóng thẳng response đang chờ.
Đóng socket là cách duy nhất cắt được một lượt stream đang treo — nếu chỉ
đặt cờ, ``iter_lines()`` vẫn chờ tới khi máy chủ gửi tiếp hoặc hết giờ.
Nhận cả ``Callable`` lẫn ``threading.Event`` để chỗ gọi khỏi phải đổi kiểu.
"""
while not self._done.is_set():
# Support both Callable and threading.Event
if hasattr(self._cancel, "is_set"):
@@ -104,6 +117,11 @@ class CancelWatchdog:
self._done.wait(self._poll_secs)
def __exit__(self, *exc_info) -> None:
"""Dừng canh và chờ luồng thoát, tối đa 1 giây.
Có chờ, vì luồng canh còn giữ tham chiếu tới response; bỏ mặc nó thì đóng
kết nối xong luồng vẫn đang đọc.
"""
self._done.set()
if self._thread is not None:
self._thread.join(timeout=1.0)
@@ -130,24 +148,36 @@ class ThinkStreamSplitter:
_CLOSE = "</think>"
def __init__(self, on_text=None, on_reasoning=None):
"""Tách dòng chữ model trả về thành phần suy nghĩ và phần trả lời.
Có bộ đệm riêng vì thẻ ``<think>`` có thể bị cắt làm đôi giữa hai gói dữ
liệu — xét từng gói rời rạc sẽ bỏ sót thẻ.
"""
self._on_text = on_text
self._on_reasoning = on_reasoning
self._buf = ""
self._in_think = False
def feed(self, piece: str) -> None:
"""Đưa một mẩu vừa nhận từ luồng stream vào bộ tách."""
if not piece:
return
self._buf += piece
self._drain()
def flush(self) -> None:
"""Kết thúc luồng: đẩy nốt phần còn giữ lại trong bộ đệm.
Bắt buộc gọi khi stream đóng, nếu không phần đuôi đang giữ chờ ghép thẻ
``<think>`` sẽ mất hẳn.
"""
if self._buf:
self._emit(self._buf)
self._buf = ""
# -- internals -----------------------------------------------------
def _emit(self, text: str) -> None:
"""Gửi văn bản ra đúng callback tuỳ đang ở trong hay ngoài khối ``<think>``."""
if not text:
return
cb = self._on_reasoning if self._in_think else self._on_text
@@ -163,6 +193,11 @@ class ThinkStreamSplitter:
return 0
def _drain(self) -> None:
"""Rút bộ đệm, cắt tại mỗi thẻ mở/đóng và lật trạng thái.
Không tìm thấy thẻ thì vẫn giữ lại phần đuôi có thể là nửa thẻ viết dở
(``<thi``) — phát ra sớm là chữ rác lọt vào bong bóng trả lời.
"""
while self._buf:
tag = self._CLOSE if self._in_think else self._OPEN
idx = self._buf.lower().find(tag)
@@ -191,6 +226,7 @@ class ToolSpec:
parameters: Dict[str, Any]
def to_openai(self) -> Dict[str, Any]:
"""Khai báo tool theo định dạng OpenAI function-calling."""
return {
"type": "function",
"function": {
@@ -201,6 +237,7 @@ class ToolSpec:
}
def to_anthropic(self) -> Dict[str, Any]:
"""Khai báo tool theo định dạng Anthropic — khác OpenAI ở tên khoá schema."""
return {
"name": self.name,
"description": self.description,
@@ -218,6 +255,9 @@ class Provider:
supports_vision = False
def __init__(self, conf: Dict[str, Any]):
"""``last_error`` được đặt khi ``list_models()`` hỏng, thay vì nuốt lỗi: màn Cài
đặt hiện nó ra để "không nạp được model" có một lý do cụ thể.
"""
self.conf = conf
self.model = conf.get("model", "")
# Set by list_models() on failure (network/auth/bad-response) instead of
@@ -399,6 +439,7 @@ class Provider:
@staticmethod
def _friendly_context_error(err: str) -> str:
"""Đổi lỗi tràn context thành câu tiếng Việt nói rõ phải làm gì tiếp."""
return (
"Nội dung quá dài cho model này ngay cả sau khi tự nén lịch sử/cắt bớt "
"tin nhắn. Hãy xoá bớt file đính kèm, chia nhỏ yêu cầu, hoặc đổi sang một "
@@ -406,6 +447,7 @@ class Provider:
)
def describe(self) -> str:
"""Chuỗi ``provider:model`` để ghi log và hiện lên thanh trạng thái."""
return f"{self.name}:{self.model}"
# -- TLS: auto-recover from a self-signed/internal-CA gateway ------
+39
View File
@@ -22,16 +22,28 @@ _MAX_RETRIES = 6 # auto-retry on rate-limit (429) up to this many times
class OpenAICompatProvider(Provider):
"""Adapter cho mọi endpoint nói giao thức OpenAI: gateway nội bộ, Ollama,
GitHub Copilot.
Một lớp dùng chung cho nhiều nhà cung cấp vì phần khác nhau giữa chúng
chỉ là ``base_url`` và cách gắn khoá — đều nằm trong ``conf``.
"""
name = "openai_compat"
supports_vision = True
def _url(self) -> str:
"""Endpoint ``/chat/completions``. Chưa cấu hình ``base_url`` thì báo lỗi
ngay tại đây, thay vì để lỗi nổ ra ở tận tầng HTTP.
"""
base = str(self.conf.get("base_url", "")).rstrip("/")
if not base:
raise ProviderError("base_url is not configured for the OpenAI-compatible provider.")
return f"{base}/chat/completions"
def _headers(self) -> Dict[str, str]:
"""Header cho một lượt gọi; không có khoá thì bỏ hẳn ``Authorization``
(Ollama chạy cục bộ không cần khoá).
"""
headers = {"Content-Type": "application/json"}
key = self.conf.get("api_key")
if key:
@@ -40,6 +52,11 @@ class OpenAICompatProvider(Provider):
@staticmethod
def _to_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Đổi lịch sử hội thoại nội bộ sang đúng khuôn OpenAI mong đợi.
Chỗ khác biệt thật sự là lượt gọi tool: bên trong app lưu một tin nhắn
assistant kèm ``tool_calls``, còn API đòi tham số phải là chuỗi JSON.
"""
out: List[Dict[str, Any]] = []
for m in messages:
role = m["role"]
@@ -89,6 +106,13 @@ class OpenAICompatProvider(Provider):
cancel: Optional[CancelFn] = None,
on_reasoning: Optional[TextCallback] = None,
) -> Dict[str, Any]:
"""Chạy một lượt chat có stream, có gọi tool, tự thử lại khi bị giới hạn tốc độ.
Giữ một bản sao ``work`` của lịch sử để khi tràn context còn cắt bớt và
gửi lại được — không đụng vào danh sách của chỗ gọi. Suy luận nội bộ mà
gateway nhét thẳng vào ``content`` dưới dạng ``<think>…</think>`` được
tách ra qua ``ThinkStreamSplitter`` để bong bóng trả lời sạch.
"""
work = list(messages) # local copy we can trim on context overflow
payload: Dict[str, Any] = {"model": self.model, "stream": True}
if tools:
@@ -104,6 +128,7 @@ class OpenAICompatProvider(Provider):
# (rather than a separate reasoning_content field). Route that to
# on_reasoning (→ "Thinking" indicator) and keep the answer bubble clean.
def _emit_answer(t: str) -> None:
"""Gom phần trả lời (đã tách khỏi khối suy luận) và đẩy dần ra ngoài."""
text_parts.append(t)
if on_text:
on_text(t)
@@ -302,6 +327,9 @@ class OpenAICompatProvider(Provider):
pass
def list_models(self):
"""Danh sách model của gateway; lỗi thì trả về list rỗng và ghi lý do vào
``last_error`` để giao diện hiện được thay vì im lặng.
"""
self.last_error = ""
base = str(self.conf.get("base_url", "")).rstrip("/")
if not base:
@@ -327,6 +355,12 @@ class OpenAICompatProvider(Provider):
@staticmethod
def _error_text(resp: requests.Response) -> str:
"""Rút câu lỗi dễ đọc nhất từ phản hồi lỗi của gateway.
Mỗi gateway trả một khuôn khác nhau: chuẩn OpenAI là
``{"error": {"message": ...}}``, có nơi trả phẳng với ``description`` mới
là câu dành cho người đọc còn ``message`` chỉ là "Not found".
"""
try:
body = resp.json()
# Prefer the OpenAI-style {"error": {"message": ...}} shape; some
@@ -353,6 +387,11 @@ class OpenAICompatProvider(Provider):
def _assemble_assistant(text_parts: List[str], tool_acc: Dict[int, Dict[str, Any]]) -> Dict[str, Any]:
"""Ghép các mẩu stream thành một tin nhắn assistant hoàn chỉnh.
Tham số tool về theo từng mẩu nên phải nối lại rồi mới parse; JSON hỏng
thì giữ nguyên chuỗi thô trong ``_raw`` thay vì làm vỡ cả lượt chat.
"""
tool_calls: List[Dict[str, Any]] = []
for idx in sorted(tool_acc):
slot = tool_acc[idx]