Nanthasit's picture
Paren-balance values, done answer fallback to last result
19387d4
Raw
History Blame Contribute Delete
13.2 kB
import gradio as gr
import subprocess, os, threading, torch, json, re, time, base64, io
subprocess.run(["playwright", "install", "chromium", "--with-deps"], capture_output=True)
from playwright.sync_api import sync_playwright
from transformers import AutoModelForCausalLM, AutoTokenizer
from PIL import Image
MODEL_ID = "Nanthasit/sakthai-coder-browser"
TOOLS = [
{"type": "function", "function": {"name": "navigate", "description": "Go to a URL", "parameters": {"type": "object", "properties": {"url": {"type": "string"}}, "required": ["url"]}}},
{"type": "function", "function": {"name": "click", "description": "Click an element by selector or text", "parameters": {"type": "object", "properties": {"target": {"type": "string", "description": "CSS selector, text label, or coordinate"}}, "required": ["target"]}}},
{"type": "function", "function": {"name": "type", "description": "Type text into a field", "parameters": {"type": "object", "properties": {"selector": {"type": "string"}, "text": {"type": "string"}, "clear_first": {"type": "boolean"}}, "required": ["selector", "text"]}}},
{"type": "function", "function": {"name": "select", "description": "Select from dropdown", "parameters": {"type": "object", "properties": {"selector": {"type": "string"}, "value": {"type": "string"}}, "required": ["selector", "value"]}}},
{"type": "function", "function": {"name": "extract", "description": "Extract text content", "parameters": {"type": "object", "properties": {"selector": {"type": "string"}}}}},
{"type": "function", "function": {"name": "scroll", "description": "Scroll the page", "parameters": {"type": "object", "properties": {"direction": {"type": "string", "enum": ["up", "down", "top", "bottom"]}}, "required": ["direction"]}}},
{"type": "function", "function": {"name": "wait", "description": "Wait for milliseconds", "parameters": {"type": "object", "properties": {"ms": {"type": "integer"}}, "required": ["ms"]}}},
{"type": "function", "function": {"name": "press_key", "description": "Press a keyboard key", "parameters": {"type": "object", "properties": {"key": {"type": "string"}}, "required": ["key"]}}},
{"type": "function", "function": {"name": "screenshot", "description": "Take screenshot", "parameters": {"type": "object", "properties": {}}}},
{"type": "function", "function": {"name": "go_back", "description": "Go back", "parameters": {"type": "object", "properties": {}}}},
{"type": "function", "function": {"name": "done", "description": "Mark task as complete", "parameters": {"type": "object", "properties": {"answer": {"type": "string"}}}}},
]
print("Loading model on CPU...", flush=True)
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
model = AutoModelForCausalLM.from_pretrained(MODEL_ID, device_map=None, torch_dtype=torch.float32)
print("Model loaded", flush=True)
_tls = threading.local()
def get_page():
if not hasattr(_tls, "page") or _tls.page is None:
pw = sync_playwright().start()
browser = pw.chromium.launch(headless=True, args=["--no-sandbox"])
_tls.page = browser.new_page(viewport={"width": 1280, "height": 800})
_tls.pw = pw
_tls.browser = browser
return _tls.page
def build_msgs(url, task):
fmt = (
"You are a browser automation agent. Complete the user's web task by outputting actions.\n"
"Output ONLY actions, one per line, in this exact format:\n"
"navigate(url=https://example.com)\n"
"click(target=#submit-button)\n"
"type(selector=#search, text=hello world)\n"
"select(selector=#country, value=TH)\n"
"extract(selector=.price)\n"
"scroll(direction=down)\n"
"wait(ms=2000)\n"
"press_key(key=Enter)\n"
"go_back()\n"
"done(answer=the result)\n"
"Example:\n"
f"User: Go to {url} and read the page title\n"
f"You: navigate(url={url})\n"
f"Tool: Page loaded: {url}. Page title: Example Domain\n"
"You: done(answer=Example Domain)\n"
)
return [
{"role": "system", "content": "You are a browser automation agent. Output tool-call lines only."},
{"role": "user", "content": fmt + f"\nTask: Go to {url} and {task}"},
]
def generate(msgs, max_new_tokens=160):
text = tokenizer.apply_chat_template(
msgs, tokenize=False, add_generation_prompt=True, tools=None,
)
inputs = tokenizer(text, return_tensors="pt")
with torch.no_grad():
out = model.generate(**inputs, max_new_tokens=max_new_tokens, temperature=0.1, do_sample=True)
return tokenizer.decode(out[0][inputs.input_ids.shape[1]:], skip_special_tokens=False)
def generate_action(msgs):
return parse_actions(generate(msgs)), generate(msgs)
TOOL_NAME_MAP = {
"navigate": "navigate", "goto": "navigate", "open": "navigate",
"click": "click", "click_element": "click",
"type": "type", "fill": "type", "input": "type",
"select": "select", "select_option": "select",
"extract": "extract", "get_text": "extract", "read": "extract",
"scroll": "scroll", "wait": "wait", "sleep": "wait",
"press_key": "press_key", "key_press": "press_key",
"screenshot": "screenshot",
"go_back": "go_back", "back": "go_back",
"done": "done", "finish": "done", "complete": "done",
}
def clean_val(val):
val = str(val).strip()
val = val.split("<|im_end|>")[0].strip()
val = val.strip(" '\"").strip()
while val.endswith(")") and val.count(")") > val.count("("):
val = val[:-1].strip()
return val
def parse_actions(text):
actions = []
for m in re.finditer(r"<tool_call>(.*?)</tool_call>", text, re.DOTALL):
try:
data = json.loads(m.group(1).strip())
fn = data.get("function", data)
actions.append((fn.get("name", ""), fn.get("arguments", {})))
except json.JSONDecodeError:
pass
if not actions:
for m in re.finditer(r"\{[^{}]*\"name\"[^{}]*\}", text, re.DOTALL):
try:
data = json.loads(m.group(0))
fn = data.get("function", data)
args = fn.get("arguments", {})
if isinstance(args, str):
args = json.loads(args)
actions.append((fn.get("name", ""), args))
except json.JSONDecodeError:
pass
if not actions:
for line in text.splitlines():
line = line.strip().strip("`")
if not line or line.startswith(("<", "```")):
continue
m = re.match(r"([A-Za-z_]+)[,:]?\s*(.*)", line)
if not m:
continue
name_raw = m.group(1).lower()
name = TOOL_NAME_MAP.get(name_raw, name_raw)
rest = m.group(2)
if name not in TOOL_NAME_MAP.values():
continue
args = {}
if rest:
for kv in re.findall(r"(\w+)\s*=\s*([^,\n]+)", rest):
args[kv[0].strip()] = clean_val(kv[1])
if not args:
args = {"url": rest} if name == "navigate" else {"target": rest} if name == "click" else {"answer": clean_val(rest)} if name == "done" else {"text": rest}
if name == "navigate" and "url" not in args and "http" in rest:
args = {"url": rest.split()[0]}
actions.append((name, args))
return actions
def fix_selector(sel):
sel = str(sel).strip()
if sel.startswith("//") or sel.startswith("("):
return "xpath=" + sel
return sel
def run_tool(name, args, page):
if name == "navigate":
page.goto(args.get("url"), wait_until="domcontentloaded", timeout=30000)
return f"Navigated to {args.get('url')}"
elif name == "click":
target = fix_selector(args.get("target") or args.get("selector") or args.get("text"))
page.click(target, timeout=10000)
return f"Clicked {target}"
elif name == "type":
sel, text = fix_selector(args.get("selector") or args.get("target")), args.get("text")
if args.get("clear_first", True):
page.fill(sel, text)
else:
page.type(sel, text)
return f"Typed into {sel}"
elif name == "select":
page.select_option(fix_selector(args.get("selector")), args.get("value"))
return f"Selected {args.get('value')}"
elif name == "extract":
content = page.inner_text(fix_selector(args.get("selector"))) if args.get("selector") else page.inner_text("body")
return content[:800]
elif name == "scroll":
d = args.get("direction") or args.get("target") or "down"
d = d.split("<|im_end|>")[0].strip()
if d == "top": page.evaluate("window.scrollTo(0, 0);")
elif d == "bottom": page.evaluate("window.scrollTo(0, document.body.scrollHeight);")
else:
delta = "window.innerHeight" if d == "down" else "-window.innerHeight"
page.evaluate(f"window.scrollBy(0, {delta});")
return f"Scrolled {d}"
elif name == "wait":
time.sleep(args.get("ms", 1000) / 1000)
return "Waited"
elif name == "press_key":
page.keyboard.press(args.get("key"))
return f"Pressed {args.get('key')}"
elif name == "screenshot":
return "Screenshot taken"
elif name == "go_back":
page.go_back(wait_until="domcontentloaded", timeout=30000)
return "Went back"
elif name == "done":
return "DONE:" + str(args.get("answer", ""))
return f"Unknown tool {name}"
def screenshot_png(page):
data = page.screenshot(type="png")
return Image.open(io.BytesIO(data))
def page_context(page):
try:
title = page.title()
body = page.inner_text("body")[:600]
return f"Page title: {title}\nPage text:\n{body}"
except Exception:
return ""
def run_task(url, task, max_steps=6):
page = get_page()
last_nav = None
shots = []
log = []
try:
page.goto(url, wait_until="domcontentloaded", timeout=30000)
last_nav = url
shots.append(screenshot_png(page))
log.append(f"OPEN {url} (page loaded)")
except Exception as e:
return "\n".join([f"ERROR navigating: {e}"]), None
msgs = [
{"role": "system", "content": "You are a browser automation agent. Output tool-call lines only."},
{"role": "user", "content": f"Task: Go to {url} and {task}"},
{"role": "assistant", "content": f"navigate(url={url})"},
{"role": "tool", "content": f"Page loaded: {url}\n{page_context(page)}", "name": "navigate"},
]
for step in range(max_steps):
raw = generate(msgs)
log.append(f"--- step {step+1}: model said ---\n{raw[:300]}")
actions = parse_actions(raw)
if not actions:
answer = raw.strip().strip("<|im_end|>").strip()
if answer and "{url}" not in answer and "<tool" not in answer:
log.append(f"FINAL ANSWER: {answer[:300]}")
return "\n".join(log), shots
log.append("(no action parsed)")
break
for name, args in actions:
try:
if name == "navigate":
dest = args.get("url")
if dest == last_nav:
result = f"Already on {dest}"
else:
result = run_tool(name, args, page)
last_nav = page.url
else:
result = run_tool(name, args, page)
except Exception as e:
result = f"ERROR: {type(e).__name__}: {str(e)[:120]}"
log.append(f">> {name}({json.dumps(args, ensure_ascii=False)}) -> {result[:150]}")
msgs.append({"role": "assistant", "content": raw[:300]})
if name == "done":
answer = result[5:] if result.startswith("DONE:") else ""
if not answer:
answer = last_result if 'last_result' in dir() else ""
log.append(f"FINAL ANSWER: {answer[:300]}")
shots.append(screenshot_png(page))
return "\n".join(log), shots
last_result = result
msgs.append({"role": "tool", "content": result + "\n" + page_context(page), "name": name})
try:
shots.append(screenshot_png(page))
except Exception:
pass
return "\n".join(log), shots
with gr.Blocks(title="SakThai Web Agent") as demo:
gr.Markdown("# SakThai Web Agent")
gr.Markdown("Real Playwright browser on a free CPU Space. The agent drives headless Chromium and shows live screenshots of every step.")
with gr.Row():
url = gr.Textbox(value="https://www.google.com", label="URL", scale=2)
task = gr.Textbox(value="search for sakthai coder browser", label="Task", scale=3)
btn = gr.Button("Run in Playwright", variant="primary")
log_out = gr.Textbox(label="Agent log", lines=12)
gallery = gr.Gallery(label="Browser screenshots (after each action)", columns=3, height=400)
btn.click(fn=run_task, inputs=[url, task], outputs=[log_out, gallery])
demo.launch(show_error=True)