main
py 166 lines 5.63 KB
Raw
1 import json
2
3 from agent import Agent, AgentContext
4 from helpers.api import ApiHandler, Input, Output, Request, Response
5 from helpers.localization import Localization
6 from helpers.persist_chat import (
7 _serialize_context,
8 _deserialize_context,
9 save_tmp_chat,
10 )
11
12
13 def _detach_response_ids(value: object) -> None:
14 if isinstance(value, dict):
15 metadata = value.get("metadata")
16 responses = metadata.get("responses") if isinstance(metadata, dict) else None
17 if isinstance(responses, dict):
18 responses.pop("response_id", None)
19 responses.pop("previous_response_id", None)
20 for nested in value.values():
21 _detach_response_ids(nested)
22 elif isinstance(value, list):
23 for nested in value:
24 _detach_response_ids(nested)
25
26
27 def _trim_history_json(history_json: str, kept_ids: set[str], after_cut_ids: set[str]) -> str:
28 """Trim a serialized history JSON to keep only messages that appear
29 before the branch cut point.
30
31 *kept_ids*: IDs of log items up to and including the cut point.
32 *after_cut_ids*: IDs of log items after the cut point.
33
34 Walk messages in order. A message is kept while the running state
35 is "keep". The state flips to "drop" the first time we encounter
36 a message whose id is in *after_cut_ids*. Messages whose id does
37 not appear in any log set (unpaired) inherit the current state.
38 Summarized topics/bulks are always preserved.
39 """
40 if not history_json:
41 return history_json
42
43 hist = json.loads(history_json)
44 keep = True # running state
45
46 def filter_messages(messages: list[dict]) -> list[dict]:
47 nonlocal keep
48 result = []
49 for msg in messages:
50 mid = msg.get("id", "")
51 if mid and mid in after_cut_ids:
52 keep = False
53 elif mid and mid in kept_ids:
54 keep = True
55 # else: unpaired – inherit current state
56 if keep:
57 result.append(msg)
58 return result
59
60 # Bulks are already summarized old history – always keep
61 # Topics: keep summarized ones; filter unsummarized
62 trimmed_topics = []
63 for topic in hist.get("topics", []):
64 if topic.get("summary"):
65 trimmed_topics.append(topic)
66 continue
67 msgs = filter_messages(topic.get("messages", []))
68 if msgs:
69 topic["messages"] = msgs
70 trimmed_topics.append(topic)
71 if not keep:
72 break
73 hist["topics"] = trimmed_topics
74
75 # Current topic
76 current = hist.get("current", {})
77 if not current.get("summary") and keep:
78 current["messages"] = filter_messages(current.get("messages", []))
79 elif not keep:
80 current["messages"] = []
81 hist["current"] = current
82
83 # Recount
84 total = sum(
85 len(t.get("messages", [])) for t in hist["topics"] if not t.get("summary")
86 ) + len(hist.get("current", {}).get("messages", []))
87 hist["counter"] = total
88 _detach_response_ids(hist)
89
90 return json.dumps(hist, ensure_ascii=False)
91
92
93 class BranchChat(ApiHandler):
94 """Create a new chat branched from an existing chat at a specific log message."""
95
96 async def process(self, input: Input, request: Request) -> Output:
97 ctxid = input.get("context", "")
98 log_no = input.get("log_no") # LogItem.no from frontend
99
100 if not ctxid:
101 return Response("Missing context id", 400)
102 if log_no is None:
103 return Response("Missing log_no", 400)
104
105 context = AgentContext.get(ctxid)
106 if not context:
107 return Response("Context not found", 404)
108
109 # Serialize the source context
110 data = _serialize_context(context)
111
112 # Remove id so _deserialize_context generates a new one
113 del data["id"]
114
115 # Trim log entries: keep only items up to and including log_no.
116 src_logs = data["log"]["logs"]
117 cut_idx = None
118 for i, item in enumerate(src_logs):
119 if item["no"] == log_no:
120 cut_idx = i
121 break
122
123 if cut_idx is None:
124 if 0 <= log_no < len(src_logs):
125 cut_idx = log_no
126 else:
127 return Response("log_no not found in chat log", 400)
128
129 kept_logs = src_logs[: cut_idx + 1]
130 after_logs = src_logs[cut_idx + 1 :]
131 data["log"]["logs"] = kept_logs
132
133 # Build ID sets for history trimming
134 kept_ids = {item["id"] for item in kept_logs if item.get("id")}
135 after_cut_ids = {item["id"] for item in after_logs if item.get("id")}
136
137 # Trim each agent's history using ID matching
138 for ag in data.get("agents", []):
139 ag["history"] = _trim_history_json(
140 ag.get("history", ""), kept_ids, after_cut_ids
141 )
142 agent_data = ag.get("data")
143 if isinstance(agent_data, dict):
144 agent_data.pop(Agent.DATA_NAME_RESPONSES_STATE, None)
145 agent_data.pop(Agent.DATA_NAME_CTX_WINDOW, None)
146
147 # Give the branch a distinguishable name
148 src_name = data.get("name") or "Chat"
149 data["name"] = f"{src_name} (branch)"
150 data["created_at"] = Localization.get().now_iso()
151
152 # Deserialize into a brand-new context (new id, fresh agent config)
153 new_context = _deserialize_context(data)
154
155 # Persist immediately
156 save_tmp_chat(new_context)
157
158 # Notify all tabs
159 from helpers.state_monitor_integration import mark_dirty_all
160 mark_dirty_all(reason="plugins.chat_branching.BranchChat")
161
162 return {
163 "ok": True,
164 "ctxid": new_context.id,
165 "message": "Chat branched successfully.",
166 }