88import re
99import sqlite3
1010from datetime import datetime
11+ from urllib .parse import unquote as _url_unquote
1112
12- from flask import Blueprint , jsonify , request
13+ from flask import Blueprint , current_app , jsonify , request
1314
15+ from utils .exclusion_rules import build_searchable_text , is_excluded_by_rules
1416from utils .workspace_path import resolve_workspace_path
1517from utils .path_helpers import normalize_file_path , get_workspace_folder_paths , to_epoch_ms
1618from utils .text_extract import extract_text_from_bubble
1719
1820bp = Blueprint ("search" , __name__ )
1921
2022
23+ def _json_dump_safe (value ) -> str :
24+ """Best-effort JSON string conversion for exclusion matching."""
25+ try :
26+ return json .dumps (value , ensure_ascii = False , sort_keys = True )
27+ except Exception :
28+ return str (value ) if value is not None else ""
29+
30+
31+ def _workspace_display_name_from_folder (folder : str | None , fallback : str | None = None ) -> str :
32+ """Extract a human-readable workspace name from workspace folder path."""
33+ if folder :
34+ raw = str (folder ).strip ()
35+ cleaned = re .sub (r"^file://" , "" , raw ).replace ("\\ " , "/" )
36+ parts = cleaned .split ("/" )
37+ leaf = parts [- 1 ] if parts else ""
38+ if leaf :
39+ return _url_unquote (leaf )
40+ return fallback or "Other chats"
41+
42+
43+ def _build_exclusion_searchable (
44+ * ,
45+ project_name : str | None ,
46+ chat_title : str | None ,
47+ model_names : list [str ] | None = None ,
48+ content_parts : list [str ] | None = None ,
49+ metadata_parts : list [str ] | None = None ,
50+ ) -> str :
51+ """Build broad searchable text so exclusion rules cover visible output."""
52+ combined = []
53+ if content_parts :
54+ combined .extend (p for p in content_parts if p )
55+ if metadata_parts :
56+ combined .extend (p for p in metadata_parts if p )
57+ return build_searchable_text (
58+ project_name = project_name ,
59+ chat_title = chat_title ,
60+ model_names = model_names ,
61+ chat_content_snippet = "\n \n " .join (combined ) if combined else None ,
62+ )
63+
64+
2165@bp .route ("/api/search" )
2266def search ():
2367 try :
2468 query = request .args .get ("q" , "" ).strip ()
2569 search_type = request .args .get ("type" , "all" )
70+ rules = current_app .config .get ("EXCLUSION_RULES" ) or []
2671
2772 if not query :
2873 return jsonify ({"error" : "No search query provided" }), 400
@@ -58,7 +103,7 @@ def search():
58103 parts = first_folder .replace ("\\ " , "/" ).split ("/" )
59104 fn = parts [- 1 ] if parts else None
60105 if fn :
61- ws_id_to_name [name ] = fn
106+ ws_id_to_name [name ] = _url_unquote ( fn )
62107 except Exception :
63108 pass
64109 except Exception :
@@ -114,41 +159,72 @@ def search():
114159 if not headers :
115160 continue
116161
162+ title = cd .get ("name" ) or ""
163+ ws_id = composer_id_to_ws .get (composer_id , "global" )
164+ ws_name = ws_id_to_name .get (ws_id )
165+ project_name = ws_name or ("Other chats" if ws_id == "global" else ws_id )
166+
167+ model_config = cd .get ("modelConfig" ) or {}
168+ model_name = model_config .get ("modelName" )
169+ model_names = [model_name ] if model_name and model_name != "default" else None
170+
171+ bubble_texts = []
172+ bubble_meta = []
173+ for header in headers :
174+ bid = header .get ("bubbleId" )
175+ bubble_entry = bubble_map .get (bid )
176+ if not bubble_entry :
177+ continue
178+ text = bubble_entry .get ("text" ) or ""
179+ if text :
180+ bubble_texts .append (text )
181+ raw_bubble = bubble_entry .get ("raw" )
182+ if raw_bubble :
183+ bubble_meta .append (_json_dump_safe (raw_bubble ))
184+
185+ exclusion_text = _build_exclusion_searchable (
186+ project_name = project_name ,
187+ chat_title = title ,
188+ model_names = model_names ,
189+ content_parts = bubble_texts ,
190+ metadata_parts = [
191+ _json_dump_safe (model_config ),
192+ _json_dump_safe (cd .get ("conversationSummary" )),
193+ _json_dump_safe (cd .get ("usage" )),
194+ _json_dump_safe (cd .get ("requestMetadata" )),
195+ _json_dump_safe (cd ),
196+ "\n " .join (bubble_meta ),
197+ ],
198+ )
199+ if is_excluded_by_rules (rules , exclusion_text ):
200+ continue
201+
117202 # Check if any bubble text matches
118203 has_match = False
119204 matching_text = ""
120- title = cd .get ("name" ) or ""
121-
122205 # Check title
123206 if title and query_lower in title .lower ():
124207 has_match = True
125208 matching_text = title
126209
127210 # Check bubble texts
128211 if not has_match :
129- for header in headers :
130- bid = header .get ("bubbleId" )
131- bubble_entry = bubble_map .get (bid )
132- if bubble_entry :
133- text = bubble_entry ["text" ]
134- if text and query_lower in text .lower ():
135- has_match = True
136- # Extract a snippet around the match
137- idx = text .lower ().find (query_lower )
138- start = max (0 , idx - 80 )
139- end = min (len (text ), idx + len (query ) + 120 )
140- matching_text = ("..." if start > 0 else "" ) + text [start :end ] + ("..." if end < len (text ) else "" )
141- break
212+ for text in bubble_texts :
213+ if text and query_lower in text .lower ():
214+ has_match = True
215+ # Extract a snippet around the match
216+ idx = text .lower ().find (query_lower )
217+ start = max (0 , idx - 80 )
218+ end = min (len (text ), idx + len (query ) + 120 )
219+ matching_text = ("..." if start > 0 else "" ) + text [start :end ] + ("..." if end < len (text ) else "" )
220+ break
142221
143222 if has_match :
144- ws_id = composer_id_to_ws .get (composer_id , "global" )
145- ws_name = ws_id_to_name .get (ws_id )
146223 if not title :
147224 # Derive title from first bubble
148- for header in headers :
149- be = bubble_map .get (header .get ("bubbleId" ))
150- if be and be ["text" ]:
151- first_lines = [l for l in be ["text" ].split ("\n " ) if l .strip ()]
225+ for text in bubble_texts :
226+ if text :
227+ first_lines = [l for l in text .split ("\n " ) if l .strip ()]
152228 if first_lines :
153229 title = first_lines [0 ][:100 ]
154230 break
@@ -191,6 +267,7 @@ def search():
191267 workspace_folder = wd .get ("folder" )
192268 except Exception :
193269 pass
270+ workspace_name = _workspace_display_name_from_folder (workspace_folder , fallback = name )
194271
195272 try :
196273 conn = sqlite3 .connect (f"file:{ db_path } ?mode=ro" , uri = True )
@@ -203,10 +280,38 @@ def search():
203280 if chat_row and chat_row [0 ]:
204281 data = json .loads (chat_row [0 ])
205282 for tab in (data .get ("tabs" ) or []):
283+ ct = tab .get ("chatTitle" ) or ""
284+ tab_model_names = None
285+ tab_meta = tab .get ("metadata" )
286+ if isinstance (tab_meta , dict ):
287+ models_used = tab_meta .get ("modelsUsed" )
288+ if isinstance (models_used , list ):
289+ tab_model_names = [str (m ) for m in models_used if m ]
290+ elif tab_meta .get ("model" ):
291+ tab_model_names = [str (tab_meta .get ("model" ))]
292+
293+ tab_bubble_texts = []
294+ for bubble in (tab .get ("bubbles" ) or []):
295+ text = bubble .get ("text" ) or ""
296+ if text :
297+ tab_bubble_texts .append (text )
298+
299+ exclusion_text = _build_exclusion_searchable (
300+ project_name = workspace_name ,
301+ chat_title = ct ,
302+ model_names = tab_model_names ,
303+ content_parts = tab_bubble_texts ,
304+ metadata_parts = [
305+ _json_dump_safe (tab ),
306+ _json_dump_safe (workspace_folder ),
307+ ],
308+ )
309+ if is_excluded_by_rules (rules , exclusion_text ):
310+ continue
311+
206312 has_match = False
207313 matching_text = ""
208314
209- ct = tab .get ("chatTitle" ) or ""
210315 if ct .lower ().find (query_lower ) != - 1 :
211316 has_match = True
212317 matching_text = ct
0 commit comments