diff --git a/ai-hub/app/api/routes/nodes.py b/ai-hub/app/api/routes/nodes.py index e9a989d..70e081b 100644 --- a/ai-hub/app/api/routes/nodes.py +++ b/ai-hub/app/api/routes/nodes.py @@ -935,8 +935,11 @@ try: orchestrator = services.orchestrator # First, trigger the cat to get it into the mirror - orchestrator.assistant.cat(node_id, path, session_id=session_id) - + res = orchestrator.assistant.cat(node_id, path, session_id=session_id) + if isinstance(res, dict) and res.get("error"): + # Mirror won't get the file if the node couldn't provide it. + raise HTTPException(status_code=404, detail=f"File not found: {res.get('error')}") + # Now, serve from mirror workspace = orchestrator.mirror.get_workspace_path(session_id) abs_path = os.path.normpath(os.path.join(workspace, path.lstrip("/"))) @@ -952,6 +955,8 @@ raise HTTPException(status_code=404, detail="File did not reach mirror in time.") return FileResponse(abs_path, filename=os.path.basename(path)) + except HTTPException: + raise except Exception as e: logger.error(f"[FS] Download error: {e}") raise HTTPException(status_code=500, detail=str(e)) diff --git a/ai-hub/app/core/grpc/core/mirror.py b/ai-hub/app/core/grpc/core/mirror.py index 7b5ad79..630fb9c 100644 --- a/ai-hub/app/core/grpc/core/mirror.py +++ b/ai-hub/app/core/grpc/core/mirror.py @@ -172,7 +172,7 @@ from concurrent.futures import ThreadPoolExecutor workspace = self.get_workspace_path(session_id) ignore_filter = self.get_ignore_filter(session_id) - + raw_files = [] for root, dirs, filenames in os.walk(workspace): dirs[:] = [d for d in dirs if not ignore_filter.is_ignored(os.path.relpath(os.path.join(root, d), workspace))] @@ -183,31 +183,32 @@ continue raw_files.append((abs_path, rel_path)) + def _hash_file(file_tuple): + abs_path, rel_path = file_tuple try: - # Optimized metadata check - stats = os.stat(abs_p) + stats = os.stat(abs_path) mtime = stats.st_mtime size = stats.st_size - - cached = self.hash_cache.get(abs_p) + + cached = self.hash_cache.get(abs_path) if cached and cached[0] == size and cached[1] == mtime: file_hash = cached[2] else: - # Memory-safe incremental hashing h = hashlib.sha256() - with open(abs_p, "rb") as f: + with open(abs_path, "rb") as f: while True: - chunk = f.read(1024 * 1024) # 1MB chunks - if not chunk: break + chunk = f.read(1024 * 1024) # 1MB chunks + if not chunk: + break h.update(chunk) file_hash = h.hexdigest() - self.hash_cache[abs_p] = (size, mtime, file_hash) - + self.hash_cache[abs_path] = (size, mtime, file_hash) + return agent_pb2.FileInfo( - path=rel_p, + path=rel_path, size=size, hash=file_hash, - is_dir=False + is_dir=False, ) except Exception: return None diff --git a/ai-hub/app/core/services/browser_client.py b/ai-hub/app/core/services/browser_client.py index baf8f30..3300cab 100644 --- a/ai-hub/app/core/services/browser_client.py +++ b/ai-hub/app/core/services/browser_client.py @@ -248,9 +248,10 @@ "title": r.title, "content": r.content_markdown, "success": r.success, - "error": r.error + "error": r.error, + "fetch_mode": getattr(r, 'fetch_mode', '') }) - + return {"success": True, "results": results} except Exception as e: return {"success": False, "error": str(e)} diff --git a/ai-hub/app/core/services/sub_agent.py b/ai-hub/app/core/services/sub_agent.py index 3bbb36e..a7dd4fc 100644 --- a/ai-hub/app/core/services/sub_agent.py +++ b/ai-hub/app/core/services/sub_agent.py @@ -113,12 +113,18 @@ else: # Fallback: Check if there's any data we can show directly if isinstance(self.result, dict): - if self.result.get("eval_result"): - rep = f"📊 Data Extracted: {str(self.result['eval_result'])[:500]}..." + eval_res = self.result.get("eval_result", "") + # Guard against the string "None" which means the JS returned null/undefined + if eval_res and eval_res != "None": + rep = f"📊 Data Extracted: {str(eval_res)[:500]}..." elif self.result.get("stdout"): rep = f"📟 Output: {str(self.result['stdout'])[:500]}..." elif self.result.get("content"): rep = f"📄 Read {len(self.result['content'])} bytes." + elif self.result.get("results") and isinstance(self.result["results"], list): + count = len(self.result["results"]) + success_count = sum(1 for r in self.result["results"] if r.get("success")) + rep = f"🔍 Fetched {success_count}/{count} URLs successfully." else: rep = "✅ Step Finished." if not self.error else f"❌ Step Failed: {self.error}" else: diff --git a/ai-hub/app/core/tools/definitions/browser_automation_agent.py b/ai-hub/app/core/tools/definitions/browser_automation_agent.py index 99630cf..b2442ac 100644 --- a/ai-hub/app/core/tools/definitions/browser_automation_agent.py +++ b/ai-hub/app/core/tools/definitions/browser_automation_agent.py @@ -31,7 +31,9 @@ elif action == "close": return browser_service.close, {"session_id": resolved_sid, "on_event": on_event} elif action == "eval": - return browser_service.eval, {"script": args.get("script", ""), "session_id": resolved_sid, "on_event": on_event} + # The SKILL.md schema uses 'text' for eval JS, but also support 'script' for backward compat + script = args.get("script") or args.get("text", "") + return browser_service.eval, {"script": script, "session_id": resolved_sid, "on_event": on_event} elif action == "scroll": return browser_service.scroll, { "delta_x": int(args.get("delta_x", 0)), diff --git a/ai-hub/app/protos/browser.proto b/ai-hub/app/protos/browser.proto index fcf7944..335fc68 100644 --- a/ai-hub/app/protos/browser.proto +++ b/ai-hub/app/protos/browser.proto @@ -80,6 +80,8 @@ string content_markdown = 3; bool success = 4; string error = 5; + // Indicates whether the result was obtained from a fast static fetch or from a JS-rendered browser fetch. + string fetch_mode = 6; } repeated FetchResult results = 1; } diff --git a/ai-hub/app/protos/browser_pb2.py b/ai-hub/app/protos/browser_pb2.py index 2df601c..64c55f0 100644 --- a/ai-hub/app/protos/browser_pb2.py +++ b/ai-hub/app/protos/browser_pb2.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! -# source: app/protos/browser.proto +# source: protos/browser.proto # Protobuf Python Version: 4.25.1 """Generated protocol buffer code.""" from google.protobuf import descriptor as _descriptor @@ -14,39 +14,39 @@ -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x18\x61pp/protos/browser.proto\x12\x07\x62rowser\"K\n\x0fNavigateRequest\x12\x0b\n\x03url\x18\x01 \x01(\t\x12\x12\n\nsession_id\x18\x02 \x01(\t\x12\x17\n\x0fwait_until_idle\x18\x03 \x01(\x08\"J\n\x0c\x43lickRequest\x12\x10\n\x08selector\x18\x01 \x01(\t\x12\x12\n\nsession_id\x18\x02 \x01(\t\x12\t\n\x01x\x18\x03 \x01(\x05\x12\t\n\x01y\x18\x04 \x01(\x05\"V\n\x0bTypeRequest\x12\x10\n\x08selector\x18\x01 \x01(\t\x12\x0c\n\x04text\x18\x02 \x01(\t\x12\x12\n\nsession_id\x18\x03 \x01(\t\x12\x13\n\x0bpress_enter\x18\x04 \x01(\x08\"4\n\x0cHoverRequest\x12\x10\n\x08selector\x18\x01 \x01(\t\x12\x12\n\nsession_id\x18\x02 \x01(\t\"W\n\rScrollRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x0f\n\x07\x64\x65lta_x\x18\x02 \x01(\x05\x12\x0f\n\x07\x64\x65lta_y\x18\x03 \x01(\x05\x12\x10\n\x08selector\x18\x04 \x01(\t\"1\n\x0b\x45valRequest\x12\x0e\n\x06script\x18\x01 \x01(\t\x12\x12\n\nsession_id\x18\x02 \x01(\t\"l\n\x0fSnapshotRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x1a\n\x12include_screenshot\x18\x02 \x01(\x08\x12\x13\n\x0binclude_dom\x18\x03 \x01(\x08\x12\x14\n\x0cinclude_a11y\x18\x04 \x01(\x08\"\"\n\x0c\x43loseRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\" \n\rCloseResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\"j\n\x14ParallelFetchRequest\x12\x0c\n\x04urls\x18\x01 \x03(\t\x12\x12\n\nsession_id\x18\x02 \x01(\t\x12\x16\n\x0emax_concurrent\x18\x03 \x01(\x05\x12\x18\n\x10\x65xtract_markdown\x18\x04 \x01(\x08\"\xb9\x01\n\x15ParallelFetchResponse\x12;\n\x07results\x18\x01 \x03(\x0b\x32*.browser.ParallelFetchResponse.FetchResult\x1a\x63\n\x0b\x46\x65tchResult\x12\x0b\n\x03url\x18\x01 \x01(\t\x12\r\n\x05title\x18\x02 \x01(\t\x12\x18\n\x10\x63ontent_markdown\x18\x03 \x01(\t\x12\x0f\n\x07success\x18\x04 \x01(\x08\x12\r\n\x05\x65rror\x18\x05 \x01(\t\"\xbb\x01\n\x0f\x42rowserResponse\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x0b\n\x03url\x18\x02 \x01(\t\x12\r\n\x05title\x18\x03 \x01(\t\x12\x0e\n\x06status\x18\x04 \x01(\t\x12\x15\n\rerror_message\x18\x05 \x01(\t\x12\x10\n\x08\x64om_path\x18\x06 \x01(\t\x12\x17\n\x0fscreenshot_path\x18\x07 \x01(\t\x12\x11\n\ta11y_path\x18\x08 \x01(\t\x12\x13\n\x0b\x65val_result\x18\t \x01(\t2\xc6\x04\n\x0e\x42rowserService\x12>\n\x08Navigate\x12\x18.browser.NavigateRequest\x1a\x18.browser.BrowserResponse\x12\x38\n\x05\x43lick\x12\x15.browser.ClickRequest\x1a\x18.browser.BrowserResponse\x12\x36\n\x04Type\x12\x14.browser.TypeRequest\x1a\x18.browser.BrowserResponse\x12\x38\n\x05Hover\x12\x15.browser.HoverRequest\x1a\x18.browser.BrowserResponse\x12:\n\x06Scroll\x12\x16.browser.ScrollRequest\x1a\x18.browser.BrowserResponse\x12:\n\x08\x45valuate\x12\x14.browser.EvalRequest\x1a\x18.browser.BrowserResponse\x12\x41\n\x0bGetSnapshot\x12\x18.browser.SnapshotRequest\x1a\x18.browser.BrowserResponse\x12=\n\x0c\x43loseSession\x12\x15.browser.CloseRequest\x1a\x16.browser.CloseResponse\x12N\n\rParallelFetch\x12\x1d.browser.ParallelFetchRequest\x1a\x1e.browser.ParallelFetchResponseb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x14protos/browser.proto\x12\x07\x62rowser\"K\n\x0fNavigateRequest\x12\x0b\n\x03url\x18\x01 \x01(\t\x12\x12\n\nsession_id\x18\x02 \x01(\t\x12\x17\n\x0fwait_until_idle\x18\x03 \x01(\x08\"J\n\x0c\x43lickRequest\x12\x10\n\x08selector\x18\x01 \x01(\t\x12\x12\n\nsession_id\x18\x02 \x01(\t\x12\t\n\x01x\x18\x03 \x01(\x05\x12\t\n\x01y\x18\x04 \x01(\x05\"V\n\x0bTypeRequest\x12\x10\n\x08selector\x18\x01 \x01(\t\x12\x0c\n\x04text\x18\x02 \x01(\t\x12\x12\n\nsession_id\x18\x03 \x01(\t\x12\x13\n\x0bpress_enter\x18\x04 \x01(\x08\"4\n\x0cHoverRequest\x12\x10\n\x08selector\x18\x01 \x01(\t\x12\x12\n\nsession_id\x18\x02 \x01(\t\"W\n\rScrollRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x0f\n\x07\x64\x65lta_x\x18\x02 \x01(\x05\x12\x0f\n\x07\x64\x65lta_y\x18\x03 \x01(\x05\x12\x10\n\x08selector\x18\x04 \x01(\t\"1\n\x0b\x45valRequest\x12\x0e\n\x06script\x18\x01 \x01(\t\x12\x12\n\nsession_id\x18\x02 \x01(\t\"l\n\x0fSnapshotRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x1a\n\x12include_screenshot\x18\x02 \x01(\x08\x12\x13\n\x0binclude_dom\x18\x03 \x01(\x08\x12\x14\n\x0cinclude_a11y\x18\x04 \x01(\x08\"\"\n\x0c\x43loseRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\" \n\rCloseResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\"j\n\x14ParallelFetchRequest\x12\x0c\n\x04urls\x18\x01 \x03(\t\x12\x12\n\nsession_id\x18\x02 \x01(\t\x12\x16\n\x0emax_concurrent\x18\x03 \x01(\x05\x12\x18\n\x10\x65xtract_markdown\x18\x04 \x01(\x08\"\xcd\x01\n\x15ParallelFetchResponse\x12;\n\x07results\x18\x01 \x03(\x0b\x32*.browser.ParallelFetchResponse.FetchResult\x1aw\n\x0b\x46\x65tchResult\x12\x0b\n\x03url\x18\x01 \x01(\t\x12\r\n\x05title\x18\x02 \x01(\t\x12\x18\n\x10\x63ontent_markdown\x18\x03 \x01(\t\x12\x0f\n\x07success\x18\x04 \x01(\x08\x12\r\n\x05\x65rror\x18\x05 \x01(\t\x12\x12\n\nfetch_mode\x18\x06 \x01(\t\"\xbb\x01\n\x0f\x42rowserResponse\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x0b\n\x03url\x18\x02 \x01(\t\x12\r\n\x05title\x18\x03 \x01(\t\x12\x0e\n\x06status\x18\x04 \x01(\t\x12\x15\n\rerror_message\x18\x05 \x01(\t\x12\x10\n\x08\x64om_path\x18\x06 \x01(\t\x12\x17\n\x0fscreenshot_path\x18\x07 \x01(\t\x12\x11\n\ta11y_path\x18\x08 \x01(\t\x12\x13\n\x0b\x65val_result\x18\t \x01(\t2\xc6\x04\n\x0e\x42rowserService\x12>\n\x08Navigate\x12\x18.browser.NavigateRequest\x1a\x18.browser.BrowserResponse\x12\x38\n\x05\x43lick\x12\x15.browser.ClickRequest\x1a\x18.browser.BrowserResponse\x12\x36\n\x04Type\x12\x14.browser.TypeRequest\x1a\x18.browser.BrowserResponse\x12\x38\n\x05Hover\x12\x15.browser.HoverRequest\x1a\x18.browser.BrowserResponse\x12:\n\x06Scroll\x12\x16.browser.ScrollRequest\x1a\x18.browser.BrowserResponse\x12:\n\x08\x45valuate\x12\x14.browser.EvalRequest\x1a\x18.browser.BrowserResponse\x12\x41\n\x0bGetSnapshot\x12\x18.browser.SnapshotRequest\x1a\x18.browser.BrowserResponse\x12=\n\x0c\x43loseSession\x12\x15.browser.CloseRequest\x1a\x16.browser.CloseResponse\x12N\n\rParallelFetch\x12\x1d.browser.ParallelFetchRequest\x1a\x1e.browser.ParallelFetchResponseb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) -_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'app.protos.browser_pb2', _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'protos.browser_pb2', _globals) if _descriptor._USE_C_DESCRIPTORS == False: DESCRIPTOR._options = None - _globals['_NAVIGATEREQUEST']._serialized_start=37 - _globals['_NAVIGATEREQUEST']._serialized_end=112 - _globals['_CLICKREQUEST']._serialized_start=114 - _globals['_CLICKREQUEST']._serialized_end=188 - _globals['_TYPEREQUEST']._serialized_start=190 - _globals['_TYPEREQUEST']._serialized_end=276 - _globals['_HOVERREQUEST']._serialized_start=278 - _globals['_HOVERREQUEST']._serialized_end=330 - _globals['_SCROLLREQUEST']._serialized_start=332 - _globals['_SCROLLREQUEST']._serialized_end=419 - _globals['_EVALREQUEST']._serialized_start=421 - _globals['_EVALREQUEST']._serialized_end=470 - _globals['_SNAPSHOTREQUEST']._serialized_start=472 - _globals['_SNAPSHOTREQUEST']._serialized_end=580 - _globals['_CLOSEREQUEST']._serialized_start=582 - _globals['_CLOSEREQUEST']._serialized_end=616 - _globals['_CLOSERESPONSE']._serialized_start=618 - _globals['_CLOSERESPONSE']._serialized_end=650 - _globals['_PARALLELFETCHREQUEST']._serialized_start=652 - _globals['_PARALLELFETCHREQUEST']._serialized_end=758 - _globals['_PARALLELFETCHRESPONSE']._serialized_start=761 - _globals['_PARALLELFETCHRESPONSE']._serialized_end=946 - _globals['_PARALLELFETCHRESPONSE_FETCHRESULT']._serialized_start=847 - _globals['_PARALLELFETCHRESPONSE_FETCHRESULT']._serialized_end=946 - _globals['_BROWSERRESPONSE']._serialized_start=949 - _globals['_BROWSERRESPONSE']._serialized_end=1136 - _globals['_BROWSERSERVICE']._serialized_start=1139 - _globals['_BROWSERSERVICE']._serialized_end=1721 + _globals['_NAVIGATEREQUEST']._serialized_start=33 + _globals['_NAVIGATEREQUEST']._serialized_end=108 + _globals['_CLICKREQUEST']._serialized_start=110 + _globals['_CLICKREQUEST']._serialized_end=184 + _globals['_TYPEREQUEST']._serialized_start=186 + _globals['_TYPEREQUEST']._serialized_end=272 + _globals['_HOVERREQUEST']._serialized_start=274 + _globals['_HOVERREQUEST']._serialized_end=326 + _globals['_SCROLLREQUEST']._serialized_start=328 + _globals['_SCROLLREQUEST']._serialized_end=415 + _globals['_EVALREQUEST']._serialized_start=417 + _globals['_EVALREQUEST']._serialized_end=466 + _globals['_SNAPSHOTREQUEST']._serialized_start=468 + _globals['_SNAPSHOTREQUEST']._serialized_end=576 + _globals['_CLOSEREQUEST']._serialized_start=578 + _globals['_CLOSEREQUEST']._serialized_end=612 + _globals['_CLOSERESPONSE']._serialized_start=614 + _globals['_CLOSERESPONSE']._serialized_end=646 + _globals['_PARALLELFETCHREQUEST']._serialized_start=648 + _globals['_PARALLELFETCHREQUEST']._serialized_end=754 + _globals['_PARALLELFETCHRESPONSE']._serialized_start=757 + _globals['_PARALLELFETCHRESPONSE']._serialized_end=962 + _globals['_PARALLELFETCHRESPONSE_FETCHRESULT']._serialized_start=843 + _globals['_PARALLELFETCHRESPONSE_FETCHRESULT']._serialized_end=962 + _globals['_BROWSERRESPONSE']._serialized_start=965 + _globals['_BROWSERRESPONSE']._serialized_end=1152 + _globals['_BROWSERSERVICE']._serialized_start=1155 + _globals['_BROWSERSERVICE']._serialized_end=1737 # @@protoc_insertion_point(module_scope) diff --git a/ai-hub/app/protos/browser_pb2_grpc.py b/ai-hub/app/protos/browser_pb2_grpc.py index f12d5c1..c69dabb 100644 --- a/ai-hub/app/protos/browser_pb2_grpc.py +++ b/ai-hub/app/protos/browser_pb2_grpc.py @@ -2,7 +2,7 @@ """Client and server classes corresponding to protobuf-defined services.""" import grpc -from app.protos import browser_pb2 as app_dot_protos_dot_browser__pb2 +from protos import browser_pb2 as protos_dot_browser__pb2 class BrowserServiceStub(object): @@ -16,48 +16,48 @@ """ self.Navigate = channel.unary_unary( '/browser.BrowserService/Navigate', - request_serializer=app_dot_protos_dot_browser__pb2.NavigateRequest.SerializeToString, - response_deserializer=app_dot_protos_dot_browser__pb2.BrowserResponse.FromString, + request_serializer=protos_dot_browser__pb2.NavigateRequest.SerializeToString, + response_deserializer=protos_dot_browser__pb2.BrowserResponse.FromString, ) self.Click = channel.unary_unary( '/browser.BrowserService/Click', - request_serializer=app_dot_protos_dot_browser__pb2.ClickRequest.SerializeToString, - response_deserializer=app_dot_protos_dot_browser__pb2.BrowserResponse.FromString, + request_serializer=protos_dot_browser__pb2.ClickRequest.SerializeToString, + response_deserializer=protos_dot_browser__pb2.BrowserResponse.FromString, ) self.Type = channel.unary_unary( '/browser.BrowserService/Type', - request_serializer=app_dot_protos_dot_browser__pb2.TypeRequest.SerializeToString, - response_deserializer=app_dot_protos_dot_browser__pb2.BrowserResponse.FromString, + request_serializer=protos_dot_browser__pb2.TypeRequest.SerializeToString, + response_deserializer=protos_dot_browser__pb2.BrowserResponse.FromString, ) self.Hover = channel.unary_unary( '/browser.BrowserService/Hover', - request_serializer=app_dot_protos_dot_browser__pb2.HoverRequest.SerializeToString, - response_deserializer=app_dot_protos_dot_browser__pb2.BrowserResponse.FromString, + request_serializer=protos_dot_browser__pb2.HoverRequest.SerializeToString, + response_deserializer=protos_dot_browser__pb2.BrowserResponse.FromString, ) self.Scroll = channel.unary_unary( '/browser.BrowserService/Scroll', - request_serializer=app_dot_protos_dot_browser__pb2.ScrollRequest.SerializeToString, - response_deserializer=app_dot_protos_dot_browser__pb2.BrowserResponse.FromString, + request_serializer=protos_dot_browser__pb2.ScrollRequest.SerializeToString, + response_deserializer=protos_dot_browser__pb2.BrowserResponse.FromString, ) self.Evaluate = channel.unary_unary( '/browser.BrowserService/Evaluate', - request_serializer=app_dot_protos_dot_browser__pb2.EvalRequest.SerializeToString, - response_deserializer=app_dot_protos_dot_browser__pb2.BrowserResponse.FromString, + request_serializer=protos_dot_browser__pb2.EvalRequest.SerializeToString, + response_deserializer=protos_dot_browser__pb2.BrowserResponse.FromString, ) self.GetSnapshot = channel.unary_unary( '/browser.BrowserService/GetSnapshot', - request_serializer=app_dot_protos_dot_browser__pb2.SnapshotRequest.SerializeToString, - response_deserializer=app_dot_protos_dot_browser__pb2.BrowserResponse.FromString, + request_serializer=protos_dot_browser__pb2.SnapshotRequest.SerializeToString, + response_deserializer=protos_dot_browser__pb2.BrowserResponse.FromString, ) self.CloseSession = channel.unary_unary( '/browser.BrowserService/CloseSession', - request_serializer=app_dot_protos_dot_browser__pb2.CloseRequest.SerializeToString, - response_deserializer=app_dot_protos_dot_browser__pb2.CloseResponse.FromString, + request_serializer=protos_dot_browser__pb2.CloseRequest.SerializeToString, + response_deserializer=protos_dot_browser__pb2.CloseResponse.FromString, ) self.ParallelFetch = channel.unary_unary( '/browser.BrowserService/ParallelFetch', - request_serializer=app_dot_protos_dot_browser__pb2.ParallelFetchRequest.SerializeToString, - response_deserializer=app_dot_protos_dot_browser__pb2.ParallelFetchResponse.FromString, + request_serializer=protos_dot_browser__pb2.ParallelFetchRequest.SerializeToString, + response_deserializer=protos_dot_browser__pb2.ParallelFetchResponse.FromString, ) @@ -123,48 +123,48 @@ rpc_method_handlers = { 'Navigate': grpc.unary_unary_rpc_method_handler( servicer.Navigate, - request_deserializer=app_dot_protos_dot_browser__pb2.NavigateRequest.FromString, - response_serializer=app_dot_protos_dot_browser__pb2.BrowserResponse.SerializeToString, + request_deserializer=protos_dot_browser__pb2.NavigateRequest.FromString, + response_serializer=protos_dot_browser__pb2.BrowserResponse.SerializeToString, ), 'Click': grpc.unary_unary_rpc_method_handler( servicer.Click, - request_deserializer=app_dot_protos_dot_browser__pb2.ClickRequest.FromString, - response_serializer=app_dot_protos_dot_browser__pb2.BrowserResponse.SerializeToString, + request_deserializer=protos_dot_browser__pb2.ClickRequest.FromString, + response_serializer=protos_dot_browser__pb2.BrowserResponse.SerializeToString, ), 'Type': grpc.unary_unary_rpc_method_handler( servicer.Type, - request_deserializer=app_dot_protos_dot_browser__pb2.TypeRequest.FromString, - response_serializer=app_dot_protos_dot_browser__pb2.BrowserResponse.SerializeToString, + request_deserializer=protos_dot_browser__pb2.TypeRequest.FromString, + response_serializer=protos_dot_browser__pb2.BrowserResponse.SerializeToString, ), 'Hover': grpc.unary_unary_rpc_method_handler( servicer.Hover, - request_deserializer=app_dot_protos_dot_browser__pb2.HoverRequest.FromString, - response_serializer=app_dot_protos_dot_browser__pb2.BrowserResponse.SerializeToString, + request_deserializer=protos_dot_browser__pb2.HoverRequest.FromString, + response_serializer=protos_dot_browser__pb2.BrowserResponse.SerializeToString, ), 'Scroll': grpc.unary_unary_rpc_method_handler( servicer.Scroll, - request_deserializer=app_dot_protos_dot_browser__pb2.ScrollRequest.FromString, - response_serializer=app_dot_protos_dot_browser__pb2.BrowserResponse.SerializeToString, + request_deserializer=protos_dot_browser__pb2.ScrollRequest.FromString, + response_serializer=protos_dot_browser__pb2.BrowserResponse.SerializeToString, ), 'Evaluate': grpc.unary_unary_rpc_method_handler( servicer.Evaluate, - request_deserializer=app_dot_protos_dot_browser__pb2.EvalRequest.FromString, - response_serializer=app_dot_protos_dot_browser__pb2.BrowserResponse.SerializeToString, + request_deserializer=protos_dot_browser__pb2.EvalRequest.FromString, + response_serializer=protos_dot_browser__pb2.BrowserResponse.SerializeToString, ), 'GetSnapshot': grpc.unary_unary_rpc_method_handler( servicer.GetSnapshot, - request_deserializer=app_dot_protos_dot_browser__pb2.SnapshotRequest.FromString, - response_serializer=app_dot_protos_dot_browser__pb2.BrowserResponse.SerializeToString, + request_deserializer=protos_dot_browser__pb2.SnapshotRequest.FromString, + response_serializer=protos_dot_browser__pb2.BrowserResponse.SerializeToString, ), 'CloseSession': grpc.unary_unary_rpc_method_handler( servicer.CloseSession, - request_deserializer=app_dot_protos_dot_browser__pb2.CloseRequest.FromString, - response_serializer=app_dot_protos_dot_browser__pb2.CloseResponse.SerializeToString, + request_deserializer=protos_dot_browser__pb2.CloseRequest.FromString, + response_serializer=protos_dot_browser__pb2.CloseResponse.SerializeToString, ), 'ParallelFetch': grpc.unary_unary_rpc_method_handler( servicer.ParallelFetch, - request_deserializer=app_dot_protos_dot_browser__pb2.ParallelFetchRequest.FromString, - response_serializer=app_dot_protos_dot_browser__pb2.ParallelFetchResponse.SerializeToString, + request_deserializer=protos_dot_browser__pb2.ParallelFetchRequest.FromString, + response_serializer=protos_dot_browser__pb2.ParallelFetchResponse.SerializeToString, ), } generic_handler = grpc.method_handlers_generic_handler( @@ -188,8 +188,8 @@ timeout=None, metadata=None): return grpc.experimental.unary_unary(request, target, '/browser.BrowserService/Navigate', - app_dot_protos_dot_browser__pb2.NavigateRequest.SerializeToString, - app_dot_protos_dot_browser__pb2.BrowserResponse.FromString, + protos_dot_browser__pb2.NavigateRequest.SerializeToString, + protos_dot_browser__pb2.BrowserResponse.FromString, options, channel_credentials, insecure, call_credentials, compression, wait_for_ready, timeout, metadata) @@ -205,8 +205,8 @@ timeout=None, metadata=None): return grpc.experimental.unary_unary(request, target, '/browser.BrowserService/Click', - app_dot_protos_dot_browser__pb2.ClickRequest.SerializeToString, - app_dot_protos_dot_browser__pb2.BrowserResponse.FromString, + protos_dot_browser__pb2.ClickRequest.SerializeToString, + protos_dot_browser__pb2.BrowserResponse.FromString, options, channel_credentials, insecure, call_credentials, compression, wait_for_ready, timeout, metadata) @@ -222,8 +222,8 @@ timeout=None, metadata=None): return grpc.experimental.unary_unary(request, target, '/browser.BrowserService/Type', - app_dot_protos_dot_browser__pb2.TypeRequest.SerializeToString, - app_dot_protos_dot_browser__pb2.BrowserResponse.FromString, + protos_dot_browser__pb2.TypeRequest.SerializeToString, + protos_dot_browser__pb2.BrowserResponse.FromString, options, channel_credentials, insecure, call_credentials, compression, wait_for_ready, timeout, metadata) @@ -239,8 +239,8 @@ timeout=None, metadata=None): return grpc.experimental.unary_unary(request, target, '/browser.BrowserService/Hover', - app_dot_protos_dot_browser__pb2.HoverRequest.SerializeToString, - app_dot_protos_dot_browser__pb2.BrowserResponse.FromString, + protos_dot_browser__pb2.HoverRequest.SerializeToString, + protos_dot_browser__pb2.BrowserResponse.FromString, options, channel_credentials, insecure, call_credentials, compression, wait_for_ready, timeout, metadata) @@ -256,8 +256,8 @@ timeout=None, metadata=None): return grpc.experimental.unary_unary(request, target, '/browser.BrowserService/Scroll', - app_dot_protos_dot_browser__pb2.ScrollRequest.SerializeToString, - app_dot_protos_dot_browser__pb2.BrowserResponse.FromString, + protos_dot_browser__pb2.ScrollRequest.SerializeToString, + protos_dot_browser__pb2.BrowserResponse.FromString, options, channel_credentials, insecure, call_credentials, compression, wait_for_ready, timeout, metadata) @@ -273,8 +273,8 @@ timeout=None, metadata=None): return grpc.experimental.unary_unary(request, target, '/browser.BrowserService/Evaluate', - app_dot_protos_dot_browser__pb2.EvalRequest.SerializeToString, - app_dot_protos_dot_browser__pb2.BrowserResponse.FromString, + protos_dot_browser__pb2.EvalRequest.SerializeToString, + protos_dot_browser__pb2.BrowserResponse.FromString, options, channel_credentials, insecure, call_credentials, compression, wait_for_ready, timeout, metadata) @@ -290,8 +290,8 @@ timeout=None, metadata=None): return grpc.experimental.unary_unary(request, target, '/browser.BrowserService/GetSnapshot', - app_dot_protos_dot_browser__pb2.SnapshotRequest.SerializeToString, - app_dot_protos_dot_browser__pb2.BrowserResponse.FromString, + protos_dot_browser__pb2.SnapshotRequest.SerializeToString, + protos_dot_browser__pb2.BrowserResponse.FromString, options, channel_credentials, insecure, call_credentials, compression, wait_for_ready, timeout, metadata) @@ -307,8 +307,8 @@ timeout=None, metadata=None): return grpc.experimental.unary_unary(request, target, '/browser.BrowserService/CloseSession', - app_dot_protos_dot_browser__pb2.CloseRequest.SerializeToString, - app_dot_protos_dot_browser__pb2.CloseResponse.FromString, + protos_dot_browser__pb2.CloseRequest.SerializeToString, + protos_dot_browser__pb2.CloseResponse.FromString, options, channel_credentials, insecure, call_credentials, compression, wait_for_ready, timeout, metadata) @@ -324,7 +324,7 @@ timeout=None, metadata=None): return grpc.experimental.unary_unary(request, target, '/browser.BrowserService/ParallelFetch', - app_dot_protos_dot_browser__pb2.ParallelFetchRequest.SerializeToString, - app_dot_protos_dot_browser__pb2.ParallelFetchResponse.FromString, + protos_dot_browser__pb2.ParallelFetchRequest.SerializeToString, + protos_dot_browser__pb2.ParallelFetchResponse.FromString, options, channel_credentials, insecure, call_credentials, compression, wait_for_ready, timeout, metadata) diff --git a/browser-service/Dockerfile b/browser-service/Dockerfile index c829728..31c73c7 100644 --- a/browser-service/Dockerfile +++ b/browser-service/Dockerfile @@ -3,7 +3,7 @@ WORKDIR /app # Install gRPC and dependencies -RUN pip install --no-cache-dir grpcio grpcio-tools playwright playwright-stealth beautifulsoup4 +RUN pip install --no-cache-dir grpcio grpcio-tools playwright playwright-stealth beautifulsoup4 aiohttp # Copy only the necessary files COPY main.py . diff --git a/browser-service/protos/browser.proto b/browser-service/protos/browser.proto index fcf7944..335fc68 100644 --- a/browser-service/protos/browser.proto +++ b/browser-service/protos/browser.proto @@ -80,6 +80,8 @@ string content_markdown = 3; bool success = 4; string error = 5; + // Indicates whether the result was obtained from a fast static fetch or from a JS-rendered browser fetch. + string fetch_mode = 6; } repeated FetchResult results = 1; } diff --git a/browser-service/protos/browser_pb2.py b/browser-service/protos/browser_pb2.py index e75d707..64c55f0 100644 --- a/browser-service/protos/browser_pb2.py +++ b/browser-service/protos/browser_pb2.py @@ -14,7 +14,7 @@ -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x14protos/browser.proto\x12\x07\x62rowser\"K\n\x0fNavigateRequest\x12\x0b\n\x03url\x18\x01 \x01(\t\x12\x12\n\nsession_id\x18\x02 \x01(\t\x12\x17\n\x0fwait_until_idle\x18\x03 \x01(\x08\"J\n\x0c\x43lickRequest\x12\x10\n\x08selector\x18\x01 \x01(\t\x12\x12\n\nsession_id\x18\x02 \x01(\t\x12\t\n\x01x\x18\x03 \x01(\x05\x12\t\n\x01y\x18\x04 \x01(\x05\"V\n\x0bTypeRequest\x12\x10\n\x08selector\x18\x01 \x01(\t\x12\x0c\n\x04text\x18\x02 \x01(\t\x12\x12\n\nsession_id\x18\x03 \x01(\t\x12\x13\n\x0bpress_enter\x18\x04 \x01(\x08\"4\n\x0cHoverRequest\x12\x10\n\x08selector\x18\x01 \x01(\t\x12\x12\n\nsession_id\x18\x02 \x01(\t\"W\n\rScrollRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x0f\n\x07\x64\x65lta_x\x18\x02 \x01(\x05\x12\x0f\n\x07\x64\x65lta_y\x18\x03 \x01(\x05\x12\x10\n\x08selector\x18\x04 \x01(\t\"1\n\x0b\x45valRequest\x12\x0e\n\x06script\x18\x01 \x01(\t\x12\x12\n\nsession_id\x18\x02 \x01(\t\"l\n\x0fSnapshotRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x1a\n\x12include_screenshot\x18\x02 \x01(\x08\x12\x13\n\x0binclude_dom\x18\x03 \x01(\x08\x12\x14\n\x0cinclude_a11y\x18\x04 \x01(\x08\"\"\n\x0c\x43loseRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\" \n\rCloseResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\"j\n\x14ParallelFetchRequest\x12\x0c\n\x04urls\x18\x01 \x03(\t\x12\x12\n\nsession_id\x18\x02 \x01(\t\x12\x16\n\x0emax_concurrent\x18\x03 \x01(\x05\x12\x18\n\x10\x65xtract_markdown\x18\x04 \x01(\x08\"\xb9\x01\n\x15ParallelFetchResponse\x12;\n\x07results\x18\x01 \x03(\x0b\x32*.browser.ParallelFetchResponse.FetchResult\x1a\x63\n\x0b\x46\x65tchResult\x12\x0b\n\x03url\x18\x01 \x01(\t\x12\r\n\x05title\x18\x02 \x01(\t\x12\x18\n\x10\x63ontent_markdown\x18\x03 \x01(\t\x12\x0f\n\x07success\x18\x04 \x01(\x08\x12\r\n\x05\x65rror\x18\x05 \x01(\t\"\xbb\x01\n\x0f\x42rowserResponse\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x0b\n\x03url\x18\x02 \x01(\t\x12\r\n\x05title\x18\x03 \x01(\t\x12\x0e\n\x06status\x18\x04 \x01(\t\x12\x15\n\rerror_message\x18\x05 \x01(\t\x12\x10\n\x08\x64om_path\x18\x06 \x01(\t\x12\x17\n\x0fscreenshot_path\x18\x07 \x01(\t\x12\x11\n\ta11y_path\x18\x08 \x01(\t\x12\x13\n\x0b\x65val_result\x18\t \x01(\t2\xc6\x04\n\x0e\x42rowserService\x12>\n\x08Navigate\x12\x18.browser.NavigateRequest\x1a\x18.browser.BrowserResponse\x12\x38\n\x05\x43lick\x12\x15.browser.ClickRequest\x1a\x18.browser.BrowserResponse\x12\x36\n\x04Type\x12\x14.browser.TypeRequest\x1a\x18.browser.BrowserResponse\x12\x38\n\x05Hover\x12\x15.browser.HoverRequest\x1a\x18.browser.BrowserResponse\x12:\n\x06Scroll\x12\x16.browser.ScrollRequest\x1a\x18.browser.BrowserResponse\x12:\n\x08\x45valuate\x12\x14.browser.EvalRequest\x1a\x18.browser.BrowserResponse\x12\x41\n\x0bGetSnapshot\x12\x18.browser.SnapshotRequest\x1a\x18.browser.BrowserResponse\x12=\n\x0c\x43loseSession\x12\x15.browser.CloseRequest\x1a\x16.browser.CloseResponse\x12N\n\rParallelFetch\x12\x1d.browser.ParallelFetchRequest\x1a\x1e.browser.ParallelFetchResponseb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x14protos/browser.proto\x12\x07\x62rowser\"K\n\x0fNavigateRequest\x12\x0b\n\x03url\x18\x01 \x01(\t\x12\x12\n\nsession_id\x18\x02 \x01(\t\x12\x17\n\x0fwait_until_idle\x18\x03 \x01(\x08\"J\n\x0c\x43lickRequest\x12\x10\n\x08selector\x18\x01 \x01(\t\x12\x12\n\nsession_id\x18\x02 \x01(\t\x12\t\n\x01x\x18\x03 \x01(\x05\x12\t\n\x01y\x18\x04 \x01(\x05\"V\n\x0bTypeRequest\x12\x10\n\x08selector\x18\x01 \x01(\t\x12\x0c\n\x04text\x18\x02 \x01(\t\x12\x12\n\nsession_id\x18\x03 \x01(\t\x12\x13\n\x0bpress_enter\x18\x04 \x01(\x08\"4\n\x0cHoverRequest\x12\x10\n\x08selector\x18\x01 \x01(\t\x12\x12\n\nsession_id\x18\x02 \x01(\t\"W\n\rScrollRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x0f\n\x07\x64\x65lta_x\x18\x02 \x01(\x05\x12\x0f\n\x07\x64\x65lta_y\x18\x03 \x01(\x05\x12\x10\n\x08selector\x18\x04 \x01(\t\"1\n\x0b\x45valRequest\x12\x0e\n\x06script\x18\x01 \x01(\t\x12\x12\n\nsession_id\x18\x02 \x01(\t\"l\n\x0fSnapshotRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x1a\n\x12include_screenshot\x18\x02 \x01(\x08\x12\x13\n\x0binclude_dom\x18\x03 \x01(\x08\x12\x14\n\x0cinclude_a11y\x18\x04 \x01(\x08\"\"\n\x0c\x43loseRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\" \n\rCloseResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\"j\n\x14ParallelFetchRequest\x12\x0c\n\x04urls\x18\x01 \x03(\t\x12\x12\n\nsession_id\x18\x02 \x01(\t\x12\x16\n\x0emax_concurrent\x18\x03 \x01(\x05\x12\x18\n\x10\x65xtract_markdown\x18\x04 \x01(\x08\"\xcd\x01\n\x15ParallelFetchResponse\x12;\n\x07results\x18\x01 \x03(\x0b\x32*.browser.ParallelFetchResponse.FetchResult\x1aw\n\x0b\x46\x65tchResult\x12\x0b\n\x03url\x18\x01 \x01(\t\x12\r\n\x05title\x18\x02 \x01(\t\x12\x18\n\x10\x63ontent_markdown\x18\x03 \x01(\t\x12\x0f\n\x07success\x18\x04 \x01(\x08\x12\r\n\x05\x65rror\x18\x05 \x01(\t\x12\x12\n\nfetch_mode\x18\x06 \x01(\t\"\xbb\x01\n\x0f\x42rowserResponse\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x0b\n\x03url\x18\x02 \x01(\t\x12\r\n\x05title\x18\x03 \x01(\t\x12\x0e\n\x06status\x18\x04 \x01(\t\x12\x15\n\rerror_message\x18\x05 \x01(\t\x12\x10\n\x08\x64om_path\x18\x06 \x01(\t\x12\x17\n\x0fscreenshot_path\x18\x07 \x01(\t\x12\x11\n\ta11y_path\x18\x08 \x01(\t\x12\x13\n\x0b\x65val_result\x18\t \x01(\t2\xc6\x04\n\x0e\x42rowserService\x12>\n\x08Navigate\x12\x18.browser.NavigateRequest\x1a\x18.browser.BrowserResponse\x12\x38\n\x05\x43lick\x12\x15.browser.ClickRequest\x1a\x18.browser.BrowserResponse\x12\x36\n\x04Type\x12\x14.browser.TypeRequest\x1a\x18.browser.BrowserResponse\x12\x38\n\x05Hover\x12\x15.browser.HoverRequest\x1a\x18.browser.BrowserResponse\x12:\n\x06Scroll\x12\x16.browser.ScrollRequest\x1a\x18.browser.BrowserResponse\x12:\n\x08\x45valuate\x12\x14.browser.EvalRequest\x1a\x18.browser.BrowserResponse\x12\x41\n\x0bGetSnapshot\x12\x18.browser.SnapshotRequest\x1a\x18.browser.BrowserResponse\x12=\n\x0c\x43loseSession\x12\x15.browser.CloseRequest\x1a\x16.browser.CloseResponse\x12N\n\rParallelFetch\x12\x1d.browser.ParallelFetchRequest\x1a\x1e.browser.ParallelFetchResponseb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -42,11 +42,11 @@ _globals['_PARALLELFETCHREQUEST']._serialized_start=648 _globals['_PARALLELFETCHREQUEST']._serialized_end=754 _globals['_PARALLELFETCHRESPONSE']._serialized_start=757 - _globals['_PARALLELFETCHRESPONSE']._serialized_end=942 + _globals['_PARALLELFETCHRESPONSE']._serialized_end=962 _globals['_PARALLELFETCHRESPONSE_FETCHRESULT']._serialized_start=843 - _globals['_PARALLELFETCHRESPONSE_FETCHRESULT']._serialized_end=942 - _globals['_BROWSERRESPONSE']._serialized_start=945 - _globals['_BROWSERRESPONSE']._serialized_end=1132 - _globals['_BROWSERSERVICE']._serialized_start=1135 - _globals['_BROWSERSERVICE']._serialized_end=1717 + _globals['_PARALLELFETCHRESPONSE_FETCHRESULT']._serialized_end=962 + _globals['_BROWSERRESPONSE']._serialized_start=965 + _globals['_BROWSERRESPONSE']._serialized_end=1152 + _globals['_BROWSERSERVICE']._serialized_start=1155 + _globals['_BROWSERSERVICE']._serialized_end=1737 # @@protoc_insertion_point(module_scope) diff --git a/browser-service/src/api/servicer.py b/browser-service/src/api/servicer.py index 9e65fd1..96ce5bd 100644 --- a/browser-service/src/api/servicer.py +++ b/browser-service/src/api/servicer.py @@ -119,7 +119,20 @@ try: page = await self.browser.get_page(session_id) result = await page.evaluate(request.script) - return await self.responses.build(page, session_id, eval_result=str(result)) + + # Common pitfall: the script may be an expression/object literal and not return a value. + # If Playwright returns None/undefined, try a secondary evaluation that forces a return. + if result is None: + try: + wrapped = f"(() => {{ return ({request.script}); }})()" + result = await page.evaluate(wrapped) + except Exception: + # Keep original result (None) if wrapping fails. + pass + + # Only stringify if result is not None/undefined to avoid passing "None" string to AI + eval_str = str(result) if result is not None else "" + return await self.responses.build(page, session_id, eval_result=eval_str) except Exception as e: if page: return await self.responses.build(page, session_id, status="error", error_message=str(e)) return browser_pb2.BrowserResponse(session_id=session_id, status="error", error_message=str(e)) @@ -252,7 +265,8 @@ title=r.get("title", ""), content_markdown=r.get("content_markdown", ""), success=r["success"], - error=r.get("error", "") + error=r.get("error", ""), + fetch_mode=r.get("fetch_mode", "") )) return browser_pb2.ParallelFetchResponse(results=proto_results) diff --git a/browser-service/src/core/browser.py b/browser-service/src/core/browser.py index 931b909..5b3330e 100644 --- a/browser-service/src/core/browser.py +++ b/browser-service/src/core/browser.py @@ -1,6 +1,11 @@ import logging import uuid import os +import re +import asyncio + +import aiohttp +from bs4 import BeautifulSoup from playwright.async_api import async_playwright # from playwright_stealth import stealth @@ -68,18 +73,121 @@ return page + async def _static_fetch(self, url, timeout=15, extract_markdown=True): + """Fast static fetch via HTTP + BeautifulSoup + markdown extraction. + + This is the fast path. If the result looks like a JS app shell, we + fall back to Playwright rendering. + """ + from src.extraction.markdown import MarkdownExtractor + + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" + } + + try: + timeout_obj = aiohttp.ClientTimeout(total=timeout) + async with aiohttp.ClientSession(timeout=timeout_obj, headers=headers) as session: + async with session.get(url, allow_redirects=True) as resp: + resp.raise_for_status() + html = await resp.text() + except Exception as e: + return {"url": url, "success": False, "error": str(e), "fetch_mode": "static"} + + title = "" + markdown = "" + try: + soup = BeautifulSoup(html, "html.parser") + title_tag = soup.find("title") + if title_tag: + title = title_tag.get_text(strip=True) + except Exception: + pass + + if extract_markdown: + try: + extractor = MarkdownExtractor() + markdown = extractor.extract(html) + except Exception: + markdown = "" + + return { + "url": url, + "success": True, + "html": html, + "title": title, + "content_markdown": markdown, + } + + def _needs_js_render(self, html: str, markdown: str, title: str) -> bool: + """Heuristic to determine whether HTML likely requires JS rendering.""" + if not html: + return True + + stripped_md = (markdown or "").strip() + if not stripped_md or stripped_md == "No readable content found.": + return True + + word_count = len(re.findall(r"\w+", stripped_md)) + if word_count < 40: + return True + + try: + soup = BeautifulSoup(html, "html.parser") + + # Typical JS app shell placeholder + root = soup.find(id=re.compile(r"^(app|root|__next|react-app|ember-app|gatsby-root)$", re.I)) + if root and len(root.get_text(strip=True)) < 20: + return True + + noscript = soup.find("noscript") + if noscript: + noscript_text = noscript.get_text(" ", strip=True) + if re.search(r"enable javascript|javascript is required|please enable javascript", noscript_text, re.I): + return True + except Exception: + pass + + # If page is script-heavy and content is light, assume JS needed + script_tags = re.findall(r"]", html, flags=re.I) + if len(script_tags) > 10 and word_count < 100: + return True + + return False + async def parallel_fetch(self, urls, max_concurrent=5, extract_markdown=True): """Fetches multiple URLs in parallel using a pool of pages.""" import asyncio - from src.extraction.markdown import MarkdownExtractor - + await self.ensure_browser() - extractor = MarkdownExtractor() semaphore = asyncio.Semaphore(max_concurrent) - + async def fetch_one(url): async with semaphore: logger.info(f"Worker fetching: {url}") + + # Fast static fetch path (no browser start) + static_result = await self._static_fetch(url, extract_markdown=extract_markdown) + if static_result.get("success"): + if not self._needs_js_render( + static_result.get("html", ""), + static_result.get("content_markdown", ""), + static_result.get("title", ""), + ): + logger.info(f"Static fetch sufficient for: {url} (fetch_mode=static)") + return { + "url": url, + "title": static_result.get("title", ""), + "content_markdown": static_result.get("content_markdown", ""), + "success": True, + "fetch_mode": "static", + } + else: + logger.info(f"Static fetch looked like JS shell, falling back to browser for: {url} (fetch_mode=js)") + else: + logger.info(f"Static fetch failed for {url}: {static_result.get('error')}. Falling back to browser.") + + # Fall back to Playwright rendering # Separate context for each fetch for isolation context = await self._browser.new_context( viewport={'width': 1280, 'height': 800}, @@ -88,26 +196,31 @@ page = await context.new_page() try: await page.goto(url, wait_until="domcontentloaded", timeout=20000) - await asyncio.sleep(1) # Wait for JS dynamic content + await asyncio.sleep(1) # Wait for JS dynamic content title = await page.title() - + content = "" if extract_markdown: html = await page.content() + # Keep existing extractor behavior + from src.extraction.markdown import MarkdownExtractor + extractor = MarkdownExtractor() content = extractor.extract(html) - + return { - "url": url, - "title": title, - "content_markdown": content, - "success": True + "url": url, + "title": title, + "content_markdown": content, + "success": True, + "fetch_mode": "js" } except Exception as e: - logger.warning(f"Failed to fetch {url}: {e}") + logger.warning(f"Failed to fetch {url}: {e} (fetch_mode=js)") return { - "url": url, - "success": False, - "error": str(e) + "url": url, + "success": False, + "error": str(e), + "fetch_mode": "js" } finally: await context.close() diff --git a/browser-service/src/extraction/scripts.py b/browser-service/src/extraction/scripts.py index f350b4f..bc79c7a 100644 --- a/browser-service/src/extraction/scripts.py +++ b/browser-service/src/extraction/scripts.py @@ -18,7 +18,7 @@ "treeitem", "textbox", "searchbox", "spinbutton", "combobox", "listbox", "slider" ]); const results = []; - + const isVisible = (el) => { if (!el.getClientRects().length) return false; const style = window.getComputedStyle(el); @@ -27,36 +27,81 @@ return true; }; - const walk = (node) => { - if (node.nodeType !== 1 || !isVisible(node)) return; + const getAriaLabelledBy = (el) => { + const ids = el.getAttribute('aria-labelledby'); + if (!ids) return ''; + return ids + .split(' ') + .map(id => { + const target = document.getElementById(id); + return target ? target.innerText : ''; + }) + .filter(Boolean) + .join(' '); + }; - const rect = node.getBoundingClientRect(); - if (rect.width < 2 || rect.height < 2) return; - - const style = window.getComputedStyle(node); + const isInteractiveNode = (node, style, role) => { const tagName = node.tagName; - const role = (node.getAttribute('role') || '').toLowerCase(); const isInput = ['INPUT', 'TEXTAREA', 'SELECT'].includes(tagName); const isButtonOrLink = tagName === 'BUTTON' || tagName === 'A' || style.cursor === 'pointer'; - const isInteractive = INTERACTIVE_ROLES.has(role) || isInput || isButtonOrLink; - - if (isInteractive) { - const innerText = node.innerText || ""; - const name = node.getAttribute('aria-label') || node.placeholder || innerText.substring(0, 50).trim() || node.value || tagName; - results.push({ - tagName: tagName, - role: role || tagName.toLowerCase(), - name: (name || "").substring(0, 100).trim(), - placeholder: node.placeholder || '', - rect: { - x: Math.round(rect.x), y: Math.round(rect.y), - width: Math.round(rect.width), height: Math.round(rect.height) - } - }); - } - - for (const child of node.children) walk(child); + const hasOnClick = typeof node.onclick === 'function' || node.getAttribute('onclick'); + const hasTabIndex = node.tabIndex >= 0; + const hasRole = role && role !== ''; + return ( + INTERACTIVE_ROLES.has(role) || + isInput || + isButtonOrLink || + hasOnClick || + hasTabIndex || + hasRole + ); }; + + const walk = (root) => { + const children = root.children || []; + for (const node of children) { + if (node.nodeType !== 1) continue; + if (!isVisible(node)) continue; + + const rect = node.getBoundingClientRect(); + if (rect.width < 2 || rect.height < 2) continue; + + const style = window.getComputedStyle(node); + const tagName = node.tagName; + const role = (node.getAttribute('role') || '').toLowerCase(); + const nameCandidates = [ + node.getAttribute('aria-label'), + getAriaLabelledBy(node), + node.placeholder, + node.value, + node.innerText + ]; + const name = (nameCandidates.find(x => x && x.toString().trim()) || tagName).toString().substring(0, 100).trim(); + + const interactive = isInteractiveNode(node, style, role); + if (interactive) { + results.push({ + tagName: tagName, + role: role || tagName.toLowerCase(), + name: name, + placeholder: node.placeholder || '', + rect: { + x: Math.round(rect.x), y: Math.round(rect.y), + width: Math.round(rect.width), height: Math.round(rect.height) + } + }); + } + + // Traverse shadow root if present + if (node.shadowRoot) { + walk(node.shadowRoot); + } + + // Continue traversal + walk(node); + } + }; + walk(document.body); return results; } diff --git a/check_assistant.py b/check_assistant.py deleted file mode 100644 index 961a5a1..0000000 --- a/check_assistant.py +++ /dev/null @@ -1,10 +0,0 @@ -import re - -with open("ai-hub/app/core/grpc/services/grpc_server.py", "r") as f: - grpc_server = f.read() - -with open("ai-hub/app/core/grpc/services/assistant.py", "r") as f: - assistant = f.read() - -print(re.search(r'def _handle_client_message\([^)]+\):.*?def', grpc_server, re.DOTALL).group(0)[:500]) - diff --git a/docs/refactors/frontend_modularity_plan.md b/docs/refactors/frontend_modularity_plan.md new file mode 100644 index 0000000..86f43c1 --- /dev/null +++ b/docs/refactors/frontend_modularity_plan.md @@ -0,0 +1,122 @@ +# Frontend Modular Refactor Plan + +## Goal +Refactor the frontend source code to be **feature-driven**, **modular**, and **aligned with cloud-native / 12-factor** principles while keeping existing behavior intact. + +This plan targets the **React app** under `frontend/src/`, reorganizing it into clear feature boundaries and shared utilities. + +--- + +## 1) Core Principles + +### ✅ Feature-driven structure +- Organize code by **domain/features** (chat, nodes, voice, auth, settings, etc.) rather than by type (components, pages, hooks). +- Each feature owns its code (components, hooks, services, pages, styles). + +### ✅ 12-factor / cloud-native alignment +- Configuration from environment variables (`process.env.REACT_APP_*`) in a central config module. +- No runtime state outside React component/local state. +- Code-splittable and lazy-loadable per feature via route-based splitting. + +### ✅ Non-breaking migration +- Implement refactor incrementally. +- Use **re-export wrappers** to keep existing import paths intact while transitioning. + +--- + +## 2) Target Folder Structure (Recommended) + +``` +src/ + app/ + App.js + routes.js + index.js + config.js + + features/ + auth/ + components/ + hooks/ + services/ + pages/ + index.js + + chat/ + components/ + hooks/ + services/ + pages/ + index.js + + nodes/ + components/ + hooks/ + services/ + pages/ + index.js + + voice/ + components/ + hooks/ + services/ + pages/ + index.js + + shared/ + components/ + hooks/ + services/ + utils/ + constants/ + styles/ +``` + +--- + +## 3) Refactor Roadmap (Feature-by-Feature) + +### Step 0: Audit & Tag +- Identify large files and cross-feature imports. +- Classify existing files into features. + +### Step 1: Build Shared Foundations +- Create `src/app/config.js` for env-based config. +- Move generic services (API, websocket) to `src/shared/services`. +- Move generic UI primitives (Button, Modal) to `src/shared/components`. + +### Step 2: Feature Migration (Example: Chat) +1. Create `src/features/chat/` structure. +2. Move `ChatWindow`, `ChatArea`, `ChatInput`, hooks, and related styles into it. +3. Add `src/features/chat/index.js` to expose exports. +4. Update `App.js` / `routes.js` to import from feature exports. +5. Keep old paths stable by adding small wrapper modules during migration. + +### Step 3: Repeat for other features +- Nodes (`src/features/nodes/`) +- Voice (`src/features/voice/`) +- Settings/Profile (`src/features/settings/`, `src/features/profile/`) + +--- + +## 4) Naming & Convention Rules +- Folder names match feature names exactly. +- File names describe purpose (e.g., `ChatPage.js`, `useChatMessages.js`). +- Each feature exports via a single `index.js`. + +--- + +## 5) Testing & Validation +- Run existing unit/integration tests (`npm test` / `yarn test`). +- Run `npm run build` to validate production output. +- Perform manual regression on key flows (chat, node console, voice). + +--- + +## 6) Next Step (Optional) +If you want, I can generate a **concrete migration plan for one feature (e.g., Chat)** including: +- Detailed file mapping (source -> destination) +- Updated import rewrites +- A minimal set of changes per PR (small and reviewable) + +Just say which feature you want to start with. diff --git a/fix_db.py b/fix_db.py deleted file mode 100644 index 6d94814..0000000 --- a/fix_db.py +++ /dev/null @@ -1,34 +0,0 @@ -import sys -sys.path.append("/app") -from app.db.session import get_db_session -from app.db.models import User, Session -from sqlalchemy.orm.attributes import flag_modified - -try: - with get_db_session() as db: - users = db.query(User).all() - for u in users: - prefs = u.preferences or {} - nodes = prefs.get("nodes", {}) - defaults = nodes.get("default_node_ids", []) - if "synology-nas" in defaults: - defaults.remove("synology-nas") - nodes["default_node_ids"] = defaults - prefs["nodes"] = nodes - u.preferences = prefs - flag_modified(u, "preferences") - print(f"Removed synology-nas from user {u.id} preferences") - - sessions = db.query(Session).filter(Session.sync_workspace_id == "session-189-5d087351").all() - for s in sessions: - attached = s.attached_node_ids or [] - if "synology-nas" in attached: - attached.remove("synology-nas") - s.attached_node_ids = attached - flag_modified(s, "attached_node_ids") - print(f"Removed synology-nas from session {s.id}") - - db.commit() - print("Done.") -except Exception as e: - print(f"Error: {e}") diff --git a/frontend/src/components/ChatArea.js b/frontend/src/components/ChatArea.js index bbc8e0a..71b1280 100644 --- a/frontend/src/components/ChatArea.js +++ b/frontend/src/components/ChatArea.js @@ -41,7 +41,7 @@ return (
-
+
diff --git a/frontend/src/components/ChatWindow.js b/frontend/src/components/ChatWindow.js index 1b34972..ff572ca 100644 --- a/frontend/src/components/ChatWindow.js +++ b/frontend/src/components/ChatWindow.js @@ -289,7 +289,7 @@
{chatHistory.map((message, index) => { const isLastMessage = index === chatHistory.length - 1; diff --git a/patch_broadcast.py b/patch_broadcast.py deleted file mode 100644 index a9a48be..0000000 --- a/patch_broadcast.py +++ /dev/null @@ -1,8 +0,0 @@ -with open("ai-hub/app/core/grpc/services/grpc_server.py", "r") as f: - content = f.read() - -target = "self.assistant.broadcast_file_chunk(fs.session_id, node_id, fs.file_data)" -if target in content: - print("grpc_server calls broadcast_file_chunk correctly.") -else: - print("grpc_server missing broadcast call!") diff --git a/patch_broadcast2.py b/patch_broadcast2.py deleted file mode 100644 index 8f27254..0000000 --- a/patch_broadcast2.py +++ /dev/null @@ -1,9 +0,0 @@ -import re - -with open("ai-hub/app/core/grpc/services/grpc_server.py", "r") as f: - grpc_server = f.read() - -lines = grpc_server.split("\n") -for i, line in enumerate(lines): - if "self.assistant.broadcast_file_chunk" in line: - print("\n".join(lines[max(0, i-5):min(len(lines), i+6)])) diff --git a/patch_grpc.py b/patch_grpc.py deleted file mode 100644 index 313c7e4..0000000 --- a/patch_grpc.py +++ /dev/null @@ -1,19 +0,0 @@ -with open("ai-hub/app/core/grpc/services/grpc_server.py", "r") as f: - content = f.read() - -target = """ if fs.session_id != "__fs_explorer__": - drifts = self.mirror.reconcile(fs.session_id, fs.manifest)""" - -replacement = """ if fs.session_id != "__fs_explorer__": - # Do not reconcile on shallow manifests triggered by interactive FS tools - if task_id and any(task_id.startswith(p) for p in ("fs-ls-", "fs-write-", "fs-rm-")): - pass - else: - drifts = self.mirror.reconcile(fs.session_id, fs.manifest)""" - -if target in content: - with open("ai-hub/app/core/grpc/services/grpc_server.py", "w") as f: - f.write(content.replace(target, replacement)) - print("Patched grpc_server.py") -else: - print("Target not found") diff --git a/patch_grpc_listener.py b/patch_grpc_listener.py deleted file mode 100644 index d64f542..0000000 --- a/patch_grpc_listener.py +++ /dev/null @@ -1,24 +0,0 @@ -with open("ai-hub/app/core/grpc/services/grpc_server.py", "r") as f: - content = f.read() - -target = """ def _read_results(): - try: - for msg in request_iterator: - self._handle_client_message(msg, node_id, node) - except Exception as e:""" - -replacement = """ def _read_results(): - try: - for msg in request_iterator: - try: - self._handle_client_message(msg, node_id, node) - except Exception as inner_e: - logger.error(f"[!] Error processing task message from {node_id}: {inner_e}", exc_info=True) - except Exception as e:""" - -if target in content: - with open("ai-hub/app/core/grpc/services/grpc_server.py", "w") as f: - f.write(content.replace(target, replacement)) - print("Patched grpc_server.py listener") -else: - print("Target not found") diff --git a/test_file_sync.py b/test_file_sync.py deleted file mode 100644 index e1a41ce..0000000 --- a/test_file_sync.py +++ /dev/null @@ -1,16 +0,0 @@ -import subprocess -import time - -def cleanup(): - # Remove files if they exist - import os - if os.path.exists("test_10mb_A.bin"): - os.remove("test_10mb_A.bin") - -def generate_10mb_file(): - with open("test_10mb_A.bin", "wb") as f: - import os - f.write(os.urandom(10 * 1024 * 1024)) - -if __name__ == "__main__": - generate_10mb_file() diff --git a/test_loopback.py b/test_loopback.py deleted file mode 100644 index 73643bd..0000000 --- a/test_loopback.py +++ /dev/null @@ -1,10 +0,0 @@ -import subprocess -import time - -def generate_100mb_file(): - with open("big_test_file_100mb.bin", "wb") as f: - f.write(os.urandom(100 * 1024 * 1024)) - -if __name__ == "__main__": - import os - generate_100mb_file()