feat(versioning): 支持仅管理对话并兼容 docker
This commit is contained in:
parent
987dc822b2
commit
d036ef5ccd
@ -27,6 +27,8 @@ class VersioningPaths:
|
|||||||
|
|
||||||
class ConversationVersioningManager:
|
class ConversationVersioningManager:
|
||||||
"""Manage hidden git snapshots bound to a conversation."""
|
"""Manage hidden git snapshots bound to a conversation."""
|
||||||
|
TRACKING_MODE_WORKSPACE_AND_CONVERSATION = "workspace_and_conversation"
|
||||||
|
TRACKING_MODE_CONVERSATION_ONLY = "conversation_only"
|
||||||
SYSTEM_AUTO_DIRS = ("compact_result", "skills", "user_upload")
|
SYSTEM_AUTO_DIRS = ("compact_result", "skills", "user_upload")
|
||||||
|
|
||||||
def __init__(self, project_path: Path | str, data_dir: Path | str, conversation_id: str):
|
def __init__(self, project_path: Path | str, data_dir: Path | str, conversation_id: str):
|
||||||
@ -43,6 +45,13 @@ class ConversationVersioningManager:
|
|||||||
snapshots_dir=(self.data_dir / "save" / self.conversation_id / "snapshots").resolve(),
|
snapshots_dir=(self.data_dir / "save" / self.conversation_id / "snapshots").resolve(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def normalize_tracking_mode(cls, mode: Optional[str]) -> str:
|
||||||
|
raw = str(mode or "").strip().lower()
|
||||||
|
if raw == cls.TRACKING_MODE_CONVERSATION_ONLY:
|
||||||
|
return cls.TRACKING_MODE_CONVERSATION_ONLY
|
||||||
|
return cls.TRACKING_MODE_WORKSPACE_AND_CONVERSATION
|
||||||
|
|
||||||
# ----------------------------
|
# ----------------------------
|
||||||
# low-level helpers
|
# low-level helpers
|
||||||
# ----------------------------
|
# ----------------------------
|
||||||
@ -151,16 +160,20 @@ class ConversationVersioningManager:
|
|||||||
return {
|
return {
|
||||||
"enabled": False,
|
"enabled": False,
|
||||||
"mode": "overwrite",
|
"mode": "overwrite",
|
||||||
|
"tracking_mode": self.TRACKING_MODE_WORKSPACE_AND_CONVERSATION,
|
||||||
"last_commit": None,
|
"last_commit": None,
|
||||||
"last_input_seq": 0,
|
"last_input_seq": 0,
|
||||||
"updated_at": None,
|
"updated_at": None,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
return json.loads(self.paths.meta_path.read_text(encoding="utf-8")) or {}
|
payload = json.loads(self.paths.meta_path.read_text(encoding="utf-8")) or {}
|
||||||
|
payload["tracking_mode"] = self.normalize_tracking_mode(payload.get("tracking_mode"))
|
||||||
|
return payload
|
||||||
except Exception:
|
except Exception:
|
||||||
return {
|
return {
|
||||||
"enabled": False,
|
"enabled": False,
|
||||||
"mode": "overwrite",
|
"mode": "overwrite",
|
||||||
|
"tracking_mode": self.TRACKING_MODE_WORKSPACE_AND_CONVERSATION,
|
||||||
"last_commit": None,
|
"last_commit": None,
|
||||||
"last_input_seq": 0,
|
"last_input_seq": 0,
|
||||||
"updated_at": None,
|
"updated_at": None,
|
||||||
@ -168,6 +181,7 @@ class ConversationVersioningManager:
|
|||||||
|
|
||||||
def save_meta(self, meta: Dict[str, Any]) -> Dict[str, Any]:
|
def save_meta(self, meta: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
payload = dict(meta or {})
|
payload = dict(meta or {})
|
||||||
|
payload["tracking_mode"] = self.normalize_tracking_mode(payload.get("tracking_mode"))
|
||||||
payload["updated_at"] = datetime.now().isoformat()
|
payload["updated_at"] = datetime.now().isoformat()
|
||||||
self.paths.meta_path.parent.mkdir(parents=True, exist_ok=True)
|
self.paths.meta_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
self.paths.meta_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
self.paths.meta_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
@ -221,6 +235,7 @@ class ConversationVersioningManager:
|
|||||||
|
|
||||||
def _normalize_checkpoint_row(self, row: Dict[str, Any]) -> Dict[str, Any]:
|
def _normalize_checkpoint_row(self, row: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
payload = dict(row or {})
|
payload = dict(row or {})
|
||||||
|
payload["tracking_mode"] = self.normalize_tracking_mode(payload.get("tracking_mode"))
|
||||||
files = payload.get("files")
|
files = payload.get("files")
|
||||||
if isinstance(files, list):
|
if isinstance(files, list):
|
||||||
normalized_files: List[Dict[str, Any]] = []
|
normalized_files: List[Dict[str, Any]] = []
|
||||||
@ -276,12 +291,22 @@ class ConversationVersioningManager:
|
|||||||
head = self._get_head_commit()
|
head = self._get_head_commit()
|
||||||
return {"ok": True, "head": head}
|
return {"ok": True, "head": head}
|
||||||
|
|
||||||
def set_enabled(self, enabled: bool, mode: Optional[str] = None) -> Dict[str, Any]:
|
def set_enabled(
|
||||||
|
self,
|
||||||
|
enabled: bool,
|
||||||
|
mode: Optional[str] = None,
|
||||||
|
*,
|
||||||
|
tracking_mode: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
meta = self.load_meta()
|
meta = self.load_meta()
|
||||||
if enabled:
|
normalized_tracking_mode = self.normalize_tracking_mode(
|
||||||
|
tracking_mode or meta.get("tracking_mode")
|
||||||
|
)
|
||||||
|
if enabled and normalized_tracking_mode == self.TRACKING_MODE_WORKSPACE_AND_CONVERSATION:
|
||||||
self.ensure_repo()
|
self.ensure_repo()
|
||||||
meta["enabled"] = bool(enabled)
|
meta["enabled"] = bool(enabled)
|
||||||
meta["mode"] = "overwrite"
|
meta["mode"] = "overwrite"
|
||||||
|
meta["tracking_mode"] = normalized_tracking_mode
|
||||||
meta["last_commit"] = self._get_head_commit()
|
meta["last_commit"] = self._get_head_commit()
|
||||||
saved = self.save_meta(meta)
|
saved = self.save_meta(meta)
|
||||||
return saved
|
return saved
|
||||||
@ -291,17 +316,20 @@ class ConversationVersioningManager:
|
|||||||
*,
|
*,
|
||||||
workspace_path: Optional[str] = None,
|
workspace_path: Optional[str] = None,
|
||||||
conversation_snapshot: Optional[Dict[str, Any]] = None,
|
conversation_snapshot: Optional[Dict[str, Any]] = None,
|
||||||
|
tracking_mode: Optional[str] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Ensure a visible seq=0 checkpoint exists after enabling versioning.
|
Ensure a visible seq=0 checkpoint exists after enabling versioning.
|
||||||
"""
|
"""
|
||||||
self.ensure_repo()
|
normalized_tracking_mode = self.normalize_tracking_mode(tracking_mode)
|
||||||
|
if normalized_tracking_mode == self.TRACKING_MODE_WORKSPACE_AND_CONVERSATION:
|
||||||
|
self.ensure_repo()
|
||||||
existing = self.get_checkpoint(0)
|
existing = self.get_checkpoint(0)
|
||||||
if existing:
|
if existing:
|
||||||
return {"created": False, "row": existing, "reason": "already_exists"}
|
return {"created": False, "row": existing, "reason": "already_exists"}
|
||||||
|
|
||||||
current_head = self._get_head_commit()
|
current_head = self._get_head_commit()
|
||||||
if not current_head:
|
if normalized_tracking_mode == self.TRACKING_MODE_WORKSPACE_AND_CONVERSATION and not current_head:
|
||||||
raise VersioningError("创建初始版本点失败:未获取到 commit")
|
raise VersioningError("创建初始版本点失败:未获取到 commit")
|
||||||
|
|
||||||
row = {
|
row = {
|
||||||
@ -319,23 +347,37 @@ class ConversationVersioningManager:
|
|||||||
"files": [],
|
"files": [],
|
||||||
"changed": False,
|
"changed": False,
|
||||||
"run_status": "initial",
|
"run_status": "initial",
|
||||||
|
"tracking_mode": normalized_tracking_mode,
|
||||||
}
|
}
|
||||||
if isinstance(conversation_snapshot, dict):
|
if isinstance(conversation_snapshot, dict):
|
||||||
row["snapshot_file"] = self._write_snapshot(0, conversation_snapshot)
|
row["snapshot_file"] = self._write_snapshot(0, conversation_snapshot)
|
||||||
self._append_input(row)
|
self._append_input(row)
|
||||||
meta = self.load_meta()
|
meta = self.load_meta()
|
||||||
|
meta["tracking_mode"] = normalized_tracking_mode
|
||||||
meta["last_commit"] = current_head
|
meta["last_commit"] = current_head
|
||||||
if int(meta.get("last_input_seq") or 0) < 0:
|
if int(meta.get("last_input_seq") or 0) < 0:
|
||||||
meta["last_input_seq"] = 0
|
meta["last_input_seq"] = 0
|
||||||
self.save_meta(meta)
|
self.save_meta(meta)
|
||||||
return {"created": True, "row": row, "reason": "created"}
|
return {"created": True, "row": row, "reason": "created"}
|
||||||
|
|
||||||
def ensure_baseline_for_first_input(self) -> Dict[str, Any]:
|
def ensure_baseline_for_first_input(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tracking_mode: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Ensure the baseline commit reflects current workspace before first manual input checkpoint.
|
Ensure the baseline commit reflects current workspace before first manual input checkpoint.
|
||||||
|
|
||||||
This is intentionally hidden from checkpoint list (no seq row created).
|
This is intentionally hidden from checkpoint list (no seq row created).
|
||||||
"""
|
"""
|
||||||
|
normalized_tracking_mode = self.normalize_tracking_mode(tracking_mode)
|
||||||
|
if normalized_tracking_mode == self.TRACKING_MODE_CONVERSATION_ONLY:
|
||||||
|
return {
|
||||||
|
"created": False,
|
||||||
|
"skipped": True,
|
||||||
|
"reason": "conversation_only_mode",
|
||||||
|
"head": self._get_head_commit(),
|
||||||
|
}
|
||||||
self.ensure_repo()
|
self.ensure_repo()
|
||||||
meta = self.load_meta()
|
meta = self.load_meta()
|
||||||
if self.get_checkpoint(0):
|
if self.get_checkpoint(0):
|
||||||
@ -439,28 +481,37 @@ class ConversationVersioningManager:
|
|||||||
workspace_path: Optional[str] = None,
|
workspace_path: Optional[str] = None,
|
||||||
conversation_snapshot: Optional[Dict[str, Any]] = None,
|
conversation_snapshot: Optional[Dict[str, Any]] = None,
|
||||||
run_status: Optional[str] = None,
|
run_status: Optional[str] = None,
|
||||||
|
tracking_mode: Optional[str] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
self.ensure_repo()
|
meta = self.load_meta()
|
||||||
previous_head = self._get_head_commit()
|
normalized_tracking_mode = self.normalize_tracking_mode(
|
||||||
self._stage_all()
|
tracking_mode or meta.get("tracking_mode")
|
||||||
_, staged_numstat, _ = self._run_git(["diff", "--cached", "--numstat"], check=False)
|
)
|
||||||
has_changes = bool((staged_numstat or "").strip())
|
previous_head: Optional[str] = self._get_head_commit()
|
||||||
if has_changes:
|
current_head: Optional[str] = previous_head
|
||||||
next_seq = int(self.load_meta().get("last_input_seq") or 0) + 1
|
|
||||||
title = (message or "").strip().splitlines()[0][:72] if message else ""
|
if normalized_tracking_mode == self.TRACKING_MODE_WORKSPACE_AND_CONVERSATION:
|
||||||
commit_msg = f"input#{next_seq} {title}".strip()
|
self.ensure_repo()
|
||||||
self._run_git(["commit", "-m", commit_msg])
|
previous_head = self._get_head_commit()
|
||||||
current_head = self._get_head_commit()
|
self._stage_all()
|
||||||
if not current_head:
|
_, staged_numstat, _ = self._run_git(["diff", "--cached", "--numstat"], check=False)
|
||||||
raise VersioningError("创建版本快照失败:未获取到 commit")
|
has_changes = bool((staged_numstat or "").strip())
|
||||||
|
if has_changes:
|
||||||
|
next_seq = int(meta.get("last_input_seq") or 0) + 1
|
||||||
|
title = (message or "").strip().splitlines()[0][:72] if message else ""
|
||||||
|
commit_msg = f"input#{next_seq} {title}".strip()
|
||||||
|
self._run_git(["commit", "-m", commit_msg])
|
||||||
|
current_head = self._get_head_commit()
|
||||||
|
if not current_head:
|
||||||
|
raise VersioningError("创建版本快照失败:未获取到 commit")
|
||||||
|
|
||||||
diff_summary = (
|
diff_summary = (
|
||||||
self._build_diff_summary(previous_head, current_head)
|
self._build_diff_summary(previous_head, current_head)
|
||||||
if current_head != previous_head
|
if normalized_tracking_mode == self.TRACKING_MODE_WORKSPACE_AND_CONVERSATION
|
||||||
|
and current_head != previous_head
|
||||||
else {"insertions": 0, "deletions": 0, "files_changed": 0, "files": []}
|
else {"insertions": 0, "deletions": 0, "files_changed": 0, "files": []}
|
||||||
)
|
)
|
||||||
|
|
||||||
meta = self.load_meta()
|
|
||||||
seq = int(meta.get("last_input_seq") or 0) + 1
|
seq = int(meta.get("last_input_seq") or 0) + 1
|
||||||
row = {
|
row = {
|
||||||
"seq": seq,
|
"seq": seq,
|
||||||
@ -475,7 +526,11 @@ class ConversationVersioningManager:
|
|||||||
"deletions": int(diff_summary.get("deletions") or 0),
|
"deletions": int(diff_summary.get("deletions") or 0),
|
||||||
"files_changed": int(diff_summary.get("files_changed") or 0),
|
"files_changed": int(diff_summary.get("files_changed") or 0),
|
||||||
"files": diff_summary.get("files") or [],
|
"files": diff_summary.get("files") or [],
|
||||||
"changed": bool(current_head != previous_head),
|
"changed": bool(
|
||||||
|
normalized_tracking_mode == self.TRACKING_MODE_WORKSPACE_AND_CONVERSATION
|
||||||
|
and current_head != previous_head
|
||||||
|
),
|
||||||
|
"tracking_mode": normalized_tracking_mode,
|
||||||
}
|
}
|
||||||
if isinstance(conversation_snapshot, dict):
|
if isinstance(conversation_snapshot, dict):
|
||||||
row["snapshot_file"] = self._write_snapshot(seq, conversation_snapshot)
|
row["snapshot_file"] = self._write_snapshot(seq, conversation_snapshot)
|
||||||
@ -484,6 +539,7 @@ class ConversationVersioningManager:
|
|||||||
self._append_input(row)
|
self._append_input(row)
|
||||||
meta["last_input_seq"] = seq
|
meta["last_input_seq"] = seq
|
||||||
meta["last_commit"] = current_head
|
meta["last_commit"] = current_head
|
||||||
|
meta["tracking_mode"] = normalized_tracking_mode
|
||||||
self.save_meta(meta)
|
self.save_meta(meta)
|
||||||
return row
|
return row
|
||||||
|
|
||||||
@ -686,7 +742,22 @@ class ConversationVersioningManager:
|
|||||||
self.save_meta(meta)
|
self.save_meta(meta)
|
||||||
return {"success": True, "commit": commit}
|
return {"success": True, "commit": commit}
|
||||||
|
|
||||||
def detect_mismatch(self, latest_commit: Optional[str], expected_workspace_path: Optional[str] = None) -> Dict[str, Any]:
|
def detect_mismatch(
|
||||||
|
self,
|
||||||
|
latest_commit: Optional[str],
|
||||||
|
expected_workspace_path: Optional[str] = None,
|
||||||
|
*,
|
||||||
|
tracking_mode: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
normalized_tracking_mode = self.normalize_tracking_mode(tracking_mode)
|
||||||
|
if normalized_tracking_mode == self.TRACKING_MODE_CONVERSATION_ONLY:
|
||||||
|
return {
|
||||||
|
"has_repo": (self.paths.git_dir / "HEAD").exists(),
|
||||||
|
"head": self._get_head_commit(),
|
||||||
|
"dirty": False,
|
||||||
|
"mismatch": False,
|
||||||
|
"workspace_matched": True,
|
||||||
|
}
|
||||||
expected = str(expected_workspace_path or "").strip()
|
expected = str(expected_workspace_path or "").strip()
|
||||||
current = str(self.project_path)
|
current = str(self.project_path)
|
||||||
workspace_matched = True if not expected else (Path(expected).expanduser().resolve() == self.project_path)
|
workspace_matched = True if not expected else (Path(expected).expanduser().resolve() == self.project_path)
|
||||||
|
|||||||
@ -159,8 +159,6 @@ def _prepare_hidden_versioning_baseline_for_first_input(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Ensure first-run baseline is committed (hidden, no checkpoint row)."""
|
"""Ensure first-run baseline is committed (hidden, no checkpoint row)."""
|
||||||
try:
|
try:
|
||||||
if not bool(getattr(web_terminal, "username", None) == "host"):
|
|
||||||
return
|
|
||||||
if _should_skip_versioning_for_message(
|
if _should_skip_versioning_for_message(
|
||||||
message=message,
|
message=message,
|
||||||
auto_user_message_event=auto_user_message_event,
|
auto_user_message_event=auto_user_message_event,
|
||||||
@ -173,14 +171,23 @@ def _prepare_hidden_versioning_baseline_for_first_input(
|
|||||||
versioning_meta = ((conv_data.get("metadata") or {}).get("versioning") or {})
|
versioning_meta = ((conv_data.get("metadata") or {}).get("versioning") or {})
|
||||||
if not bool(versioning_meta.get("enabled", False)):
|
if not bool(versioning_meta.get("enabled", False)):
|
||||||
return
|
return
|
||||||
|
tracking_mode = ConversationVersioningManager.normalize_tracking_mode(
|
||||||
|
versioning_meta.get("tracking_mode")
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
tracking_mode == ConversationVersioningManager.TRACKING_MODE_WORKSPACE_AND_CONVERSATION
|
||||||
|
and not bool(getattr(web_terminal, "username", None) == "host")
|
||||||
|
):
|
||||||
|
return
|
||||||
manager = ConversationVersioningManager(
|
manager = ConversationVersioningManager(
|
||||||
project_path=workspace.project_path,
|
project_path=workspace.project_path,
|
||||||
data_dir=workspace.data_dir,
|
data_dir=workspace.data_dir,
|
||||||
conversation_id=conversation_id,
|
conversation_id=conversation_id,
|
||||||
)
|
)
|
||||||
baseline = manager.ensure_baseline_for_first_input()
|
baseline = manager.ensure_baseline_for_first_input(tracking_mode=tracking_mode)
|
||||||
debug_log(
|
debug_log(
|
||||||
f"[Versioning][Baseline] conv={conversation_id} "
|
f"[Versioning][Baseline] conv={conversation_id} "
|
||||||
|
f"tracking_mode={tracking_mode} "
|
||||||
f"created={baseline.get('created')} skipped={baseline.get('skipped')} "
|
f"created={baseline.get('created')} skipped={baseline.get('skipped')} "
|
||||||
f"reason={baseline.get('reason')} head={baseline.get('head')}"
|
f"reason={baseline.get('reason')} head={baseline.get('head')}"
|
||||||
)
|
)
|
||||||
@ -200,10 +207,8 @@ def _record_hidden_versioning_checkpoint_after_run(
|
|||||||
auto_user_message_event: bool,
|
auto_user_message_event: bool,
|
||||||
run_status: str = "completed",
|
run_status: str = "completed",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Host-only hidden snapshot after current manual user input run finished."""
|
"""Hidden snapshot after current manual user input run finished."""
|
||||||
try:
|
try:
|
||||||
if not bool(getattr(web_terminal, "username", None) == "host"):
|
|
||||||
return
|
|
||||||
if _should_skip_versioning_for_message(
|
if _should_skip_versioning_for_message(
|
||||||
message=message,
|
message=message,
|
||||||
auto_user_message_event=auto_user_message_event,
|
auto_user_message_event=auto_user_message_event,
|
||||||
@ -216,6 +221,14 @@ def _record_hidden_versioning_checkpoint_after_run(
|
|||||||
versioning_meta = ((conv_data.get("metadata") or {}).get("versioning") or {})
|
versioning_meta = ((conv_data.get("metadata") or {}).get("versioning") or {})
|
||||||
if not bool(versioning_meta.get("enabled", False)):
|
if not bool(versioning_meta.get("enabled", False)):
|
||||||
return
|
return
|
||||||
|
tracking_mode = ConversationVersioningManager.normalize_tracking_mode(
|
||||||
|
versioning_meta.get("tracking_mode")
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
tracking_mode == ConversationVersioningManager.TRACKING_MODE_WORKSPACE_AND_CONVERSATION
|
||||||
|
and not bool(getattr(web_terminal, "username", None) == "host")
|
||||||
|
):
|
||||||
|
return
|
||||||
snapshot_messages = conv_data.get("messages") or []
|
snapshot_messages = conv_data.get("messages") or []
|
||||||
snapshot_payload = {
|
snapshot_payload = {
|
||||||
"conversation_id": conversation_id,
|
"conversation_id": conversation_id,
|
||||||
@ -237,9 +250,11 @@ def _record_hidden_versioning_checkpoint_after_run(
|
|||||||
workspace_path=str(workspace.project_path),
|
workspace_path=str(workspace.project_path),
|
||||||
conversation_snapshot=snapshot_payload,
|
conversation_snapshot=snapshot_payload,
|
||||||
run_status=str(run_status or "completed"),
|
run_status=str(run_status or "completed"),
|
||||||
|
tracking_mode=tracking_mode,
|
||||||
)
|
)
|
||||||
debug_log(
|
debug_log(
|
||||||
f"[Versioning][Checkpoint] conv={conversation_id} seq={row.get('seq')} "
|
f"[Versioning][Checkpoint] conv={conversation_id} seq={row.get('seq')} "
|
||||||
|
f"tracking_mode={tracking_mode} "
|
||||||
f"msg_index={message_index} snapshot_messages={len(snapshot_messages)} "
|
f"msg_index={message_index} snapshot_messages={len(snapshot_messages)} "
|
||||||
f"commit={row.get('commit')} changed={row.get('changed')} status={row.get('run_status')}"
|
f"commit={row.get('commit')} changed={row.get('changed')} status={row.get('run_status')}"
|
||||||
)
|
)
|
||||||
@ -249,6 +264,7 @@ def _record_hidden_versioning_checkpoint_after_run(
|
|||||||
"versioning": {
|
"versioning": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"mode": "overwrite",
|
"mode": "overwrite",
|
||||||
|
"tracking_mode": tracking_mode,
|
||||||
"last_commit": row.get("commit"),
|
"last_commit": row.get("commit"),
|
||||||
"last_input_seq": int(row.get("seq") or 0),
|
"last_input_seq": int(row.get("seq") or 0),
|
||||||
"updated_at": datetime.now().isoformat(),
|
"updated_at": datetime.now().isoformat(),
|
||||||
|
|||||||
@ -160,6 +160,17 @@ def _normalize_conv_id(conversation_id: str) -> str:
|
|||||||
return conv if conv.startswith("conv_") else f"conv_{conv}"
|
return conv if conv.startswith("conv_") else f"conv_{conv}"
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_versioning_tracking_mode(value: Optional[str]) -> str:
|
||||||
|
return ConversationVersioningManager.normalize_tracking_mode(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _can_use_versioning_scope(username: str, tracking_mode: str) -> bool:
|
||||||
|
normalized_tracking_mode = _normalize_versioning_tracking_mode(tracking_mode)
|
||||||
|
return _is_host_mode_request(username) or (
|
||||||
|
normalized_tracking_mode == ConversationVersioningManager.TRACKING_MODE_CONVERSATION_ONLY
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _is_host_mode_request(username: str) -> bool:
|
def _is_host_mode_request(username: str) -> bool:
|
||||||
return bool(session.get("host_mode")) or (username == "host")
|
return bool(session.get("host_mode")) or (username == "host")
|
||||||
|
|
||||||
@ -173,6 +184,46 @@ def _get_conv_versioning_manager(workspace: UserWorkspace, conversation_id: str)
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_scope_component(value: Any, default: str = "default") -> str:
|
||||||
|
raw = str(value or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return default
|
||||||
|
sanitized = re.sub(r"[^0-9A-Za-z._-]+", "_", raw).strip("._-")
|
||||||
|
if not sanitized:
|
||||||
|
sanitized = default
|
||||||
|
return sanitized[:120]
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_input_draft_path(workspace: UserWorkspace, username: str) -> Tuple[Path, str]:
|
||||||
|
base_dir = Path(workspace.data_dir).expanduser().resolve() / "composer_drafts"
|
||||||
|
if _is_host_mode_request(username):
|
||||||
|
workspace_id = session.get("host_workspace_id") or session.get("workspace_id") or "default"
|
||||||
|
scope = f"host_workspace:{workspace_id}"
|
||||||
|
filename = f"{_sanitize_scope_component(workspace_id)}.json"
|
||||||
|
return (base_dir / "host" / filename).resolve(), scope
|
||||||
|
safe_user = _sanitize_scope_component(username or "user", default="user")
|
||||||
|
scope = f"user:{safe_user}"
|
||||||
|
# docker 模式下 data_dir 本身已按用户隔离,这里固定单文件即可。
|
||||||
|
return (base_dir / "docker" / "draft.json").resolve(), scope
|
||||||
|
|
||||||
|
|
||||||
|
def _read_input_draft_payload(path: Path) -> Dict[str, Any]:
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
raw = json.loads(path.read_text(encoding="utf-8")) or {}
|
||||||
|
return raw if isinstance(raw, dict) else {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _atomic_write_input_draft(path: Path, payload: Dict[str, Any]) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
tmp.replace(path)
|
||||||
|
|
||||||
|
|
||||||
def _get_conversation_versioning_meta(terminal: WebTerminal, conversation_id: str) -> Dict[str, Any]:
|
def _get_conversation_versioning_meta(terminal: WebTerminal, conversation_id: str) -> Dict[str, Any]:
|
||||||
normalized = _normalize_conv_id(conversation_id)
|
normalized = _normalize_conv_id(conversation_id)
|
||||||
data = terminal.context_manager.conversation_manager.load_conversation(normalized) or {}
|
data = terminal.context_manager.conversation_manager.load_conversation(normalized) or {}
|
||||||
@ -181,8 +232,15 @@ def _get_conversation_versioning_meta(terminal: WebTerminal, conversation_id: st
|
|||||||
if not isinstance(versioning, dict):
|
if not isinstance(versioning, dict):
|
||||||
versioning = {}
|
versioning = {}
|
||||||
enabled = bool(versioning.get("enabled", False))
|
enabled = bool(versioning.get("enabled", False))
|
||||||
|
tracking_mode = _normalize_versioning_tracking_mode(versioning.get("tracking_mode"))
|
||||||
mode = "overwrite"
|
mode = "overwrite"
|
||||||
return {"enabled": enabled, "mode": mode, "conversation_id": normalized, "metadata": meta}
|
return {
|
||||||
|
"enabled": enabled,
|
||||||
|
"mode": mode,
|
||||||
|
"tracking_mode": tracking_mode,
|
||||||
|
"conversation_id": normalized,
|
||||||
|
"metadata": meta,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _update_conversation_versioning_meta(
|
def _update_conversation_versioning_meta(
|
||||||
@ -191,14 +249,17 @@ def _update_conversation_versioning_meta(
|
|||||||
*,
|
*,
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
mode: str,
|
mode: str,
|
||||||
|
tracking_mode: Optional[str] = None,
|
||||||
last_commit: Optional[str] = None,
|
last_commit: Optional[str] = None,
|
||||||
last_input_seq: Optional[int] = None,
|
last_input_seq: Optional[int] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
normalized = _normalize_conv_id(conversation_id)
|
normalized = _normalize_conv_id(conversation_id)
|
||||||
|
normalized_tracking_mode = _normalize_versioning_tracking_mode(tracking_mode)
|
||||||
payload: Dict[str, Any] = {
|
payload: Dict[str, Any] = {
|
||||||
"versioning": {
|
"versioning": {
|
||||||
"enabled": bool(enabled),
|
"enabled": bool(enabled),
|
||||||
"mode": "overwrite",
|
"mode": "overwrite",
|
||||||
|
"tracking_mode": normalized_tracking_mode,
|
||||||
"updated_at": datetime.now().isoformat(),
|
"updated_at": datetime.now().isoformat(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -209,6 +270,79 @@ def _update_conversation_versioning_meta(
|
|||||||
return terminal.context_manager.conversation_manager.update_conversation_metadata(normalized, payload)
|
return terminal.context_manager.conversation_manager.update_conversation_metadata(normalized, payload)
|
||||||
|
|
||||||
|
|
||||||
|
@conversation_bp.route('/api/input-draft', methods=['GET'])
|
||||||
|
@api_login_required
|
||||||
|
@with_terminal
|
||||||
|
def get_input_draft(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||||
|
del terminal
|
||||||
|
try:
|
||||||
|
path, scope = _resolve_input_draft_path(workspace, username)
|
||||||
|
payload = _read_input_draft_payload(path)
|
||||||
|
content = payload.get("content")
|
||||||
|
updated_at = payload.get("updated_at")
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"data": {
|
||||||
|
"content": content if isinstance(content, str) else "",
|
||||||
|
"updated_at": str(updated_at or ""),
|
||||||
|
"scope": scope,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
return jsonify({"success": False, "error": f"读取输入草稿失败: {exc}"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@conversation_bp.route('/api/input-draft', methods=['POST', 'PUT'])
|
||||||
|
@api_login_required
|
||||||
|
@with_terminal
|
||||||
|
def upsert_input_draft(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||||
|
del terminal
|
||||||
|
try:
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
content = body.get("content") if isinstance(body, dict) else ""
|
||||||
|
if content is None:
|
||||||
|
content = ""
|
||||||
|
if not isinstance(content, str):
|
||||||
|
content = str(content)
|
||||||
|
if len(content) > 40000:
|
||||||
|
return jsonify({"success": False, "error": "输入草稿过长(最大 40000 字符)"}), 400
|
||||||
|
|
||||||
|
path, scope = _resolve_input_draft_path(workspace, username)
|
||||||
|
if content == "":
|
||||||
|
if path.exists():
|
||||||
|
try:
|
||||||
|
path.unlink()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"data": {
|
||||||
|
"saved": True,
|
||||||
|
"cleared": True,
|
||||||
|
"scope": scope,
|
||||||
|
"updated_at": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"content": content,
|
||||||
|
"updated_at": datetime.now().isoformat(),
|
||||||
|
"scope": scope,
|
||||||
|
}
|
||||||
|
_atomic_write_input_draft(path, payload)
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"data": {
|
||||||
|
"saved": True,
|
||||||
|
"cleared": False,
|
||||||
|
"scope": scope,
|
||||||
|
"updated_at": payload["updated_at"],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
return jsonify({"success": False, "error": f"保存输入草稿失败: {exc}"}), 500
|
||||||
|
|
||||||
|
|
||||||
# === 背景生成对话标题(从 app_legacy 拆分) ===
|
# === 背景生成对话标题(从 app_legacy 拆分) ===
|
||||||
@conversation_bp.route('/api/conversations', methods=['GET'])
|
@conversation_bp.route('/api/conversations', methods=['GET'])
|
||||||
@api_login_required
|
@api_login_required
|
||||||
@ -365,37 +499,46 @@ def load_conversation(conversation_id, terminal: WebTerminal, workspace: UserWor
|
|||||||
session['thinking_mode'] = terminal.thinking_mode
|
session['thinking_mode'] = terminal.thinking_mode
|
||||||
session['model_key'] = getattr(terminal, "model_key", None)
|
session['model_key'] = getattr(terminal, "model_key", None)
|
||||||
normalized_id = _normalize_conv_id(conversation_id)
|
normalized_id = _normalize_conv_id(conversation_id)
|
||||||
if _is_host_mode_request(username):
|
try:
|
||||||
try:
|
vm = _get_conv_versioning_manager(workspace, normalized_id)
|
||||||
vm = _get_conv_versioning_manager(workspace, normalized_id)
|
vmeta = _get_conversation_versioning_meta(terminal, normalized_id)
|
||||||
vmeta = _get_conversation_versioning_meta(terminal, normalized_id)
|
tracking_mode = _normalize_versioning_tracking_mode(vmeta.get("tracking_mode"))
|
||||||
latest_checkpoint = vm.get_latest_checkpoint() if vmeta.get("enabled") else None
|
enabled = bool(vmeta.get("enabled")) and _can_use_versioning_scope(username, tracking_mode)
|
||||||
latest_commit = (latest_checkpoint or {}).get("commit") if latest_checkpoint else None
|
latest_checkpoint = vm.get_latest_checkpoint() if enabled else None
|
||||||
expected_workspace_path = (latest_checkpoint or {}).get("workspace_path") if latest_checkpoint else None
|
latest_commit = (latest_checkpoint or {}).get("commit") if latest_checkpoint else None
|
||||||
mismatch = vm.detect_mismatch(latest_commit, expected_workspace_path=expected_workspace_path) if vmeta.get("enabled") else {
|
expected_workspace_path = (latest_checkpoint or {}).get("workspace_path") if latest_checkpoint else None
|
||||||
"has_repo": False,
|
mismatch = vm.detect_mismatch(
|
||||||
"head": None,
|
latest_commit,
|
||||||
"dirty": False,
|
expected_workspace_path=expected_workspace_path,
|
||||||
"mismatch": False,
|
tracking_mode=tracking_mode,
|
||||||
"workspace_matched": True,
|
) if enabled else {
|
||||||
}
|
"has_repo": False,
|
||||||
result["versioning"] = {
|
"head": None,
|
||||||
"enabled": bool(vmeta.get("enabled")),
|
"dirty": False,
|
||||||
"mode": "overwrite",
|
"mismatch": False,
|
||||||
"latest_seq": int((latest_checkpoint or {}).get("seq") or 0) if latest_checkpoint else None,
|
"workspace_matched": True,
|
||||||
"latest_commit": latest_commit,
|
}
|
||||||
"mismatch": bool(mismatch.get("mismatch")),
|
result["versioning"] = {
|
||||||
"dirty": bool(mismatch.get("dirty")),
|
"host_mode": _is_host_mode_request(username),
|
||||||
"workspace_matched": bool(mismatch.get("workspace_matched", True)),
|
"enabled": enabled,
|
||||||
}
|
"mode": "overwrite",
|
||||||
except Exception as exc:
|
"tracking_mode": tracking_mode,
|
||||||
debug_log(f"[Versioning] load status 读取失败: {exc}")
|
"latest_seq": int((latest_checkpoint or {}).get("seq") or 0) if latest_checkpoint else None,
|
||||||
result["versioning"] = {
|
"latest_commit": latest_commit,
|
||||||
"enabled": False,
|
"mismatch": bool(mismatch.get("mismatch")),
|
||||||
"mode": "overwrite",
|
"dirty": bool(mismatch.get("dirty")),
|
||||||
"mismatch": False,
|
"workspace_matched": bool(mismatch.get("workspace_matched", True)),
|
||||||
"error": str(exc),
|
}
|
||||||
}
|
except Exception as exc:
|
||||||
|
debug_log(f"[Versioning] load status 读取失败: {exc}")
|
||||||
|
result["versioning"] = {
|
||||||
|
"host_mode": _is_host_mode_request(username),
|
||||||
|
"enabled": False,
|
||||||
|
"mode": "overwrite",
|
||||||
|
"tracking_mode": ConversationVersioningManager.TRACKING_MODE_WORKSPACE_AND_CONVERSATION,
|
||||||
|
"mismatch": False,
|
||||||
|
"error": str(exc),
|
||||||
|
}
|
||||||
|
|
||||||
# 广播对话切换事件
|
# 广播对话切换事件
|
||||||
socketio.emit('conversation_changed', {
|
socketio.emit('conversation_changed', {
|
||||||
@ -593,12 +736,15 @@ def get_conversation_versioning_status(conversation_id, terminal: WebTerminal, w
|
|||||||
normalized_id = _normalize_conv_id(conversation_id)
|
normalized_id = _normalize_conv_id(conversation_id)
|
||||||
host_mode = _is_host_mode_request(username)
|
host_mode = _is_host_mode_request(username)
|
||||||
versioning_meta = _get_conversation_versioning_meta(terminal, normalized_id)
|
versioning_meta = _get_conversation_versioning_meta(terminal, normalized_id)
|
||||||
|
tracking_mode = _normalize_versioning_tracking_mode(versioning_meta.get("tracking_mode"))
|
||||||
|
enabled = bool(versioning_meta.get("enabled")) and _can_use_versioning_scope(username, tracking_mode)
|
||||||
manager = _get_conv_versioning_manager(workspace, normalized_id)
|
manager = _get_conv_versioning_manager(workspace, normalized_id)
|
||||||
latest = manager.get_latest_checkpoint() if versioning_meta.get("enabled") and host_mode else None
|
latest = manager.get_latest_checkpoint() if enabled else None
|
||||||
mismatch = manager.detect_mismatch(
|
mismatch = manager.detect_mismatch(
|
||||||
(latest or {}).get("commit"),
|
(latest or {}).get("commit"),
|
||||||
expected_workspace_path=(latest or {}).get("workspace_path"),
|
expected_workspace_path=(latest or {}).get("workspace_path"),
|
||||||
) if versioning_meta.get("enabled") and host_mode else {
|
tracking_mode=tracking_mode,
|
||||||
|
) if enabled else {
|
||||||
"has_repo": False,
|
"has_repo": False,
|
||||||
"head": None,
|
"head": None,
|
||||||
"dirty": False,
|
"dirty": False,
|
||||||
@ -610,8 +756,11 @@ def get_conversation_versioning_status(conversation_id, terminal: WebTerminal, w
|
|||||||
"data": {
|
"data": {
|
||||||
"conversation_id": normalized_id,
|
"conversation_id": normalized_id,
|
||||||
"host_mode": host_mode,
|
"host_mode": host_mode,
|
||||||
"enabled": bool(versioning_meta.get("enabled")) if host_mode else False,
|
"supports_workspace_tracking": host_mode,
|
||||||
|
"supports_conversation_only": True,
|
||||||
|
"enabled": enabled,
|
||||||
"mode": "overwrite",
|
"mode": "overwrite",
|
||||||
|
"tracking_mode": tracking_mode,
|
||||||
"latest_seq": int((latest or {}).get("seq") or 0) if latest else None,
|
"latest_seq": int((latest or {}).get("seq") or 0) if latest else None,
|
||||||
"latest_commit": (latest or {}).get("commit"),
|
"latest_commit": (latest or {}).get("commit"),
|
||||||
"mismatch": bool(mismatch.get("mismatch")),
|
"mismatch": bool(mismatch.get("mismatch")),
|
||||||
@ -629,14 +778,19 @@ def get_conversation_versioning_status(conversation_id, terminal: WebTerminal, w
|
|||||||
def update_conversation_versioning(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
def update_conversation_versioning(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||||
try:
|
try:
|
||||||
normalized_id = _normalize_conv_id(conversation_id)
|
normalized_id = _normalize_conv_id(conversation_id)
|
||||||
if not _is_host_mode_request(username):
|
host_mode = _is_host_mode_request(username)
|
||||||
return jsonify({"success": False, "error": "版本管理仅支持宿主机模式"}), 400
|
current_meta = _get_conversation_versioning_meta(terminal, normalized_id)
|
||||||
payload = request.get_json(silent=True) or {}
|
payload = request.get_json(silent=True) or {}
|
||||||
enabled = bool(payload.get("enabled"))
|
enabled = bool(payload.get("enabled"))
|
||||||
mode = "overwrite"
|
mode = "overwrite"
|
||||||
|
tracking_mode = _normalize_versioning_tracking_mode(
|
||||||
|
payload.get("tracking_mode") if "tracking_mode" in payload else current_meta.get("tracking_mode")
|
||||||
|
)
|
||||||
|
if enabled and tracking_mode == ConversationVersioningManager.TRACKING_MODE_WORKSPACE_AND_CONVERSATION and not host_mode:
|
||||||
|
tracking_mode = ConversationVersioningManager.TRACKING_MODE_CONVERSATION_ONLY
|
||||||
|
|
||||||
manager = _get_conv_versioning_manager(workspace, normalized_id)
|
manager = _get_conv_versioning_manager(workspace, normalized_id)
|
||||||
meta = manager.set_enabled(enabled=enabled, mode=mode)
|
meta = manager.set_enabled(enabled=enabled, mode=mode, tracking_mode=tracking_mode)
|
||||||
if enabled:
|
if enabled:
|
||||||
conv_data = terminal.context_manager.conversation_manager.load_conversation(normalized_id) or {}
|
conv_data = terminal.context_manager.conversation_manager.load_conversation(normalized_id) or {}
|
||||||
snapshot_payload = {
|
snapshot_payload = {
|
||||||
@ -650,10 +804,12 @@ def update_conversation_versioning(conversation_id, terminal: WebTerminal, works
|
|||||||
init_result = manager.ensure_initial_checkpoint(
|
init_result = manager.ensure_initial_checkpoint(
|
||||||
workspace_path=str(workspace.project_path),
|
workspace_path=str(workspace.project_path),
|
||||||
conversation_snapshot=snapshot_payload,
|
conversation_snapshot=snapshot_payload,
|
||||||
|
tracking_mode=tracking_mode,
|
||||||
)
|
)
|
||||||
init_row = init_result.get("row") or {}
|
init_row = init_result.get("row") or {}
|
||||||
debug_log(
|
debug_log(
|
||||||
f"[Versioning][Init] conv={normalized_id} enabled={enabled} "
|
f"[Versioning][Init] conv={normalized_id} enabled={enabled} "
|
||||||
|
f"tracking_mode={tracking_mode} "
|
||||||
f"created={init_result.get('created')} reason={init_result.get('reason')} "
|
f"created={init_result.get('created')} reason={init_result.get('reason')} "
|
||||||
f"seq={init_row.get('seq')} commit={init_row.get('commit')}"
|
f"seq={init_row.get('seq')} commit={init_row.get('commit')}"
|
||||||
)
|
)
|
||||||
@ -664,6 +820,7 @@ def update_conversation_versioning(conversation_id, terminal: WebTerminal, works
|
|||||||
normalized_id,
|
normalized_id,
|
||||||
enabled=enabled,
|
enabled=enabled,
|
||||||
mode=mode,
|
mode=mode,
|
||||||
|
tracking_mode=tracking_mode,
|
||||||
last_commit=meta.get("last_commit"),
|
last_commit=meta.get("last_commit"),
|
||||||
last_input_seq=int(meta.get("last_input_seq") or 0),
|
last_input_seq=int(meta.get("last_input_seq") or 0),
|
||||||
)
|
)
|
||||||
@ -675,6 +832,7 @@ def update_conversation_versioning(conversation_id, terminal: WebTerminal, works
|
|||||||
"conversation_id": normalized_id,
|
"conversation_id": normalized_id,
|
||||||
"enabled": bool(meta.get("enabled")),
|
"enabled": bool(meta.get("enabled")),
|
||||||
"mode": "overwrite",
|
"mode": "overwrite",
|
||||||
|
"tracking_mode": tracking_mode,
|
||||||
"last_commit": meta.get("last_commit"),
|
"last_commit": meta.get("last_commit"),
|
||||||
"last_input_seq": int(meta.get("last_input_seq") or 0),
|
"last_input_seq": int(meta.get("last_input_seq") or 0),
|
||||||
}
|
}
|
||||||
@ -691,11 +849,16 @@ def update_conversation_versioning(conversation_id, terminal: WebTerminal, works
|
|||||||
def list_conversation_versioning_checkpoints(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
def list_conversation_versioning_checkpoints(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||||
try:
|
try:
|
||||||
normalized_id = _normalize_conv_id(conversation_id)
|
normalized_id = _normalize_conv_id(conversation_id)
|
||||||
if not _is_host_mode_request(username):
|
host_mode = _is_host_mode_request(username)
|
||||||
return jsonify({"success": False, "error": "版本管理仅支持宿主机模式"}), 400
|
|
||||||
versioning_meta = _get_conversation_versioning_meta(terminal, normalized_id)
|
versioning_meta = _get_conversation_versioning_meta(terminal, normalized_id)
|
||||||
|
tracking_mode = _normalize_versioning_tracking_mode(versioning_meta.get("tracking_mode"))
|
||||||
|
if tracking_mode == ConversationVersioningManager.TRACKING_MODE_WORKSPACE_AND_CONVERSATION and not host_mode:
|
||||||
|
return jsonify({"success": False, "error": "当前模式不支持工作区版本点,请切换到宿主机模式"}), 400
|
||||||
if not versioning_meta.get("enabled"):
|
if not versioning_meta.get("enabled"):
|
||||||
return jsonify({"success": True, "data": {"enabled": False, "items": []}})
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"data": {"enabled": False, "mode": "overwrite", "tracking_mode": tracking_mode, "items": []}
|
||||||
|
})
|
||||||
manager = _get_conv_versioning_manager(workspace, normalized_id)
|
manager = _get_conv_versioning_manager(workspace, normalized_id)
|
||||||
rows = manager.list_checkpoints()
|
rows = manager.list_checkpoints()
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@ -703,6 +866,7 @@ def list_conversation_versioning_checkpoints(conversation_id, terminal: WebTermi
|
|||||||
"data": {
|
"data": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"mode": "overwrite",
|
"mode": "overwrite",
|
||||||
|
"tracking_mode": tracking_mode,
|
||||||
"items": rows,
|
"items": rows,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -716,8 +880,11 @@ def list_conversation_versioning_checkpoints(conversation_id, terminal: WebTermi
|
|||||||
def get_conversation_versioning_checkpoint_detail(conversation_id, seq: int, terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
def get_conversation_versioning_checkpoint_detail(conversation_id, seq: int, terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||||
try:
|
try:
|
||||||
normalized_id = _normalize_conv_id(conversation_id)
|
normalized_id = _normalize_conv_id(conversation_id)
|
||||||
if not _is_host_mode_request(username):
|
host_mode = _is_host_mode_request(username)
|
||||||
return jsonify({"success": False, "error": "版本管理仅支持宿主机模式"}), 400
|
vmeta = _get_conversation_versioning_meta(terminal, normalized_id)
|
||||||
|
tracking_mode = _normalize_versioning_tracking_mode(vmeta.get("tracking_mode"))
|
||||||
|
if tracking_mode == ConversationVersioningManager.TRACKING_MODE_WORKSPACE_AND_CONVERSATION and not host_mode:
|
||||||
|
return jsonify({"success": False, "error": "当前模式不支持工作区版本详情,请切换到宿主机模式"}), 400
|
||||||
manager = _get_conv_versioning_manager(workspace, normalized_id)
|
manager = _get_conv_versioning_manager(workspace, normalized_id)
|
||||||
row = manager.get_checkpoint_detail(seq, include_patch=True)
|
row = manager.get_checkpoint_detail(seq, include_patch=True)
|
||||||
if not row:
|
if not row:
|
||||||
@ -733,8 +900,7 @@ def get_conversation_versioning_checkpoint_detail(conversation_id, seq: int, ter
|
|||||||
def restore_conversation_versioning_checkpoint(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
def restore_conversation_versioning_checkpoint(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||||
try:
|
try:
|
||||||
normalized_id = _normalize_conv_id(conversation_id)
|
normalized_id = _normalize_conv_id(conversation_id)
|
||||||
if not _is_host_mode_request(username):
|
host_mode = _is_host_mode_request(username)
|
||||||
return jsonify({"success": False, "error": "版本管理仅支持宿主机模式"}), 400
|
|
||||||
payload = request.get_json(silent=True) or {}
|
payload = request.get_json(silent=True) or {}
|
||||||
seq = int(payload.get("seq") or 0)
|
seq = int(payload.get("seq") or 0)
|
||||||
if seq < 0:
|
if seq < 0:
|
||||||
@ -750,16 +916,28 @@ def restore_conversation_versioning_checkpoint(conversation_id, terminal: WebTer
|
|||||||
checkpoint = manager.get_checkpoint(seq)
|
checkpoint = manager.get_checkpoint(seq)
|
||||||
if not checkpoint:
|
if not checkpoint:
|
||||||
return jsonify({"success": False, "error": "未找到对应版本点"}), 404
|
return jsonify({"success": False, "error": "未找到对应版本点"}), 404
|
||||||
|
tracking_mode = _normalize_versioning_tracking_mode(
|
||||||
|
checkpoint.get("tracking_mode") or vmeta.get("tracking_mode")
|
||||||
|
)
|
||||||
|
if tracking_mode == ConversationVersioningManager.TRACKING_MODE_WORKSPACE_AND_CONVERSATION and not host_mode:
|
||||||
|
return jsonify({"success": False, "error": "当前模式不支持工作区回溯,请切换到宿主机模式"}), 400
|
||||||
|
|
||||||
debug_log(
|
debug_log(
|
||||||
f"[Versioning][Restore] start conv={normalized_id} seq={seq} mode={restore_mode} "
|
f"[Versioning][Restore] start conv={normalized_id} seq={seq} mode={restore_mode} "
|
||||||
|
f"tracking_mode={tracking_mode} "
|
||||||
f"target_commit={checkpoint.get('commit')} workspace={workspace.project_path}"
|
f"target_commit={checkpoint.get('commit')} workspace={workspace.project_path}"
|
||||||
)
|
)
|
||||||
manager.restore_to_commit(checkpoint.get("commit"))
|
if tracking_mode == ConversationVersioningManager.TRACKING_MODE_WORKSPACE_AND_CONVERSATION:
|
||||||
debug_log(
|
manager.restore_to_commit(checkpoint.get("commit"))
|
||||||
f"[Versioning][Restore] git restored conv={normalized_id} seq={seq} "
|
debug_log(
|
||||||
f"commit={checkpoint.get('commit')}"
|
f"[Versioning][Restore] git restored conv={normalized_id} seq={seq} "
|
||||||
)
|
f"commit={checkpoint.get('commit')}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
debug_log(
|
||||||
|
f"[Versioning][Restore] conversation-only mode, skip workspace restore "
|
||||||
|
f"conv={normalized_id} seq={seq}"
|
||||||
|
)
|
||||||
|
|
||||||
cm = terminal.context_manager.conversation_manager
|
cm = terminal.context_manager.conversation_manager
|
||||||
conv_data = cm.load_conversation(normalized_id) or {}
|
conv_data = cm.load_conversation(normalized_id) or {}
|
||||||
@ -814,6 +992,9 @@ def restore_conversation_versioning_checkpoint(conversation_id, terminal: WebTer
|
|||||||
if not ok:
|
if not ok:
|
||||||
return jsonify({"success": False, "error": "覆盖当前对话失败"}), 500
|
return jsonify({"success": False, "error": "覆盖当前对话失败"}), 500
|
||||||
prune_info = manager.prune_checkpoints_after(seq)
|
prune_info = manager.prune_checkpoints_after(seq)
|
||||||
|
resolved_last_commit = prune_info.get("last_commit") or checkpoint.get("commit")
|
||||||
|
if not resolved_last_commit:
|
||||||
|
resolved_last_commit = (manager.load_meta() or {}).get("last_commit")
|
||||||
debug_log(
|
debug_log(
|
||||||
f"[Versioning][Restore] overwrite pruned conv={normalized_id} seq<={seq} "
|
f"[Versioning][Restore] overwrite pruned conv={normalized_id} seq<={seq} "
|
||||||
f"kept={prune_info.get('kept')} removed={prune_info.get('removed')} "
|
f"kept={prune_info.get('kept')} removed={prune_info.get('removed')} "
|
||||||
@ -825,7 +1006,8 @@ def restore_conversation_versioning_checkpoint(conversation_id, terminal: WebTer
|
|||||||
normalized_id,
|
normalized_id,
|
||||||
enabled=True,
|
enabled=True,
|
||||||
mode="overwrite",
|
mode="overwrite",
|
||||||
last_commit=prune_info.get("last_commit") or checkpoint.get("commit"),
|
tracking_mode=tracking_mode,
|
||||||
|
last_commit=resolved_last_commit,
|
||||||
last_input_seq=int(prune_info.get("max_seq") or checkpoint.get("seq") or 0),
|
last_input_seq=int(prune_info.get("max_seq") or checkpoint.get("seq") or 0),
|
||||||
)
|
)
|
||||||
cm.update_conversation_metadata(
|
cm.update_conversation_metadata(
|
||||||
@ -835,7 +1017,8 @@ def restore_conversation_versioning_checkpoint(conversation_id, terminal: WebTer
|
|||||||
"versioning": {
|
"versioning": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"mode": "overwrite",
|
"mode": "overwrite",
|
||||||
"last_commit": prune_info.get("last_commit") or checkpoint.get("commit"),
|
"tracking_mode": tracking_mode,
|
||||||
|
"last_commit": resolved_last_commit,
|
||||||
"last_input_seq": int(prune_info.get("max_seq") or checkpoint.get("seq") or 0),
|
"last_input_seq": int(prune_info.get("max_seq") or checkpoint.get("seq") or 0),
|
||||||
"updated_at": datetime.now().isoformat(),
|
"updated_at": datetime.now().isoformat(),
|
||||||
},
|
},
|
||||||
@ -880,6 +1063,7 @@ def restore_conversation_versioning_checkpoint(conversation_id, terminal: WebTer
|
|||||||
"conversation_id": target_conversation_id,
|
"conversation_id": target_conversation_id,
|
||||||
"restored_seq": int(checkpoint.get("seq") or 0),
|
"restored_seq": int(checkpoint.get("seq") or 0),
|
||||||
"restored_commit": checkpoint.get("commit"),
|
"restored_commit": checkpoint.get("commit"),
|
||||||
|
"tracking_mode": tracking_mode,
|
||||||
"source_conversation_id": normalized_id,
|
"source_conversation_id": normalized_id,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@ -253,6 +253,9 @@ async def run_deep_compression(
|
|||||||
old_title = conv_data.get("title") or "未命名"
|
old_title = conv_data.get("title") or "未命名"
|
||||||
source_versioning = metadata.get("versioning") if isinstance(metadata.get("versioning"), dict) else {}
|
source_versioning = metadata.get("versioning") if isinstance(metadata.get("versioning"), dict) else {}
|
||||||
source_versioning_enabled = bool(source_versioning.get("enabled"))
|
source_versioning_enabled = bool(source_versioning.get("enabled"))
|
||||||
|
source_tracking_mode = ConversationVersioningManager.normalize_tracking_mode(
|
||||||
|
source_versioning.get("tracking_mode")
|
||||||
|
)
|
||||||
compression_count = int(metadata.get("compression_count", 0) or 0)
|
compression_count = int(metadata.get("compression_count", 0) or 0)
|
||||||
previous_records = _normalize_deep_compression_records(metadata)
|
previous_records = _normalize_deep_compression_records(metadata)
|
||||||
previous_max_count = max([int(item.get("count") or 0) for item in previous_records], default=0)
|
previous_max_count = max([int(item.get("count") or 0) for item in previous_records], default=0)
|
||||||
@ -349,7 +352,11 @@ async def run_deep_compression(
|
|||||||
data_dir=workspace.data_dir,
|
data_dir=workspace.data_dir,
|
||||||
conversation_id=new_conv_id,
|
conversation_id=new_conv_id,
|
||||||
)
|
)
|
||||||
vm_meta = vm.set_enabled(enabled=True, mode="overwrite")
|
vm_meta = vm.set_enabled(
|
||||||
|
enabled=True,
|
||||||
|
mode="overwrite",
|
||||||
|
tracking_mode=source_tracking_mode,
|
||||||
|
)
|
||||||
new_conv_data = cm.conversation_manager.load_conversation(new_conv_id) or {}
|
new_conv_data = cm.conversation_manager.load_conversation(new_conv_id) or {}
|
||||||
snapshot_payload = {
|
snapshot_payload = {
|
||||||
"conversation_id": new_conv_id,
|
"conversation_id": new_conv_id,
|
||||||
@ -362,6 +369,7 @@ async def run_deep_compression(
|
|||||||
init_result = vm.ensure_initial_checkpoint(
|
init_result = vm.ensure_initial_checkpoint(
|
||||||
workspace_path=str(workspace.project_path),
|
workspace_path=str(workspace.project_path),
|
||||||
conversation_snapshot=snapshot_payload,
|
conversation_snapshot=snapshot_payload,
|
||||||
|
tracking_mode=source_tracking_mode,
|
||||||
)
|
)
|
||||||
init_row = init_result.get("row") or {}
|
init_row = init_result.get("row") or {}
|
||||||
last_commit = init_row.get("commit") or vm_meta.get("last_commit")
|
last_commit = init_row.get("commit") or vm_meta.get("last_commit")
|
||||||
@ -371,6 +379,7 @@ async def run_deep_compression(
|
|||||||
"versioning": {
|
"versioning": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"mode": "overwrite",
|
"mode": "overwrite",
|
||||||
|
"tracking_mode": source_tracking_mode,
|
||||||
"updated_at": datetime.now().isoformat(),
|
"updated_at": datetime.now().isoformat(),
|
||||||
"last_commit": last_commit,
|
"last_commit": last_commit,
|
||||||
"last_input_seq": int(vm_meta.get("last_input_seq") or 0),
|
"last_input_seq": int(vm_meta.get("last_input_seq") or 0),
|
||||||
|
|||||||
@ -402,6 +402,7 @@
|
|||||||
v-if="versioningDialogOpen"
|
v-if="versioningDialogOpen"
|
||||||
:host-mode="versioningHostMode"
|
:host-mode="versioningHostMode"
|
||||||
:enabled="versioningEnabled"
|
:enabled="versioningEnabled"
|
||||||
|
:tracking-mode="versioningTrackingMode"
|
||||||
:loading="versioningLoading"
|
:loading="versioningLoading"
|
||||||
:items="versioningCheckpoints"
|
:items="versioningCheckpoints"
|
||||||
:selected-seq="versioningSelectedSeq"
|
:selected-seq="versioningSelectedSeq"
|
||||||
@ -415,6 +416,7 @@
|
|||||||
@toggle-enabled="toggleConversationVersioning"
|
@toggle-enabled="toggleConversationVersioning"
|
||||||
@select="selectVersioningCheckpoint"
|
@select="selectVersioningCheckpoint"
|
||||||
@update:restore-mode="versioningRestoreMode = $event"
|
@update:restore-mode="versioningRestoreMode = $event"
|
||||||
|
@update:tracking-mode="handleVersioningTrackingModeChange"
|
||||||
@confirm="confirmVersioningRestore"
|
@confirm="confirmVersioningRestore"
|
||||||
/>
|
/>
|
||||||
</transition>
|
</transition>
|
||||||
|
|||||||
@ -1,6 +1,12 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { debugLog } from './common';
|
import { debugLog } from './common';
|
||||||
|
|
||||||
|
const normalizeTrackingMode = (value: any): 'workspace_and_conversation' | 'conversation_only' => {
|
||||||
|
return String(value || '').toLowerCase() === 'conversation_only'
|
||||||
|
? 'conversation_only'
|
||||||
|
: 'workspace_and_conversation';
|
||||||
|
};
|
||||||
|
|
||||||
export const versioningMethods = {
|
export const versioningMethods = {
|
||||||
async fetchVersioningStatus(conversationId = null, options = {}) {
|
async fetchVersioningStatus(conversationId = null, options = {}) {
|
||||||
const targetId = conversationId || this.currentConversationId;
|
const targetId = conversationId || this.currentConversationId;
|
||||||
@ -18,6 +24,14 @@ export const versioningMethods = {
|
|||||||
const payload = data.data || {};
|
const payload = data.data || {};
|
||||||
this.versioningHostMode = !!payload.host_mode;
|
this.versioningHostMode = !!payload.host_mode;
|
||||||
this.versioningEnabled = !!payload.enabled;
|
this.versioningEnabled = !!payload.enabled;
|
||||||
|
const resolvedTrackingMode = normalizeTrackingMode(payload.tracking_mode);
|
||||||
|
this.versioningTrackingMode = !this.versioningHostMode &&
|
||||||
|
resolvedTrackingMode !== 'conversation_only'
|
||||||
|
? 'conversation_only'
|
||||||
|
: resolvedTrackingMode;
|
||||||
|
if (!this.versioningHostMode) {
|
||||||
|
this.newConversationVersioningTrackingMode = 'conversation_only';
|
||||||
|
}
|
||||||
this.versioningMode = 'overwrite';
|
this.versioningMode = 'overwrite';
|
||||||
this.versioningRestoreMode = 'overwrite';
|
this.versioningRestoreMode = 'overwrite';
|
||||||
this.versioningMismatch = !!payload.mismatch;
|
this.versioningMismatch = !!payload.mismatch;
|
||||||
@ -45,6 +59,9 @@ export const versioningMethods = {
|
|||||||
if (!resp.ok || !data?.success) {
|
if (!resp.ok || !data?.success) {
|
||||||
throw new Error(data?.error || '加载版本点失败');
|
throw new Error(data?.error || '加载版本点失败');
|
||||||
}
|
}
|
||||||
|
if (data?.data?.tracking_mode) {
|
||||||
|
this.versioningTrackingMode = normalizeTrackingMode(data.data.tracking_mode);
|
||||||
|
}
|
||||||
const items = Array.isArray(data?.data?.items) ? data.data.items : [];
|
const items = Array.isArray(data?.data?.items) ? data.data.items : [];
|
||||||
this.versioningCheckpoints = items;
|
this.versioningCheckpoints = items;
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
@ -67,6 +84,12 @@ export const versioningMethods = {
|
|||||||
async openVersioningDialog() {
|
async openVersioningDialog() {
|
||||||
if (!this.currentConversationId) {
|
if (!this.currentConversationId) {
|
||||||
this.versioningEnabled = !!this.newConversationVersioningEnabled;
|
this.versioningEnabled = !!this.newConversationVersioningEnabled;
|
||||||
|
this.versioningTrackingMode = normalizeTrackingMode(
|
||||||
|
this.newConversationVersioningTrackingMode
|
||||||
|
);
|
||||||
|
if (!this.versioningHostMode && this.versioningTrackingMode !== 'conversation_only') {
|
||||||
|
this.versioningTrackingMode = 'conversation_only';
|
||||||
|
}
|
||||||
this.versioningWorkspaceMatched = true;
|
this.versioningWorkspaceMatched = true;
|
||||||
this.versioningCheckpoints = [];
|
this.versioningCheckpoints = [];
|
||||||
this.versioningSelectedSeq = null;
|
this.versioningSelectedSeq = null;
|
||||||
@ -87,18 +110,22 @@ export const versioningMethods = {
|
|||||||
await this.fetchVersioningCheckpoints(this.currentConversationId);
|
await this.fetchVersioningCheckpoints(this.currentConversationId);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async handleVersioningTrackingModeChange(mode: string) {
|
||||||
|
await this.updateVersioningTrackingMode(mode);
|
||||||
|
},
|
||||||
|
|
||||||
async toggleConversationVersioning(enabled: boolean) {
|
async toggleConversationVersioning(enabled: boolean) {
|
||||||
if (!this.currentConversationId) {
|
if (!this.currentConversationId) {
|
||||||
if (!this.versioningHostMode) {
|
|
||||||
this.uiPushToast({
|
|
||||||
title: '版本管理',
|
|
||||||
message: '仅宿主机模式支持版本管理',
|
|
||||||
type: 'warning'
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.newConversationVersioningEnabled = !!enabled;
|
this.newConversationVersioningEnabled = !!enabled;
|
||||||
this.versioningEnabled = !!enabled;
|
this.versioningEnabled = !!enabled;
|
||||||
|
if (enabled) {
|
||||||
|
if (!this.versioningHostMode) {
|
||||||
|
this.versioningTrackingMode = 'conversation_only';
|
||||||
|
}
|
||||||
|
this.newConversationVersioningTrackingMode = normalizeTrackingMode(
|
||||||
|
this.versioningTrackingMode
|
||||||
|
);
|
||||||
|
}
|
||||||
this.uiPushToast({
|
this.uiPushToast({
|
||||||
title: '版本管理',
|
title: '版本管理',
|
||||||
message: enabled ? '已为下一次新对话开启版本管理' : '已取消下一次新对话的版本管理',
|
message: enabled ? '已为下一次新对话开启版本管理' : '已取消下一次新对话的版本管理',
|
||||||
@ -108,12 +135,18 @@ export const versioningMethods = {
|
|||||||
}
|
}
|
||||||
this.versioningLoading = true;
|
this.versioningLoading = true;
|
||||||
try {
|
try {
|
||||||
|
let trackingMode = normalizeTrackingMode(this.versioningTrackingMode);
|
||||||
|
if (!this.versioningHostMode && trackingMode !== 'conversation_only') {
|
||||||
|
trackingMode = 'conversation_only';
|
||||||
|
this.versioningTrackingMode = trackingMode;
|
||||||
|
}
|
||||||
const resp = await fetch(`/api/conversations/${this.currentConversationId}/versioning`, {
|
const resp = await fetch(`/api/conversations/${this.currentConversationId}/versioning`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
enabled: !!enabled,
|
enabled: !!enabled,
|
||||||
mode: 'overwrite'
|
mode: 'overwrite',
|
||||||
|
tracking_mode: trackingMode
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
const data = await resp.json().catch(() => ({}));
|
const data = await resp.json().catch(() => ({}));
|
||||||
@ -121,6 +154,9 @@ export const versioningMethods = {
|
|||||||
throw new Error(data?.error || '切换版本管理失败');
|
throw new Error(data?.error || '切换版本管理失败');
|
||||||
}
|
}
|
||||||
this.versioningEnabled = !!data?.data?.enabled;
|
this.versioningEnabled = !!data?.data?.enabled;
|
||||||
|
this.versioningTrackingMode = normalizeTrackingMode(
|
||||||
|
data?.data?.tracking_mode || this.versioningTrackingMode
|
||||||
|
);
|
||||||
this.versioningMode = 'overwrite';
|
this.versioningMode = 'overwrite';
|
||||||
this.versioningRestoreMode = 'overwrite';
|
this.versioningRestoreMode = 'overwrite';
|
||||||
this.uiPushToast({
|
this.uiPushToast({
|
||||||
@ -264,15 +300,20 @@ export const versioningMethods = {
|
|||||||
async applyPendingVersioningToConversation(conversationId: string) {
|
async applyPendingVersioningToConversation(conversationId: string) {
|
||||||
const targetId = conversationId || this.currentConversationId;
|
const targetId = conversationId || this.currentConversationId;
|
||||||
if (!targetId) return;
|
if (!targetId) return;
|
||||||
if (!this.versioningHostMode) return;
|
|
||||||
if (!this.newConversationVersioningEnabled) return;
|
if (!this.newConversationVersioningEnabled) return;
|
||||||
try {
|
try {
|
||||||
|
const trackingMode = normalizeTrackingMode(
|
||||||
|
!this.versioningHostMode
|
||||||
|
? 'conversation_only'
|
||||||
|
: (this.newConversationVersioningTrackingMode || this.versioningTrackingMode)
|
||||||
|
);
|
||||||
const resp = await fetch(`/api/conversations/${targetId}/versioning`, {
|
const resp = await fetch(`/api/conversations/${targetId}/versioning`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
mode: 'overwrite'
|
mode: 'overwrite',
|
||||||
|
tracking_mode: trackingMode
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
const data = await resp.json().catch(() => ({}));
|
const data = await resp.json().catch(() => ({}));
|
||||||
@ -281,6 +322,9 @@ export const versioningMethods = {
|
|||||||
}
|
}
|
||||||
this.newConversationVersioningEnabled = false;
|
this.newConversationVersioningEnabled = false;
|
||||||
this.versioningEnabled = true;
|
this.versioningEnabled = true;
|
||||||
|
this.versioningTrackingMode = normalizeTrackingMode(
|
||||||
|
data?.data?.tracking_mode || trackingMode
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.uiPushToast({
|
this.uiPushToast({
|
||||||
title: '版本管理',
|
title: '版本管理',
|
||||||
@ -288,5 +332,64 @@ export const versioningMethods = {
|
|||||||
type: 'error'
|
type: 'error'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateVersioningTrackingMode(mode: string) {
|
||||||
|
const normalizedMode = normalizeTrackingMode(mode);
|
||||||
|
if (!this.versioningHostMode && normalizedMode === 'workspace_and_conversation') {
|
||||||
|
this.uiPushToast({
|
||||||
|
title: '版本管理',
|
||||||
|
message: '当前模式仅支持“仅管理对话”',
|
||||||
|
type: 'warning'
|
||||||
|
});
|
||||||
|
this.versioningTrackingMode = 'conversation_only';
|
||||||
|
this.newConversationVersioningTrackingMode = 'conversation_only';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.versioningTrackingMode = normalizedMode;
|
||||||
|
if (!this.currentConversationId) {
|
||||||
|
this.newConversationVersioningTrackingMode = normalizedMode;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!this.versioningEnabled) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
this.versioningLoading = true;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/conversations/${this.currentConversationId}/versioning`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
enabled: true,
|
||||||
|
mode: 'overwrite',
|
||||||
|
tracking_mode: normalizedMode
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
if (!resp.ok || !data?.success) {
|
||||||
|
throw new Error(data?.error || '更新版本管理范围失败');
|
||||||
|
}
|
||||||
|
this.versioningTrackingMode = normalizeTrackingMode(
|
||||||
|
data?.data?.tracking_mode || normalizedMode
|
||||||
|
);
|
||||||
|
this.uiPushToast({
|
||||||
|
title: '版本管理',
|
||||||
|
message:
|
||||||
|
this.versioningTrackingMode === 'conversation_only'
|
||||||
|
? '已切换为仅管理对话'
|
||||||
|
: '已切换为管理工作区和对话',
|
||||||
|
type: 'success'
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
this.uiPushToast({
|
||||||
|
title: '版本管理',
|
||||||
|
message: error?.message || '更新版本管理范围失败',
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
this.versioningLoading = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -98,6 +98,8 @@ export function dataState() {
|
|||||||
hostWorkspaceCreateError: '',
|
hostWorkspaceCreateError: '',
|
||||||
versioningEnabled: false,
|
versioningEnabled: false,
|
||||||
newConversationVersioningEnabled: false,
|
newConversationVersioningEnabled: false,
|
||||||
|
versioningTrackingMode: 'workspace_and_conversation',
|
||||||
|
newConversationVersioningTrackingMode: 'workspace_and_conversation',
|
||||||
versioningMode: 'overwrite',
|
versioningMode: 'overwrite',
|
||||||
versioningMismatch: false,
|
versioningMismatch: false,
|
||||||
versioningWorkspaceMatched: true,
|
versioningWorkspaceMatched: true,
|
||||||
@ -149,6 +151,10 @@ export function dataState() {
|
|||||||
connectionHeartbeatRequestTimeoutMs: 5000,
|
connectionHeartbeatRequestTimeoutMs: 5000,
|
||||||
connectionHeartbeatIntervalMs: 8000,
|
connectionHeartbeatIntervalMs: 8000,
|
||||||
connectionHeartbeatDisconnectedIntervalMs: 1000,
|
connectionHeartbeatDisconnectedIntervalMs: 1000,
|
||||||
|
composerDraftSaveTimer: null,
|
||||||
|
composerDraftDirty: false,
|
||||||
|
composerDraftLastSyncedContent: '',
|
||||||
|
composerDraftFetchSeq: 0,
|
||||||
|
|
||||||
// 工具控制菜单
|
// 工具控制菜单
|
||||||
icons: ICONS,
|
icons: ICONS,
|
||||||
|
|||||||
@ -8,11 +8,24 @@
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:checked="enabled"
|
:checked="enabled"
|
||||||
:disabled="!hostMode || loading"
|
:disabled="loading"
|
||||||
@change="$emit('toggle-enabled', ($event.target as HTMLInputElement).checked)"
|
@change="$emit('toggle-enabled', ($event.target as HTMLInputElement).checked)"
|
||||||
/>
|
/>
|
||||||
<span>开启</span>
|
<span>开启</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="scope-line">
|
||||||
|
<span>管理范围</span>
|
||||||
|
<select
|
||||||
|
:value="trackingMode"
|
||||||
|
:disabled="!hostMode || loading || restoring"
|
||||||
|
@change="$emit('update:tracking-mode', ($event.target as HTMLSelectElement).value)"
|
||||||
|
>
|
||||||
|
<option value="workspace_and_conversation" :disabled="!hostMode">
|
||||||
|
管理工作区和对话
|
||||||
|
</option>
|
||||||
|
<option value="conversation_only">仅管理对话</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="versioning-header-actions">
|
<div class="versioning-header-actions">
|
||||||
<button type="button" class="refresh-btn" :disabled="loading" @click="$emit('refresh')">
|
<button type="button" class="refresh-btn" :disabled="loading" @click="$emit('refresh')">
|
||||||
@ -84,14 +97,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="versioning-footer">
|
<footer class="versioning-footer">
|
||||||
<div v-if="workspaceMatched === false" class="workspace-mismatch-tip">
|
<div v-if="needWorkspaceMatch && workspaceMatched === false" class="workspace-mismatch-tip">
|
||||||
当前对话所属工作区与当前工作区不一致,无法执行回溯。
|
当前对话所属工作区与当前工作区不一致,无法执行回溯。
|
||||||
</div>
|
</div>
|
||||||
<label>回溯模式:覆盖当前对话</label>
|
<label>回溯模式:覆盖当前对话</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="confirm-btn"
|
class="confirm-btn"
|
||||||
:disabled="!enabled || selectedSeq === null || selectedSeq === undefined || restoring || workspaceMatched === false"
|
:disabled="!enabled || selectedSeq === null || selectedSeq === undefined || restoring || (needWorkspaceMatch && workspaceMatched === false)"
|
||||||
@click="$emit('confirm')"
|
@click="$emit('confirm')"
|
||||||
>
|
>
|
||||||
{{ restoring ? '回溯中...' : '确认回溯' }}
|
{{ restoring ? '回溯中...' : '确认回溯' }}
|
||||||
@ -107,6 +120,7 @@ import { computed, ref, watch } from 'vue';
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
hostMode: boolean;
|
hostMode: boolean;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
trackingMode: string;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
items: any[];
|
items: any[];
|
||||||
selectedSeq: number | null;
|
selectedSeq: number | null;
|
||||||
@ -121,11 +135,14 @@ defineEmits([
|
|||||||
'close',
|
'close',
|
||||||
'refresh',
|
'refresh',
|
||||||
'toggle-enabled',
|
'toggle-enabled',
|
||||||
|
'update:tracking-mode',
|
||||||
'select',
|
'select',
|
||||||
'confirm',
|
'confirm',
|
||||||
'update:restore-mode'
|
'update:restore-mode'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const needWorkspaceMatch = computed(() => String(props.trackingMode || '') !== 'conversation_only');
|
||||||
|
|
||||||
const orderedItems = computed(() => {
|
const orderedItems = computed(() => {
|
||||||
const src = Array.isArray(props.items) ? props.items : [];
|
const src = Array.isArray(props.items) ? props.items : [];
|
||||||
return [...src].sort((a: any, b: any) => Number(a?.seq || 0) - Number(b?.seq || 0));
|
return [...src].sort((a: any, b: any) => Number(a?.seq || 0) - Number(b?.seq || 0));
|
||||||
@ -169,6 +186,8 @@ const formatTime = (value: string) => {
|
|||||||
.versioning-header-actions { display: inline-flex; align-items: center; gap: 8px; }
|
.versioning-header-actions { display: inline-flex; align-items: center; gap: 8px; }
|
||||||
.close-btn { border: 0; background: transparent; font-size: 24px; cursor: pointer; }
|
.close-btn { border: 0; background: transparent; font-size: 24px; cursor: pointer; }
|
||||||
.switch-line { display: flex; align-items: center; gap: 8px; font-size: 14px; }
|
.switch-line { display: flex; align-items: center; gap: 8px; font-size: 14px; }
|
||||||
|
.scope-line { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; color: var(--claude-text-secondary); }
|
||||||
|
.scope-line select { border: 1px solid var(--theme-control-border); border-radius: 8px; padding: 5px 10px; background: var(--theme-surface-soft); color: var(--claude-text); }
|
||||||
.refresh-btn { border: 1px solid var(--theme-control-border); border-radius: 8px; padding: 6px 10px; background: var(--theme-surface-soft); color: var(--claude-text); cursor: pointer; }
|
.refresh-btn { border: 1px solid var(--theme-control-border); border-radius: 8px; padding: 6px 10px; background: var(--theme-surface-soft); color: var(--claude-text); cursor: pointer; }
|
||||||
.versioning-body { display: grid; grid-template-columns: 380px 1fr; min-height: 0; flex: 1; border-bottom: 1px solid var(--theme-control-border); }
|
.versioning-body { display: grid; grid-template-columns: 380px 1fr; min-height: 0; flex: 1; border-bottom: 1px solid var(--theme-control-border); }
|
||||||
.checkpoint-list { overflow: auto; border-right: 1px solid var(--theme-control-border); padding: 10px; }
|
.checkpoint-list { overflow: auto; border-right: 1px solid var(--theme-control-border); padding: 10px; }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user