-- Made By AI --
local Players = game:GetService("Players")
local UserInputService = game:GetService("UserInputService")
local TweenService = game:GetService("TweenService")
local RunService = game:GetService("RunService")
local LogService = game:GetService("LogService")
local CaptureService = game:GetService("CaptureService")
local AssetService = game:GetService("AssetService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local HttpService = game:GetService("HttpService")
local MarketplaceService = game:GetService("MarketplaceService")
local player = Players.LocalPlayer
local playerGui = player:WaitForChild("PlayerGui")
local camera = workspace.CurrentCamera
SERIF = Enum.Font.GothamBold
pcall(function() SERIF = Enum.Font.Merriweather end)
local C = {
bg = Color3.fromRGB(24, 23, 21),
sidebar = Color3.fromRGB(19, 18, 17),
panel = Color3.fromRGB(33, 32, 30),
hover = Color3.fromRGB(44, 42, 39),
userBubble = Color3.fromRGB(36, 34, 31),
code = Color3.fromRGB(16, 15, 14),
stroke = Color3.fromRGB(56, 53, 49),
text = Color3.fromRGB(247, 245, 240),
subtext = Color3.fromRGB(156, 151, 143),
error = Color3.fromRGB(235, 110, 110),
}
local MODELS = {
{ brand = "deepseek", brandName = "DeepSeek", name = "DeepSeek V4 Flash", short = "V4 Flash", id = "deepseek-v4.1-flash-free", format = "openai", provider = "opencode", icon = "rbxassetid://101905873334212", accent = Color3.fromRGB(77, 107, 254), desc = "Free · OpenCode Zen", think = "DeepThink", vision = true },
{ brand = "gpt", brandName = "ChatGPT", name = "GPT-6.1 Sol", short = "6.1 Sol", id = "gpt-6.1-sol", format = "openai", provider = "agentrouter", endpoint = "chat", wire = "chat", icon = "rbxassetid://117321183753096", accent = Color3.fromRGB(16, 163, 127), desc = "Reasoning model", think = "Think", vision = true },
{ brand = "gpt", brandName = "ChatGPT", name = "GPT-6 Astra", short = "6 Astra", id = "gpt-6-astra", format = "openai", provider = "agentrouter", endpoint = "chat", wire = "chat", icon = "rbxassetid://117321183753096", accent = Color3.fromRGB(130, 105, 240), desc = "Advanced reasoning", think = "Think", vision = true },
{ brand = "opencode", brandName = "GLM", name = "GLM 5.3 Flash Free", short = "GLM Free", id = "glm-5.3-flash-free", format = "openai", provider = "opencode", mark = "glm", accent = Color3.fromRGB(70, 150, 220), desc = "Free · balanced", think = "Think", vision = false },
{ brand = "opencode", brandName = "MiMo", name = "MiMo V2.6 Flash Free", short = "MiMo Free", id = "mimo-v2.6-flash-free", format = "openai", provider = "opencode", mark = "mm", accent = Color3.fromRGB(50, 190, 130), desc = "Free · multimodal", think = "Think", vision = true },
{ brand = "opencode", brandName = "Big Pickle", name = "Big Pickle", short = "Pickle", id = "big-pickle", format = "openai", provider = "opencode", mark = "bp", accent = Color3.fromRGB(210, 150, 50), desc = "Free · general", think = "Think", vision = false },
{ brand = "opencode", brandName = "Nemotron", name = "Nemotron 3.5 Lightning Free", short = "Nemo Free", id = "nemotron-3.5-lightning-free", format = "openai", provider = "opencode", mark = "nv", accent = Color3.fromRGB(110, 80, 230), desc = "Free · long context", think = "Think", vision = false },
{ brand = "opencode", brandName = "Beam", name = "Beam Free", short = "Beam Free", id = "beam-free", format = "openai", provider = "opencode", mark = "bm", accent = Color3.fromRGB(40, 170, 210), desc = "Free · reasoning", think = "Think", vision = false },
{ brand = "opencode", brandName = "Flint", name = "Flint 40B Free", short = "Flint Free", id = "flint-40b-free", format = "openai", provider = "opencode", mark = "fl", accent = Color3.fromRGB(200, 90, 140), desc = "Free · general", think = "Think", vision = false },
{ brand = "opencode", brandName = "Nemotron VL", name = "Nemotron Super VL Free", short = "VL Free", id = "nemotron-3.5-super-vl-free", format = "openai", provider = "opencode", mark = "vl", accent = Color3.fromRGB(230, 90, 70), desc = "Free · vision", think = "Think", vision = true },
}
local function modelById(id)
if not id then return nil end
for _, m in ipairs(MODELS) do
if m.id == id then return m end
end
return nil
end
function providerLabel(m)
local p = m and m.provider
if p == "openrouter" then return "OpenRouter" end
if p == "opencode" then return "OpenCode Zen" end
return "Agent Router"
end
local function new(class, props, parent)
local inst = Instance.new(class)
for k, v in pairs(props) do inst[k] = v end
inst.Parent = parent
return inst
end
local function round(p, r) return new("UICorner", { CornerRadius = UDim.new(0, r) }, p) end
local function stroke(p, color, thickness)
return new("UIStroke", { Color = color or C.stroke, Thickness = thickness or 1,
ApplyStrokeMode = Enum.ApplyStrokeMode.Border }, p)
end
local function pad(p, t, r, b, l)
return new("UIPadding", { PaddingTop = UDim.new(0, t), PaddingRight = UDim.new(0, r),
PaddingBottom = UDim.new(0, b), PaddingLeft = UDim.new(0, l) }, p)
end
local MOTION = 1
local function tween(inst, t, props)
local dur = t * MOTION
if dur <= 0 then
for k, v in pairs(props) do inst[k] = v end
return
end
TweenService:Create(inst, TweenInfo.new(dur, Enum.EasingStyle.Quad, Enum.EasingDirection.Out), props):Play()
end
local function text(props, parent)
local base = {
BackgroundTransparency = 1, Font = Enum.Font.Gotham, TextSize = 14,
TextColor3 = C.text, TextXAlignment = Enum.TextXAlignment.Left, TextWrapped = true,
}
for k, v in pairs(props) do base[k] = v end
return new("TextLabel", base, parent)
end
local function button(props, parent)
local base = {
BackgroundTransparency = 1, AutoButtonColor = false, Text = "",
Font = Enum.Font.GothamMedium, TextSize = 14, TextColor3 = C.text,
}
for k, v in pairs(props) do base[k] = v end
return new("TextButton", base, parent)
end
local function hover(btn, overColor, overTransparency)
local key
for k, v in pairs(C) do
if v == overColor then key = k break end
end
local c0, t0, hovering = btn.BackgroundColor3, btn.BackgroundTransparency, false
btn.MouseEnter:Connect(function()
if hovering then return end
hovering = true
c0, t0 = btn.BackgroundColor3, btn.BackgroundTransparency
tween(btn, 0.12, { BackgroundColor3 = key and C[key] or overColor, BackgroundTransparency = overTransparency or 0 })
end)
btn.MouseLeave:Connect(function()
if not hovering then return end
hovering = false
tween(btn, 0.12, { BackgroundColor3 = c0, BackgroundTransparency = t0 })
end)
end
-- Executors expose the clipboard under different names.
function copyToClipboard(value)
local text = tostring(value or "")
local fn = setclipboard or toclipboard or writeclipboard
if type(fn) == "function" then
pcall(fn, text)
return true
end
return false
end
local function bar(parent, w, h, x, y, rot, color)
local f = new("Frame", {
AnchorPoint = Vector2.new(0.5, 0.5), Position = UDim2.new(0.5, x, 0.5, y),
Size = UDim2.fromOffset(w, h), Rotation = rot, BackgroundColor3 = color, BorderSizePixel = 0,
}, parent)
round(f, math.min(w, h) / 2)
return f
end
local function icon(kind, parent, color, size)
size = size or 20
local s = size / 20
local root = new("Frame", {
BackgroundTransparency = 1, Size = UDim2.fromOffset(size, size),
AnchorPoint = Vector2.new(0.5, 0.5), Position = UDim2.fromScale(0.5, 0.5),
}, parent)
if kind == "arrowUp" then
bar(root, 2.4 * s, 11 * s, 0, 0.5 * s, 0, color)
bar(root, 2.4 * s, 6.5 * s, -2.2 * s, -3.6 * s, 45, color)
bar(root, 2.4 * s, 6.5 * s, 2.2 * s, -3.6 * s, -45, color)
elseif kind == "cross" then
bar(root, 2 * s, 15 * s, 0, 0, 45, color)
bar(root, 2 * s, 15 * s, 0, 0, -45, color)
elseif kind == "plus" then
bar(root, 2 * s, 13 * s, 0, 0, 0, color)
bar(root, 13 * s, 2 * s, 0, 0, 0, color)
elseif kind == "minus" then
bar(root, 13 * s, 2 * s, 0, 0, 0, color)
elseif kind == "chevron" then
bar(root, 2 * s, 7 * s, -2.4 * s, 0, -45, color)
bar(root, 2 * s, 7 * s, 2.4 * s, 0, 45, color)
elseif kind == "check" then
bar(root, 2 * s, 5 * s, -3.5 * s, 1.5 * s, -45, color)
bar(root, 2 * s, 10 * s, 1.5 * s, -0.5 * s, 45, color)
elseif kind == "menu" then
bar(root, 14 * s, 2 * s, 0, -5 * s, 0, color)
bar(root, 14 * s, 2 * s, 0, 0, 0, color)
bar(root, 14 * s, 2 * s, 0, 5 * s, 0, color)
elseif kind == "image" then
local f = new("Frame", { AnchorPoint = Vector2.new(0.5, 0.5), Position = UDim2.fromScale(0.5, 0.5),
Size = UDim2.fromOffset(15 * s, 12 * s), BackgroundTransparency = 1 }, root)
round(f, 3)
stroke(f, color, 1.6)
bar(root, 3.6 * s, 3.6 * s, 3 * s, -2 * s, 0, color)
bar(root, 9 * s, 1.6 * s, -1.5 * s, 3 * s, -30, color)
elseif kind == "terminal" then
local f = new("Frame", { AnchorPoint = Vector2.new(0.5, 0.5), Position = UDim2.fromScale(0.5, 0.5),
Size = UDim2.fromOffset(16 * s, 13 * s), BackgroundTransparency = 1 }, root)
round(f, 3)
stroke(f, color, 1.6)
bar(root, 1.6 * s, 4.2 * s, -3.6 * s, -1.4 * s, -45, color)
bar(root, 1.6 * s, 4.2 * s, -3.6 * s, 1.4 * s, 45, color)
bar(root, 4.5 * s, 1.6 * s, 2.5 * s, 2.5 * s, 0, color)
elseif kind == "globe" then
local f = new("Frame", { AnchorPoint = Vector2.new(0.5, 0.5), Position = UDim2.fromScale(0.5, 0.5),
Size = UDim2.fromOffset(15 * s, 15 * s), BackgroundTransparency = 1 }, root)
round(f, 99)
stroke(f, color, 1.6)
bar(root, 15 * s, 1.5 * s, 0, 0, 0, color)
local e = new("Frame", { AnchorPoint = Vector2.new(0.5, 0.5), Position = UDim2.fromScale(0.5, 0.5),
Size = UDim2.fromOffset(7 * s, 15 * s), BackgroundTransparency = 1 }, root)
round(e, 99)
stroke(e, color, 1.5)
elseif kind == "settings" then
local gear = new("Frame", { AnchorPoint = Vector2.new(0.5, 0.5), Position = UDim2.fromScale(0.5, 0.5), Size = UDim2.fromOffset(12 * s, 12 * s), BackgroundTransparency = 1 }, root)
round(gear, 99)
stroke(gear, color, 1.6)
local center = new("Frame", { AnchorPoint = Vector2.new(0.5, 0.5), Position = UDim2.fromScale(0.5, 0.5), Size = UDim2.fromOffset(4 * s, 4 * s), BackgroundColor3 = color }, root)
round(center, 99)
for i = 0, 3 do bar(root, 2 * s, 5 * s, 0, 0, i * 45, color) end
elseif kind == "stop" then
local f = new("Frame", {
AnchorPoint = Vector2.new(0.5, 0.5), Position = UDim2.fromScale(0.5, 0.5),
Size = UDim2.fromOffset(10 * s, 10 * s), BackgroundColor3 = color, BorderSizePixel = 0,
}, root)
round(f, 2)
elseif kind == "file" then
local f = new("Frame", { AnchorPoint = Vector2.new(0.5, 0.5), Position = UDim2.fromScale(0.5, 0.5),
Size = UDim2.fromOffset(12 * s, 15 * s), BackgroundTransparency = 1 }, root)
round(f, 2)
stroke(f, color, 1.5)
bar(root, 6 * s, 1.5 * s, -1.5 * s, -2 * s, 0, color)
bar(root, 8 * s, 1.5 * s, -1 * s, 2 * s, 0, color)
elseif kind == "folder" then
local f = new("Frame", { AnchorPoint = Vector2.new(0.5, 0.5), Position = UDim2.fromScale(0.5, 0.5),
Size = UDim2.fromOffset(15 * s, 12 * s), BackgroundTransparency = 1 }, root)
round(f, 2)
stroke(f, color, 1.5)
bar(root, 6 * s, 2 * s, -3 * s, -5 * s, 0, color)
elseif kind == "search" then
local ring = new("Frame", { AnchorPoint = Vector2.new(0.5, 0.5), Position = UDim2.new(0.5, -1 * s, 0.5, -1 * s),
Size = UDim2.fromOffset(11 * s, 11 * s), BackgroundTransparency = 1 }, root)
round(ring, 99)
stroke(ring, color, 1.7)
bar(root, 1.7 * s, 5 * s, 4 * s, 4 * s, -45, color)
elseif kind == "code" then
text({ Size = UDim2.fromScale(1, 1), Text = ">", Font = Enum.Font.Code,
TextSize = math.max(8, math.floor(11 * s)), TextColor3 = color,
TextXAlignment = Enum.TextXAlignment.Center, TextYAlignment = Enum.TextYAlignment.Center, TextWrapped = false }, root)
elseif kind == "person" then
local head = new("Frame", { AnchorPoint = Vector2.new(0.5, 0.5), Position = UDim2.new(0.5, 0, 0.5, -3 * s),
Size = UDim2.fromOffset(6 * s, 6 * s), BackgroundColor3 = color, BorderSizePixel = 0 }, root)
round(head, 99)
local body = new("Frame", { AnchorPoint = Vector2.new(0.5, 1), Position = UDim2.new(0.5, 0, 0.5, 5 * s),
Size = UDim2.fromOffset(12 * s, 8 * s), BackgroundColor3 = color, BorderSizePixel = 0 }, root)
round(body, 99)
end
return root
end
function recolor(root, color)
for _, d in ipairs(root:GetDescendants()) do
if d:IsA("Frame") then d.BackgroundColor3 = color end
end
end
local function brandLogo(parent, model, size)
if model.mark then
local root = new("Frame", {
Name = "ModelLogo", BackgroundColor3 = model.accent,
Size = UDim2.fromOffset(size, size), BorderSizePixel = 0,
}, parent)
round(root, math.max(4, math.floor(size * 0.28)))
local letters = tostring(model.mark):upper()
text({
Size = UDim2.fromScale(1, 1), Text = letters,
Font = Enum.Font.GothamBold, TextSize = math.max(8, math.floor(size * 0.42)),
TextColor3 = Color3.new(1, 1, 1), TextXAlignment = Enum.TextXAlignment.Center,
TextYAlignment = Enum.TextYAlignment.Center, TextWrapped = false,
}, root)
return root
end
return new("ImageLabel", {
Name = "ModelLogo", BackgroundTransparency = 1,
Size = UDim2.fromOffset(size, size), Image = model.icon or "",
ScaleType = Enum.ScaleType.Fit, ImageColor3 = Color3.new(1, 1, 1),
}, parent)
end
local small = camera.ViewportSize.X < 700
local SIDEBAR_W = small and math.max(150, math.floor(camera.ViewportSize.X * 0.82)) or 236
local conversations, current = {}, nil
local projects, currentProject = {}, nil
local modelIndex, thinkOn = 1, false
local busy, cancelFlag, sidebarOpen = false, false, not small
local newFolder
local openSettings
local settings = {
console = true, screen = true, web = true, theme = "Claude", uiScale = 1,
personality = "", apiKey = "", opencodeApiKey = "", openrouterKey = "", provider = "opencode",
autoLogo = true, permission = "ask", motion = "Full", transcriptWidth = 740,
}
local API = {}
local AGENTROUTER_KEY = ""
local OPENCODE_KEY = ""
API.AGENTROUTER_URL = "https://agentrouter.org/v1/chat/completions"
API.AGENTROUTER_RESPONSES_URL = "https://agentrouter.org/v1/responses"
API.AGENTROUTER_MESSAGES_URL = "https://agentrouter.org/v1/messages"
API.OPENCODE_URL = "https://opencode.ai/zen/v1/chat/completions"
API.OPENCODE_INFERENCE_URL = "https://opencode.ai/inference/openai/v1/chat/completions"
API.OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
API.OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models"
API.OPENCODE_MODELS_URL = "https://opencode.ai/zen/v1/models"
API.AGENTROUTER_MODELS_URL = "https://agentrouter.org/v1/models"
API.OPENROUTER_KEY_URL = "https://openrouter.ai/api/v1/key"
API.OPENROUTER_SPOOF = { ["HTTP-Referer"] = "https://roblox.local", ["X-Title"] = "Roblox AI Assistant" }
function API.withIdentity(fn)
if type(fn) ~= "function" then return nil end
local getter = getthreadidentity or (syn and syn.getthreadidentity)
local setter = setthreadidentity or (syn and syn.setthreadidentity)
if type(setter) ~= "function" then return fn() end
local current = nil
if type(getter) == "function" then
local ok, value = pcall(getter)
if ok then current = value end
end
pcall(setter, 8)
local results = table.pack(pcall(fn))
if current ~= nil then pcall(setter, current) end
if not results[1] then error(results[2], 0) end
return table.unpack(results, 2, results.n)
end
local opencodeBlockedUntil = 0
local webTainted = false
local testApiConnection
local SETTINGS_FILE = "AgentRouterRobloxAI.json"
-- Game Detection --
local gameName = "Unknown Game"
local placeId = game.PlaceId
pcall(function()
local info = MarketplaceService:GetProductInfo(placeId)
if info and info.Name then gameName = info.Name end
end)
local ACTIVE_PALETTE = {}
for k, v in pairs(C) do ACTIVE_PALETTE[k] = v end
local THEMES = {
Claude = { bg = Color3.fromRGB(24, 23, 21), sidebar = Color3.fromRGB(19, 18, 17), panel = Color3.fromRGB(33, 32, 30), hover = Color3.fromRGB(44, 42, 39), userBubble = Color3.fromRGB(36, 34, 31), code = Color3.fromRGB(16, 15, 14), stroke = Color3.fromRGB(56, 53, 49), text = Color3.fromRGB(247, 245, 240), subtext = Color3.fromRGB(156, 151, 143), error = Color3.fromRGB(235, 110, 110) },
ChatGPT = { bg = Color3.fromRGB(32, 33, 35), sidebar = Color3.fromRGB(23, 24, 25), panel = Color3.fromRGB(43, 44, 46), hover = Color3.fromRGB(57, 58, 61), userBubble = Color3.fromRGB(36, 36, 38), code = Color3.fromRGB(18, 18, 19), stroke = Color3.fromRGB(58, 59, 62), text = Color3.fromRGB(238, 238, 238), subtext = Color3.fromRGB(150, 151, 153), error = Color3.fromRGB(255, 100, 100) },
DeepSeek = { bg = Color3.fromRGB(20, 25, 33), sidebar = Color3.fromRGB(14, 18, 24), panel = Color3.fromRGB(29, 34, 44), hover = Color3.fromRGB(41, 49, 61), userBubble = Color3.fromRGB(26, 32, 42), code = Color3.fromRGB(11, 14, 19), stroke = Color3.fromRGB(47, 57, 72), text = Color3.fromRGB(223, 231, 241), subtext = Color3.fromRGB(131, 143, 157), error = Color3.fromRGB(255, 106, 106) },
GLM = { bg = Color3.fromRGB(18, 24, 27), sidebar = Color3.fromRGB(13, 17, 20), panel = Color3.fromRGB(27, 34, 38), hover = Color3.fromRGB(38, 47, 52), userBubble = Color3.fromRGB(23, 30, 34), code = Color3.fromRGB(9, 12, 14), stroke = Color3.fromRGB(44, 55, 61), text = Color3.fromRGB(226, 235, 238), subtext = Color3.fromRGB(130, 145, 150), error = Color3.fromRGB(245, 112, 124) },
Kimi = { bg = Color3.fromRGB(23, 21, 28), sidebar = Color3.fromRGB(17, 15, 22), panel = Color3.fromRGB(32, 29, 40), hover = Color3.fromRGB(44, 40, 55), userBubble = Color3.fromRGB(29, 26, 37), code = Color3.fromRGB(12, 11, 16), stroke = Color3.fromRGB(53, 49, 66), text = Color3.fromRGB(234, 231, 241), subtext = Color3.fromRGB(144, 139, 158), error = Color3.fromRGB(245, 112, 124) },
}
local PERMISSION_MODES = {
{ id = "readonly", title = "Read only", desc = "Inspection only. Nothing is changed, executed or written." },
{ id = "ask", title = "Ask first", desc = "Reads run freely. Anything that changes the game waits for you." },
{ id = "auto", title = "Auto (ask for dangerous)", desc = "Reads and writes run freely. Code execution and deletion still ask." },
{ id = "allow", title = "Allow everything", desc = "No prompts at all. Only sensible when you trust the provider.", accent = true },
}
function permissionInfo(id)
for _, p in ipairs(PERMISSION_MODES) do if p.id == id then return p end end
return PERMISSION_MODES[2]
end
function loadSavedSettings()
if type(readfile) ~= "function" or type(isfile) ~= "function" then return end
local ok, data = pcall(function()
if not isfile(SETTINGS_FILE) then return nil end
return HttpService:JSONDecode(readfile(SETTINGS_FILE))
end)
if ok and type(data) == "table" then
for _, key in ipairs({ "console", "screen", "web", "theme", "uiScale", "personality", "apiKey", "opencodeApiKey", "openrouterKey", "provider", "autoLogo", "permission", "motion", "transcriptWidth", "autoCompact", "customModel", "modelId" }) do
if data[key] ~= nil then settings[key] = data[key] end
end
end
end
function saveSettings()
if type(writefile) ~= "function" then return false end
local ok = pcall(function() writefile(SETTINGS_FILE, HttpService:JSONEncode(settings)) end)
return ok
end
loadSavedSettings()
AGENTROUTER_KEY = settings.apiKey or ""
OPENCODE_KEY = settings.opencodeApiKey or ""
MOTION = settings.motion == "Off" and 0 or (settings.motion == "Subtle" and 0.6 or 1)
local attachments, MAX_ATTACH = {}, 3
local gui = (function()
local props = { Name = "AIChatUI", ResetOnSpawn = false, IgnoreGuiInset = true,
ZIndexBehavior = Enum.ZIndexBehavior.Sibling, DisplayOrder = 100 }
local ok, built = pcall(function()
local container = gethui and gethui()
if container then return new("ScreenGui", props, container) end
return nil
end)
if ok and built then return built end
return new("ScreenGui", props, playerGui)
end)()
local window = new("Frame", {
Name = "Window", AnchorPoint = Vector2.new(0.5, 0.5), Position = UDim2.fromScale(0.5, 0.5),
Size = UDim2.fromScale(0.88, 0.9), BackgroundColor3 = C.bg,
}, gui)
round(window, 16)
stroke(window)
local windowConstraint = new("UISizeConstraint", { MinSize = Vector2.new(180, 160), MaxSize = Vector2.new(980, 640) }, window)
local winScale = new("UIScale", { Scale = 1 }, window)
local main = new("Frame", {
Name = "Main", BackgroundTransparency = 1, Position = UDim2.fromOffset(SIDEBAR_W, 0),
Size = UDim2.new(1, -SIDEBAR_W, 1, 0),
}, window)
local header = new("Frame", { Name = "Header", BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 52) }, main)
local loadingBar = new("Frame", { Name = "LoadingBar", Position = UDim2.new(0, 0, 0, 50), Size = UDim2.new(0.18, 0, 0, 2), BackgroundColor3 = MODELS[1].accent, BorderSizePixel = 0, ZIndex = 20 }, main)
loadingBar.Visible = false
local loadingActive = false
function setLoading(active)
loadingActive = active
loadingBar.Visible = active
if active then
task.spawn(function()
while loadingActive and loadingBar.Parent do
loadingBar.Position = UDim2.new(-0.2, 0, 0, 50)
loadingBar.BackgroundColor3 = MODELS[modelIndex].accent
tween(loadingBar, 0.7, { Position = UDim2.new(1, 0, 0, 50) })
task.wait(0.75)
end
loadingBar.Visible = false
end)
end
end
local menuBtn = button({ Position = UDim2.fromOffset(10, 10), Size = UDim2.fromOffset(32, 32),
BackgroundColor3 = C.hover }, header)
round(menuBtn, 8)
hover(menuBtn, C.hover, 0)
icon("menu", menuBtn, C.subtext, 20)
local titleLabel = text({
Position = UDim2.fromOffset(54, 4), Size = UDim2.new(1, -150, 0, 24), Text = "New chat",
Font = Enum.Font.GothamMedium, TextSize = 15, TextWrapped = false, TextTruncate = Enum.TextTruncate.AtEnd,
}, header)
local subtitleLabel = text({
Position = UDim2.fromOffset(54, 27), Size = UDim2.new(1, -150, 0, 16), Text = "",
TextSize = 11, TextColor3 = C.subtext, TextWrapped = false, TextTruncate = Enum.TextTruncate.AtEnd,
}, header)
local function updateHeaderSubtitle()
local m = MODELS[modelIndex]
local proj = currentProject and currentProject.name or "Chat"
subtitleLabel.Text = proj .. " · " .. m.brandName .. " " .. m.short
end
local minBtn = button({ AnchorPoint = Vector2.new(1, 0), Position = UDim2.new(1, -46, 0, 10),
Size = UDim2.fromOffset(32, 32), BackgroundColor3 = C.hover }, header)
round(minBtn, 8); hover(minBtn, C.hover, 0); icon("minus", minBtn, C.subtext, 20)
local closeBtn = button({ AnchorPoint = Vector2.new(1, 0), Position = UDim2.new(1, -10, 0, 10),
Size = UDim2.fromOffset(32, 32), BackgroundColor3 = C.hover }, header)
round(closeBtn, 8); hover(closeBtn, C.hover, 0); icon("cross", closeBtn, C.subtext, 18)
local messageArea = new("ScrollingFrame", {
Name = "Messages", Position = UDim2.fromOffset(0, 52), Size = UDim2.new(1, 0, 1, -170),
BackgroundTransparency = 1, BorderSizePixel = 0, ScrollBarThickness = 4,
ScrollBarImageColor3 = C.stroke, CanvasSize = UDim2.new(),
AutomaticCanvasSize = Enum.AutomaticSize.Y, ScrollingDirection = Enum.ScrollingDirection.Y,
}, main)
local column = new("Frame", {
Name = "Column", AnchorPoint = Vector2.new(0.5, 0), Position = UDim2.fromScale(0.5, 0),
Size = UDim2.new(1, -32, 0, 0), AutomaticSize = Enum.AutomaticSize.Y, BackgroundTransparency = 1,
}, messageArea)
new("UISizeConstraint", { MaxSize = Vector2.new(740, math.huge) }, column)
new("UIListLayout", { Padding = UDim.new(0, 20), SortOrder = Enum.SortOrder.LayoutOrder }, column)
pad(column, 16, 0, 24, 0)
local composer = new("Frame", {
Name = "Composer", AnchorPoint = Vector2.new(0.5, 1), Position = UDim2.new(0.5, 0, 1, -14),
Size = UDim2.new(1, -32, 0, 92), BackgroundColor3 = C.panel,
}, main)
round(composer, 18)
stroke(composer)
new("UISizeConstraint", { MaxSize = Vector2.new(740, math.huge) }, composer)
local input = new("TextBox", {
Name = "Input", Position = UDim2.fromOffset(16, 14), Size = UDim2.new(1, -32, 0, 24),
BackgroundTransparency = 1, Text = "", PlaceholderText = "Message...",
PlaceholderColor3 = C.subtext, TextColor3 = C.text, TextSize = 15, Font = Enum.Font.Gotham,
TextXAlignment = Enum.TextXAlignment.Left, TextYAlignment = Enum.TextYAlignment.Top,
ClearTextOnFocus = false, MultiLine = true, TextWrapped = true,
}, composer)
local attachRow = new("Frame", {
Name = "Attachments", Position = UDim2.fromOffset(12, 10), Size = UDim2.new(1, -24, 0, 58),
BackgroundTransparency = 1, Visible = false,
}, composer)
new("UIListLayout", { FillDirection = Enum.FillDirection.Horizontal, Padding = UDim.new(0, 8),
SortOrder = Enum.SortOrder.LayoutOrder }, attachRow)
local row = new("Frame", {
AnchorPoint = Vector2.new(0, 1), Position = UDim2.new(0, 10, 1, -8),
Size = UDim2.new(1, -20, 0, 34), BackgroundTransparency = 1,
}, composer)
local plusBtn = button({
AnchorPoint = Vector2.new(0, 0.5), Position = UDim2.new(0, 0, 0.5, 0),
Size = UDim2.fromOffset(32, 32), BackgroundColor3 = C.panel,
}, row)
round(plusBtn, 16)
stroke(plusBtn, C.stroke)
hover(plusBtn, C.hover, 0)
icon("plus", plusBtn, C.subtext, 18)
local thinkPill = button({
AnchorPoint = Vector2.new(0, 0.5), Position = UDim2.new(0, 40, 0.5, 0),
Size = UDim2.fromOffset(0, 32), AutomaticSize = Enum.AutomaticSize.X, BackgroundColor3 = C.panel,
}, row)
round(thinkPill, 16)
local thinkStroke = stroke(thinkPill, C.stroke)
pad(thinkPill, 0, 14, 0, 28)
local thinkDot = new("Frame", {
AnchorPoint = Vector2.new(0, 0.5), Position = UDim2.new(0, -17, 0.5, 0),
Size = UDim2.fromOffset(8, 8), BackgroundColor3 = C.subtext,
}, thinkPill)
round(thinkDot, 4)
local thinkLabel = text({
Size = UDim2.new(0, 0, 1, 0), AutomaticSize = Enum.AutomaticSize.X, Text = "Think",
TextSize = 13, Font = Enum.Font.GothamMedium, TextColor3 = C.subtext, TextWrapped = false,
}, thinkPill)
local sendBtn = button({
AnchorPoint = Vector2.new(1, 0.5), Position = UDim2.new(1, 0, 0.5, 0),
Size = UDim2.fromOffset(34, 34), BackgroundColor3 = MODELS[1].accent,
}, row)
round(sendBtn, 17)
local arrowIcon = icon("arrowUp", sendBtn, Color3.new(1, 1, 1), 20)
local stopIcon = icon("stop", sendBtn, Color3.new(1, 1, 1), 20)
stopIcon.Visible = false
local modelBtn = button({
AnchorPoint = Vector2.new(1, 0.5), Position = UDim2.new(1, -72, 0.5, 0),
Size = UDim2.fromOffset(140, 30), BackgroundColor3 = C.panel,
}, row)
round(modelBtn, 15)
stroke(modelBtn, C.stroke)
hover(modelBtn, C.hover, 0)
local modelDot = new("Frame", {
AnchorPoint = Vector2.new(0, 0.5), Position = UDim2.new(0, 11, 0.5, 0),
Size = UDim2.fromOffset(7, 7), BackgroundColor3 = Color3.fromRGB(106, 200, 145), BorderSizePixel = 0,
}, modelBtn)
round(modelDot, 4)
local modelName = text({
Position = UDim2.fromOffset(23, 0), Size = UDim2.new(0, 58, 1, 0), Text = "DS Free",
TextSize = 12, Font = Enum.Font.GothamMedium, TextWrapped = false,
TextTruncate = Enum.TextTruncate.AtEnd,
}, modelBtn)
local contextPct = text({
AnchorPoint = Vector2.new(1, 0.5), Position = UDim2.new(1, -26, 0.5, 0),
Size = UDim2.fromOffset(30, 20), Text = "0%", TextSize = 11,
TextColor3 = Color3.fromRGB(106, 200, 145), TextXAlignment = Enum.TextXAlignment.Right, TextWrapped = false,
}, modelBtn)
local chev = new("Frame", { BackgroundTransparency = 1, AnchorPoint = Vector2.new(1, 0.5),
Position = UDim2.new(1, -8, 0.5, 0), Size = UDim2.fromOffset(14, 14) }, modelBtn)
icon("chevron", chev, C.subtext, 14)
local moreBtn = button({ AnchorPoint = Vector2.new(1, 0.5), Position = UDim2.new(1, -40, 0.5, 0),
Size = UDim2.fromOffset(26, 30), BackgroundColor3 = C.panel }, row)
round(moreBtn, 8)
stroke(moreBtn, C.stroke)
hover(moreBtn, C.hover, 0)
do
local dots = new("Frame", { BackgroundTransparency = 1, AnchorPoint = Vector2.new(0.5, 0.5),
Position = UDim2.fromScale(0.5, 0.5), Size = UDim2.fromOffset(16, 16) }, moreBtn)
for k = 1, 3 do
local d = new("Frame", { AnchorPoint = Vector2.new(0.5, 0.5), Position = UDim2.new(0.5, (k - 2) * 5, 0.5, 0),
Size = UDim2.fromOffset(2.5, 2.5), BackgroundColor3 = C.subtext, BorderSizePixel = 0 }, dots)
round(d, 2)
end
end
local function updateModelChip()
local m = MODELS[modelIndex]
modelName.Text = m.short
local chars = 0
if current then
for _, msg in ipairs(current.messages) do
if msg.text then chars += #msg.text end
for _, t in ipairs(msg.tools or {}) do
if t.label then chars += #t.label end
end
if msg.images then chars += #msg.images * 4000 end
end
end
local pct = math.clamp(math.floor((chars / 4) / (m.context or 1000000) * 100 + 0.5), 0, 99)
contextPct.Text = pct .. "%"
local col = pct >= 85 and C.error or (pct >= 60 and Color3.fromRGB(230, 180, 80) or Color3.fromRGB(106, 200, 145))
contextPct.TextColor3 = col
modelDot.BackgroundColor3 = col
end
local sidebar = new("Frame", {
Name = "Sidebar", Size = UDim2.new(0, SIDEBAR_W, 1, 0), BackgroundColor3 = C.sidebar,
ClipsDescendants = true, ZIndex = small and 20 or 1,
}, window)
round(sidebar, 16)
new("Frame", { Size = UDim2.new(0, 16, 1, 0), Position = UDim2.new(1, -16, 0, 0),
BackgroundColor3 = C.sidebar, BorderSizePixel = 0 }, sidebar)
new("Frame", { Size = UDim2.new(0, 1, 1, 0), Position = UDim2.new(1, -1, 0, 0),
BackgroundColor3 = C.stroke, BorderSizePixel = 0 }, sidebar)
local sideInner = new("Frame", { BackgroundTransparency = 1, Size = UDim2.new(0, SIDEBAR_W, 1, 0) }, sidebar)
local brandSpark = brandLogo(sideInner, MODELS[1], 22)
brandSpark.Position = UDim2.fromOffset(18, 17)
text({ Position = UDim2.fromOffset(50, 12), Size = UDim2.fromOffset(100, 32), Text = "Chat",
Font = SERIF, TextSize = 20, TextWrapped = false }, sideInner)
local sideCloseBtn = button({
AnchorPoint = Vector2.new(1, 0), Position = UDim2.new(1, -14, 0, 12),
Size = UDim2.fromOffset(28, 28), BackgroundColor3 = C.hover,
}, sideInner)
round(sideCloseBtn, 8)
hover(sideCloseBtn, C.hover, 0)
icon("cross", sideCloseBtn, C.subtext, 14)
local newBtn = button({
Position = UDim2.fromOffset(12, 50), Size = UDim2.new(1, -24, 0, 36), BackgroundColor3 = C.panel,
}, sideInner)
round(newBtn, 10)
stroke(newBtn, C.stroke)
hover(newBtn, C.hover, 0)
local plusHolder = new("Frame", { BackgroundTransparency = 1, Position = UDim2.fromOffset(10, 9),
Size = UDim2.fromOffset(20, 20) }, newBtn)
local plusIcon = icon("plus", plusHolder, MODELS[1].accent, 16)
text({ Position = UDim2.fromOffset(38, 0), Size = UDim2.new(1, -44, 1, 0), Text = "New chat",
Font = Enum.Font.GothamMedium, TextSize = 14, TextWrapped = false }, newBtn)
local searchBox = new("TextBox", {
Position = UDim2.fromOffset(12, 104), Size = UDim2.new(1, -24, 0, 32), BackgroundColor3 = C.bg,
Text = "", PlaceholderText = "Search chats", PlaceholderColor3 = C.subtext,
TextColor3 = C.text, TextSize = 13, Font = Enum.Font.Gotham, ClearTextOnFocus = false,
TextXAlignment = Enum.TextXAlignment.Left,
}, sideInner)
round(searchBox, 8)
pad(searchBox, 0, 10, 0, 12)
local convoList = new("ScrollingFrame", {
Position = UDim2.fromOffset(8, 210), Size = UDim2.new(1, -16, 1, -284), BackgroundTransparency = 1,
BorderSizePixel = 0, ScrollBarThickness = 3, ScrollBarImageColor3 = C.stroke,
CanvasSize = UDim2.new(), AutomaticCanvasSize = Enum.AutomaticSize.Y,
}, sideInner)
new("UIListLayout", { Padding = UDim.new(0, 4), SortOrder = Enum.SortOrder.LayoutOrder }, convoList)
local sideProviderLabel
local userCard = new("Frame", {
Position = UDim2.new(0, 10, 1, -58), Size = UDim2.new(1, -20, 0, 48), BackgroundColor3 = C.panel,
}, sideInner)
round(userCard, 12)
local avatar = new("ImageLabel", { Position = UDim2.fromOffset(8, 8), Size = UDim2.fromOffset(32, 32),
BackgroundColor3 = C.hover, Image = "" }, userCard)
round(avatar, 16)
task.spawn(function()
local ok, img = pcall(function()
return Players:GetUserThumbnailAsync(player.UserId, Enum.ThumbnailType.HeadShot, Enum.ThumbnailSize.Size100x100)
end)
if ok then avatar.Image = img end
end)
text({ Position = UDim2.fromOffset(48, 6), Size = UDim2.new(1, -56, 0, 20), Text = player.DisplayName,
Font = Enum.Font.GothamMedium, TextSize = 13, TextWrapped = false, TextTruncate = Enum.TextTruncate.AtEnd }, userCard)
sideProviderLabel = text({ Position = UDim2.fromOffset(48, 24), Size = UDim2.new(1, -56, 0, 16),
Text = providerLabel(MODELS[1]),
TextSize = 11, TextColor3 = C.subtext, TextWrapped = false, TextTruncate = Enum.TextTruncate.AtEnd }, userCard)
local settingsBtn, layoutSidebar, workspaceNav
local workspacePanel, toggleWorkspace, workspaceRefresh
do
local nav = new("Frame", { Name = "Nav", Position = UDim2.fromOffset(8, 98), Size = UDim2.new(1, -16, 0, 0),
AutomaticSize = Enum.AutomaticSize.Y, BackgroundTransparency = 1 }, sideInner)
new("UIListLayout", { Padding = UDim.new(0, 1), SortOrder = Enum.SortOrder.LayoutOrder }, nav)
local order, subs = 0, {}
local function navRow(label, kind, indent)
indent = indent or 0
order = order + 1
local row = button({ Size = UDim2.new(1, 0, 0, 28), LayoutOrder = order }, nav)
round(row, 7)
hover(row, C.hover, 0.45)
local ic = new("Frame", { BackgroundTransparency = 1, Position = UDim2.fromOffset(10 + indent, 6),
Size = UDim2.fromOffset(16, 16) }, row)
icon(kind, ic, C.subtext, 16)
text({ Position = UDim2.fromOffset(34 + indent, 0), Size = UDim2.new(1, -44 - indent, 1, 0),
Text = label, TextSize = 13, TextColor3 = C.subtext, TextWrapped = false }, row)
return row
end
navRow("Customize", "settings").MouseButton1Click:Connect(function() openSettings("Appearance") end)
workspaceNav = navRow("Workspace", "folder")
workspaceNav.MouseButton1Click:Connect(function()
if toggleWorkspace then toggleWorkspace() end
end)
local moreRow = navRow("More", "code")
local chevIcon = icon("chevron", moreRow, C.subtext, 12)
chevIcon.AnchorPoint = Vector2.new(1, 0.5)
chevIcon.Position = UDim2.new(1, -12, 0.5, 0)
chevIcon.Rotation = -90
local function subRow(label, kind, page)
local row = navRow(label, kind, 14)
row.Visible = false
row.MouseButton1Click:Connect(function() openSettings(page) end)
table.insert(subs, row)
end
subRow("Providers", "globe", "Providers")
subRow("Tools", "terminal", "Permissions")
settingsBtn = navRow("Settings", "settings")
settingsBtn.MouseButton1Click:Connect(function() openSettings("General") end)
local moreOpen = false
moreRow.MouseButton1Click:Connect(function()
moreOpen = not moreOpen
for _, r in ipairs(subs) do r.Visible = moreOpen end
tween(chevIcon, 0.16, { Rotation = moreOpen and 0 or -90 })
task.defer(function() layoutSidebar() end)
end)
layoutSidebar = function()
local visible = 0
for _, ch in ipairs(nav:GetChildren()) do
if ch:IsA("GuiObject") and ch.Visible then visible = visible + 1 end
end
local top = 98 + visible * 28 + math.max(0, visible - 1) + 10
searchBox.Position = UDim2.fromOffset(12, top)
convoList.Position = UDim2.fromOffset(8, top + 42)
local total = sideInner.AbsoluteSize.Y
if total <= 0 then total = window.AbsoluteSize.Y end
if total <= 0 then total = 560 end
local height = total - (top + 42) - 74
if height < 60 then height = 60 end
convoList.Size = UDim2.new(1, -16, 0, height)
end
sideInner:GetPropertyChangedSignal("AbsoluteSize"):Connect(function() layoutSidebar() end)
nav:GetPropertyChangedSignal("AbsoluteSize"):Connect(function() layoutSidebar() end)
task.defer(function() layoutSidebar() end)
end
local toggleBtn = button({
AnchorPoint = Vector2.new(0, 0.5), Position = UDim2.new(0, 12, 0.5, 0),
Size = UDim2.fromOffset(46, 46), BackgroundColor3 = C.sidebar,
}, gui)
round(toggleBtn, 23)
stroke(toggleBtn)
local toggleSpark = brandLogo(toggleBtn, MODELS[1], 22)
toggleSpark.Position = UDim2.fromOffset(12, 12)
do
local WS = { expanded = {}, selected = nil, order = 0, view = "tree", source = nil,
cache = { at = 0, items = nil } }
local SCRIPT_CLASSES = { Script = true, LocalScript = true, ModuleScript = true }
local REMOTE_CLASSES = { RemoteEvent = true, RemoteFunction = true, BindableEvent = true,
BindableFunction = true, UnreliableRemoteEvent = true }
local ROWS_CAP = 300
local root = new("Frame", { Name = "Workspace", Position = UDim2.fromOffset(0, 52),
Size = UDim2.new(1, 0, 1, -52), BackgroundColor3 = C.bg, Visible = false, ZIndex = 8 }, main)
workspacePanel = root
local bar = new("Frame", { Size = UDim2.new(1, 0, 0, 40), BackgroundColor3 = C.bg }, root)
new("Frame", { Position = UDim2.new(0, 0, 1, -1), Size = UDim2.new(1, 0, 0, 1),
BackgroundColor3 = C.stroke, BorderSizePixel = 0 }, bar)
local tabs = new("Frame", { Position = UDim2.fromOffset(10, 9), Size = UDim2.fromOffset(170, 22),
BackgroundTransparency = 1 }, bar)
new("UIListLayout", { FillDirection = Enum.FillDirection.Horizontal, Padding = UDim.new(0, 4),
SortOrder = Enum.SortOrder.LayoutOrder, VerticalAlignment = Enum.VerticalAlignment.Center }, tabs)
local chipButtons = {}
for i, name in ipairs({ "Tree", "Scripts", "Remotes" }) do
local chip = button({ Size = UDim2.fromOffset(52, 22), BackgroundColor3 = C.hover,
Text = name, TextSize = 10, LayoutOrder = i }, tabs)
round(chip, 6)
hover(chip, C.hover, 0)
chipButtons[name] = chip
end
local filter = new("TextBox", { Position = UDim2.fromOffset(188, 9), Size = UDim2.new(1, -374, 0, 22),
BackgroundColor3 = C.panel, Text = "", PlaceholderText = "Filter by name or class",
PlaceholderColor3 = C.subtext, TextColor3 = C.text, TextSize = 11,
TextXAlignment = Enum.TextXAlignment.Left, ClearTextOnFocus = false }, bar)
round(filter, 6)
pad(filter, 0, 8, 0, 8)
local collapseBtn = button({ AnchorPoint = Vector2.new(1, 0), Position = UDim2.new(1, -114, 0, 9),
Size = UDim2.fromOffset(62, 22), BackgroundColor3 = C.panel }, bar)
round(collapseBtn, 6)
hover(collapseBtn, C.hover, 0)
text({ Size = UDim2.fromScale(1, 1), Text = "Collapse", TextSize = 10, TextColor3 = C.subtext,
TextXAlignment = Enum.TextXAlignment.Center, TextWrapped = false }, collapseBtn)
local refreshBtn = button({ AnchorPoint = Vector2.new(1, 0), Position = UDim2.new(1, -46, 0, 9),
Size = UDim2.fromOffset(62, 22), BackgroundColor3 = C.panel }, bar)
round(refreshBtn, 6)
hover(refreshBtn, C.hover, 0)
text({ Size = UDim2.fromScale(1, 1), Text = "Refresh", TextSize = 10, TextColor3 = C.subtext,
TextXAlignment = Enum.TextXAlignment.Center, TextWrapped = false }, refreshBtn)
local closeWs = button({ AnchorPoint = Vector2.new(1, 0), Position = UDim2.new(1, -10, 0, 9),
Size = UDim2.fromOffset(26, 22), BackgroundColor3 = C.hover }, bar)
round(closeWs, 6)
hover(closeWs, C.hover, 0)
icon("cross", closeWs, C.subtext, 12)
local list = new("ScrollingFrame", { Position = UDim2.fromOffset(0, 40), Size = UDim2.new(0.56, 0, 1, -40),
BackgroundTransparency = 1, BorderSizePixel = 0, ScrollBarThickness = 3,
ScrollBarImageColor3 = C.stroke, CanvasSize = UDim2.new(),
AutomaticCanvasSize = Enum.AutomaticSize.Y }, root)
new("UIListLayout", { Padding = UDim.new(0, 1), SortOrder = Enum.SortOrder.LayoutOrder }, list)
local detail = new("Frame", { Position = UDim2.new(0.56, 0, 0, 40), Size = UDim2.new(0.44, 0, 1, -40),
BackgroundColor3 = C.sidebar }, root)
new("Frame", { Size = UDim2.new(0, 1, 1, 0), BackgroundColor3 = C.stroke, BorderSizePixel = 0 }, detail)
local title = text({ Position = UDim2.fromOffset(12, 10), Size = UDim2.new(1, -24, 0, 18),
Text = "No instance selected", Font = Enum.Font.GothamBold, TextSize = 12,
TextColor3 = C.text, TextWrapped = false, TextTruncate = Enum.TextTruncate.AtEnd }, detail)
local subtitle = text({ Position = UDim2.fromOffset(12, 28), Size = UDim2.new(1, -24, 0, 16),
Text = "Pick something to inspect it.", TextSize = 10, Font = Enum.Font.Code,
TextColor3 = C.subtext, TextWrapped = false, TextTruncate = Enum.TextTruncate.AtEnd }, detail)
local propList = new("ScrollingFrame", { Position = UDim2.fromOffset(8, 50),
Size = UDim2.new(1, -16, 1, -58), BackgroundTransparency = 1, BorderSizePixel = 0,
ScrollBarThickness = 3, ScrollBarImageColor3 = C.stroke, CanvasSize = UDim2.new(),
AutomaticCanvasSize = Enum.AutomaticSize.Y }, detail)
new("UIListLayout", { Padding = UDim.new(0, 2), SortOrder = Enum.SortOrder.LayoutOrder }, propList)
local sourceScroll = new("ScrollingFrame", { Position = UDim2.fromOffset(8, 50),
Size = UDim2.new(1, -16, 1, -58), BackgroundColor3 = C.code, BorderSizePixel = 0,
ScrollBarThickness = 3, ScrollBarImageColor3 = C.stroke, CanvasSize = UDim2.new(),
AutomaticCanvasSize = Enum.AutomaticSize.Y, Visible = false }, detail)
round(sourceScroll, 6)
local sourceLabel = text({ Position = UDim2.fromOffset(10, 8), Size = UDim2.new(1, -20, 0, 0),
AutomaticSize = Enum.AutomaticSize.Y, Text = "", Font = Enum.Font.Code, TextSize = 11,
TextColor3 = C.text, TextWrapped = false }, sourceScroll)
local copySource = button({ AnchorPoint = Vector2.new(1, 0), Position = UDim2.new(1, -10, 0, 8),
Size = UDim2.fromOffset(48, 18), BackgroundColor3 = C.hover, Text = "Copy", TextSize = 9,
Visible = false, ZIndex = 3 }, detail)
round(copySource, 5)
hover(copySource, C.panel, 0)
local function classNameOf(inst)
local ok, value = pcall(function() return inst.ClassName end)
if ok and type(value) == "string" and value ~= "" then return value end
return "Instance"
end
local function pathOf(inst)
local ok, full = pcall(function() return inst:GetFullName() end)
if ok and type(full) == "string" and full ~= "" then return full end
return tostring(inst.Name or "?")
end
local function formatValue(v)
local t = typeof(v)
if t == "Instance" then
return pathOf(v)
end
if t == "Vector3" then
return string.format("(%.2f, %.2f, %.2f)", v.X, v.Y, v.Z)
end
if t == "Vector2" then
return string.format("(%.2f, %.2f)", v.X, v.Y)
end
if t == "Color3" then
return string.format("%d, %d, %d", math.floor(v.R * 255 + 0.5), math.floor(v.G * 255 + 0.5),
math.floor(v.B * 255 + 0.5))
end
if t == "UDim2" then
return string.format("%d,%d,%d,%d", v.X.Scale, v.X.Offset, v.Y.Scale, v.Y.Offset)
end
if t == "CFrame" then
local p = v.Position
return string.format("(%.1f, %.1f, %.1f)", p.X, p.Y, p.Z)
end
local ok, s = pcall(tostring, v)
if not ok then return "<" .. t .. ">" end
if #s > 90 then s = s:sub(1, 87) .. "..." end
return s
end
local function parseValue(raw, current)
local value = tostring(raw or "")
local t = typeof(current)
if t == "boolean" then return value:lower() == "true" or value == "1" end
if t == "number" then return tonumber(value) end
if t == "Vector3" then
local x, y, z = value:match("([%-%d%.]+)%s*,%s*([%-%d%.]+)%s*,%s*([%-%d%.]+)")
if x then return Vector3.new(tonumber(x), tonumber(y), tonumber(z)) end
return current
end
if t == "Vector2" then
local x, y = value:match("([%-%d%.]+)%s*,%s*([%-%d%.]+)")
if x then return Vector2.new(tonumber(x), tonumber(y)) end
return current
end
if t == "Color3" then
local r, g, b = value:match("(%d+)%s*,%s*(%d+)%s*,%s*(%d+)")
if r then return Color3.fromRGB(tonumber(r), tonumber(g), tonumber(b)) end
return current
end
if t == "UDim2" then
local xs, xo, ys, yo = value:match("([%-%d%.]+)%s*,%s*([%-%d%.]+)%s*,%s*([%-%d%.]+)%s*,%s*([%-%d%.]+)")
if xs then return UDim2.new(tonumber(xs), tonumber(xo), tonumber(ys), tonumber(yo)) end
return current
end
return value
end
local KNOWN_PROPS = { "Name", "Position", "Size", "CFrame", "Anchored", "CanCollide", "Transparency",
"Color", "BackgroundColor3", "BackgroundTransparency", "Text", "TextColor3", "TextSize", "Visible",
"Enabled", "Value", "Health", "MaxHealth", "WalkSpeed", "JumpPower", "Material", "CanTouch",
"Shape", "Orientation", "Massless", "Locked", "Disabled", "Parent" }
local function propsFor(inst)
local list, seen = {}, {}
local ok, props = pcall(function() return inst:GetProperties() end)
if ok and type(props) == "table" then
for _, name in ipairs(props) do
if not seen[name] then
seen[name] = true
local okGet = pcall(function() return inst[name] end)
if okGet then list[#list + 1] = name end
end
end
end
for _, name in ipairs(KNOWN_PROPS) do
if not seen[name] then
local okGet = pcall(function() return inst[name] end)
if okGet then
seen[name] = true
list[#list + 1] = name
end
end
end
table.sort(list)
return list
end
local function allInstances()
local now = os.clock()
if WS.cache.items and now - WS.cache.at < 6 then return WS.cache.items end
local items, frontier, guard = {}, { game }, 0
while #frontier > 0 and #items < 5000 and guard < 6000 do
guard = guard + 1
local node = table.remove(frontier, 1)
local ok, children = pcall(function() return node:GetChildren() end)
if ok and type(children) == "table" then
for _, child in ipairs(children) do
items[#items + 1] = child
local okKids, kids = pcall(function() return child:GetChildren() end)
if okKids and type(kids) == "table" and #kids > 0 then
frontier[#frontier + 1] = child
end
end
end
end
WS.cache.items, WS.cache.at = items, now
return items
end
local function readSource(inst)
local ok, value = pcall(function()
return API.withIdentity(function() return inst.Source end)
end)
if not ok then return nil, tostring(value):sub(1, 180) end
if type(value) ~= "string" or value == "" then return nil, "No readable Source on this instance." end
return value
end
local function clearDetail()
for _, ch in ipairs(propList:GetChildren()) do
if ch:IsA("GuiObject") then ch:Destroy() end
end
end
local function propRow(order, name, inst, current, editable)
local rowBox = new("Frame", { Size = UDim2.new(1, 0, 0, 24), BackgroundColor3 = C.panel,
LayoutOrder = order }, propList)
round(rowBox, 5)
text({ Position = UDim2.fromOffset(8, 4), Size = UDim2.new(0.42, -8, 0, 16), Text = name,
TextSize = 10, Font = Enum.Font.Code, TextColor3 = C.subtext,
TextWrapped = false, TextTruncate = Enum.TextTruncate.AtEnd }, rowBox)
local valueLabel = text({ Position = UDim2.new(0.42, 0, 0, 0), Size = UDim2.new(0.58, -46, 1, 0),
Text = formatValue(current), TextSize = 10, Font = Enum.Font.Code,
TextColor3 = C.text, TextWrapped = false,
TextTruncate = Enum.TextTruncate.AtEnd }, rowBox)
if not editable then return end
local editor = new("TextBox", { Position = UDim2.new(0.42, -2, 0.5, 0),
Size = UDim2.new(0.58, -44, 0, 18), BackgroundColor3 = C.bg, BackgroundTransparency = 1,
Text = "", TextSize = 10, Font = Enum.Font.Code, TextColor3 = C.text,
ClearTextOnFocus = false, Visible = false }, rowBox)
pad(editor, 0, 5, 0, 3)
local editBtn = button({ AnchorPoint = Vector2.new(1, 0.5),
Position = UDim2.new(1, -5, 0.5, 0), Size = UDim2.fromOffset(36, 18),
BackgroundColor3 = C.hover, Text = "Edit", TextSize = 9 }, rowBox)
round(editBtn, 4)
hover(editBtn, C.bg, 0)
local editing = false
local function commit()
if not editing then return end
editing = false
editor.Visible = false
valueLabel.Visible = true
local typed = editor.Text
local okSet = pcall(function() inst[name] = parseValue(typed, inst[name]) end)
if okSet then
valueLabel.Text = formatValue(inst[name])
else
valueLabel.Text = "cannot set"
end
end
editor.FocusLost:Connect(commit)
editBtn.MouseButton1Click:Connect(function()
editing = true
editor.Text = formatValue(inst[name])
valueLabel.Visible = false
editor.Visible = true
editor:CaptureFocus()
end)
end
local function showProps(inst)
WS.selected = inst
sourceScroll.Visible = false
copySource.Visible = false
propList.Visible = true
title.Text = inst.Name .. " · " .. classNameOf(inst)
subtitle.Text = pathOf(inst)
clearDetail()
local order = 0
order = order + 1
propRow(order, "ClassName", inst, classNameOf(inst), false)
order = order + 1
propRow(order, "Name", inst, inst.Name, true)
for _, name in ipairs(propsFor(inst)) do
if name ~= "ClassName" and name ~= "Name" and name ~= "Parent" then
local okGet, current = pcall(function() return inst[name] end)
if okGet then
order = order + 1
propRow(order, name, inst, current, true)
end
end
end
local kids = {}
pcall(function() kids = inst:GetChildren() end)
order = order + 1
local kidRow = new("Frame", { Size = UDim2.new(1, 0, 0, 22), BackgroundTransparency = 1,
LayoutOrder = order }, propList)
text({ Size = UDim2.new(1, -8, 1, 0), Text = tostring(#kids) .. " children",
TextSize = 10, TextColor3 = C.subtext, TextWrapped = false }, kidRow)
for i, child in ipairs(kids) do
if i > 12 then
order = order + 1
local more = new("Frame", { Size = UDim2.new(1, 0, 0, 18), BackgroundTransparency = 1,
LayoutOrder = order }, propList)
text({ Size = UDim2.new(1, -8, 1, 0), Text = " + " .. tostring(#kids - 12) .. " more",
TextSize = 9, TextColor3 = C.subtext, TextWrapped = false }, more)
break
end
order = order + 1
local childBtn = button({ Size = UDim2.new(1, 0, 0, 20), BackgroundTransparency = 1,
LayoutOrder = order }, propList)
round(childBtn, 4)
hover(childBtn, C.hover, 0.5)
pad(childBtn, 0, 6, 0, 8)
text({ Position = UDim2.fromOffset(4, 3), Size = UDim2.new(1, -8, 0, 14),
Text = child.Name, TextSize = 10, TextColor3 = C.subtext,
TextWrapped = false, TextTruncate = Enum.TextTruncate.AtEnd }, childBtn)
childBtn.MouseButton1Click:Connect(function()
if SCRIPT_CLASSES[classNameOf(child)] then
showSource(child)
else
showProps(child)
end
end)
end
end
local function showSource(inst)
WS.selected = inst
propList.Visible = false
sourceScroll.Visible = true
copySource.Visible = true
sourceScroll.CanvasPosition = Vector2.new(0, 0)
title.Text = inst.Name .. " · " .. classNameOf(inst)
subtitle.Text = pathOf(inst)
clearDetail()
local source, err = readSource(inst)
if not source then
WS.source = nil
copySource.Visible = false
sourceLabel.TextColor3 = C.subtext
sourceLabel.Text = "-- " .. tostring(err or "Source is not readable here.")
return
end
WS.source = source
sourceLabel.TextColor3 = C.text
local shown = source
if #shown > 20000 then
shown = shown:sub(1, 20000) .. "\n-- [truncated here, Copy still copies the whole script]"
end
sourceLabel.Text = shown
end
local function select(inst)
if SCRIPT_CLASSES[classNameOf(inst)] then
showSource(inst)
else
showProps(inst)
end
end
local function treeRow(depth, inst)
local children = {}
pcall(function() children = inst:GetChildren() end)
WS.order = WS.order + 1
local item = button({ Size = UDim2.new(1, 0, 0, 20), BackgroundTransparency = 1,
LayoutOrder = WS.order }, list)
round(item, 4)
hover(item, C.hover, 0.4)
pad(item, 0, 6, 0, 4 + depth * 10)
if #children > 0 then
local chev = button({ Position = UDim2.fromOffset(2, 3), Size = UDim2.fromOffset(14, 14),
BackgroundTransparency = 1 }, item)
local arrow = icon("chevron", chev, WS.selected == inst and C.text or C.subtext, 8)
arrow.Rotation = WS.expanded[inst] and 90 or 0
chev.MouseButton1Click:Connect(function()
WS.expanded[inst] = not WS.expanded[inst]
if WS.render then WS.render() end
end)
end
local dot = new("Frame", { Position = UDim2.fromOffset(19, 7), Size = UDim2.fromOffset(5, 5),
BackgroundColor3 = WS.selected == inst and MODELS[modelIndex].accent or C.stroke,
BorderSizePixel = 0 }, item)
round(dot, 3)
text({ Position = UDim2.fromOffset(30, 0), Size = UDim2.new(1, -56, 1, 0), Text = inst.Name,
TextSize = 10, TextColor3 = WS.selected == inst and C.text or C.subtext,
TextWrapped = false, TextTruncate = Enum.TextTruncate.AtEnd }, item)
if #children > 0 then
text({ AnchorPoint = Vector2.new(1, 0.5), Position = UDim2.new(1, -8, 0.5, 0),
Size = UDim2.fromOffset(28, 14), Text = tostring(#children), TextSize = 9,
TextColor3 = C.subtext, TextXAlignment = Enum.TextXAlignment.Right,
TextWrapped = false }, item)
end
item.MouseButton1Click:Connect(function()
WS.selected = inst
if #children > 0 and not WS.expanded[inst] then
WS.expanded[inst] = true
end
if WS.render then WS.render() end
select(inst)
end)
end
local function flatRow(inst, hint)
WS.order = WS.order + 1
local item = button({ Size = UDim2.new(1, 0, 0, 26), BackgroundTransparency = 1,
LayoutOrder = WS.order }, list)
round(item, 4)
hover(item, C.hover, 0.4)
pad(item, 0, 8, 0, 12)
local dot = new("Frame", { Position = UDim2.fromOffset(2, 10), Size = UDim2.fromOffset(6, 6),
BackgroundColor3 = WS.selected == inst and MODELS[modelIndex].accent or C.stroke,
BorderSizePixel = 0 }, item)
round(dot, 3)
text({ Position = UDim2.fromOffset(14, 3), Size = UDim2.new(1, -84, 0, 14), Text = inst.Name,
TextSize = 10, TextColor3 = WS.selected == inst and C.text or C.subtext,
TextWrapped = false, TextTruncate = Enum.TextTruncate.AtEnd }, item)
text({ Position = UDim2.fromOffset(14, 14), Size = UDim2.new(1, -20, 0, 12),
Text = pathOf(inst), TextSize = 8, TextColor3 = C.subtext,
TextWrapped = false, TextTruncate = Enum.TextTruncate.AtEnd }, item)
if hint then
text({ AnchorPoint = Vector2.new(1, 0.5), Position = UDim2.new(1, -8, 0.5, 0),
Size = UDim2.fromOffset(70, 14), Text = hint, TextSize = 8,
TextColor3 = C.subtext, TextXAlignment = Enum.TextXAlignment.Right,
TextWrapped = false, TextTruncate = Enum.TextTruncate.AtEnd }, item)
end
item.MouseButton1Click:Connect(function()
WS.selected = inst
if WS.render then WS.render() end
select(inst)
end)
end
local function noteLabel(value)
text({ Size = UDim2.new(1, 0, 0, 26), Text = " " .. value, TextSize = 10,
TextColor3 = C.subtext, TextWrapped = false }, list)
end
local function matches(inst, query)
if query == "" then return true end
if tostring(inst.Name):lower():find(query, 1, true) then return true end
return classNameOf(inst):lower():find(query, 1, true) ~= nil
end
local function renderTree()
local query = filter.Text:lower():gsub("^%s+", ""):gsub("%s+$", "")
if query ~= "" then
local hits = 0
for _, inst in ipairs(allInstances()) do
if matches(inst, query) then
hits = hits + 1
if hits > ROWS_CAP then break end
treeRow(1, inst)
end
end
if hits == 0 then
noteLabel("Nothing matches that filter.")
elseif hits > ROWS_CAP then
noteLabel("Showing the first " .. tostring(ROWS_CAP) .. " matches.")
end
return
end
noteLabel("Root: " .. gameName)
local function walk(node, depth)
if depth > 12 then return end
local children = {}
pcall(function() children = node:GetChildren() end)
for _, child in ipairs(children) do
if WS.order >= ROWS_CAP then return end
treeRow(depth, child)
if WS.expanded[child] then walk(child, depth + 1) end
end
end
walk(game, 1)
if WS.order == 0 then noteLabel("This place has no instances") end
if WS.order >= ROWS_CAP then
noteLabel("Showing the first " .. tostring(ROWS_CAP) .. " nodes. Filter to narrow.")
end
end
local function renderScripts()
local query = filter.Text:lower():gsub("^%s+", ""):gsub("%s+$", "")
local hits, total = {}, 0
for _, inst in ipairs(allInstances()) do
if SCRIPT_CLASSES[classNameOf(inst)] then
total = total + 1
if matches(inst, query) then hits[#hits + 1] = inst end
end
end
if total == 0 then
noteLabel("No scripts were found in this place.")
return
end
table.sort(hits, function(a, b)
return tostring(a.Name):lower() < tostring(b.Name):lower()
end)
noteLabel(tostring(total) .. (total == 1 and " script" or " scripts")
.. " in this place. Select one to read its source.")
for i, inst in ipairs(hits) do
if i > ROWS_CAP then break end
flatRow(inst, classNameOf(inst))
end
if #hits == 0 then noteLabel("Nothing matches that filter.") end
end
local function renderRemotes()
local query = filter.Text:lower():gsub("^%s+", ""):gsub("%s+$", "")
local hits, total = {}, 0
for _, inst in ipairs(allInstances()) do
if REMOTE_CLASSES[classNameOf(inst)] then
total = total + 1
if matches(inst, query) then hits[#hits + 1] = inst end
end
end
if total == 0 then
noteLabel("No RemoteEvent, RemoteFunction or Bindable instances found.")
return
end
table.sort(hits, function(a, b)
return tostring(a.Name):lower() < tostring(b.Name):lower()
end)
noteLabel(tostring(total) .. (total == 1 and " remote" or " remotes")
.. ". Select one to inspect its properties.")
for i, inst in ipairs(hits) do
if i > ROWS_CAP then break end
flatRow(inst, classNameOf(inst))
end
if #hits == 0 then noteLabel("Nothing matches that filter.") end
end
local function paintChips()
for name, chip in pairs(chipButtons) do
local on = WS.view == string.lower(name)
chip.BackgroundTransparency = on and 0 or 1
chip.TextColor3 = on and C.text or C.subtext
end
collapseBtn.Visible = WS.view == "tree"
filter.PlaceholderText = WS.view == "tree" and "Filter by name or class"
or ("Filter " .. WS.view)
end
WS.render = function(keepScroll)
local scrollY = list.CanvasPosition.Y
for _, ch in ipairs(list:GetChildren()) do
if ch:IsA("GuiObject") then ch:Destroy() end
end
WS.order = 0
if WS.view == "scripts" then
renderScripts()
elseif WS.view == "remotes" then
renderRemotes()
else
renderTree()
end
if keepScroll ~= false and scrollY > 0 then
list.CanvasPosition = Vector2.new(0, scrollY)
end
end
WS.renderTree = WS.render
local function setView(name)
WS.view = name
paintChips()
if root.Visible then WS.render(false) end
end
for name, chip in pairs(chipButtons) do
chip.MouseButton1Click:Connect(function() setView(string.lower(name)) end)
end
collapseBtn.MouseButton1Click:Connect(function()
WS.expanded = {}
if WS.render then WS.render(false) end
end)
workspaceRefresh = function()
if root.Visible and WS.render then WS.render() end
end
local navLabel
for _, ch in ipairs(workspaceNav:GetChildren()) do
if ch:IsA("TextLabel") then navLabel = ch end
end
local function setOpen(open)
root.Visible = open
if navLabel then navLabel.TextColor3 = open and C.text or C.subtext end
if open then
titleLabel.Text = "Workspace"
subtitleLabel.Text = gameName .. " · " .. WS.view .. " explorer"
paintChips()
WS.render(false)
else
titleLabel.Text = current and current.title or "New chat"
updateHeaderSubtitle()
end
end
toggleWorkspace = function()
setOpen(not root.Visible)
end
copySource.MouseButton1Click:Connect(function()
if WS.source and copyToClipboard(WS.source) then
copySource.Text = "Copied"
task.delay(1.2, function()
if copySource.Parent then copySource.Text = "Copy" end
end)
end
end)
filter:GetPropertyChangedSignal("Text"):Connect(function()
if WS.render then WS.render(false) end
end)
refreshBtn.MouseButton1Click:Connect(function()
WS.cache.items, WS.cache.at = nil, 0
if WS.render then WS.render() end
end)
closeWs.MouseButton1Click:Connect(function()
setOpen(false)
end)
paintChips()
task.defer(function()
if workspacePanel and not workspacePanel.Visible then WS.render() end
end)
end
local readConsole
local CONSOLE
do
CONSOLE = {
log = {}, max = 300, start = os.clock(),
types = {
[Enum.MessageType.MessageOutput] = "output",
[Enum.MessageType.MessageInfo] = "info",
[Enum.MessageType.MessageWarning] = "warning",
[Enum.MessageType.MessageError] = "error",
},
}
pushLog = function(message, mtype, src)
table.insert(CONSOLE.log, { t = os.clock() - CONSOLE.start, kind = CONSOLE.types[mtype] or "output",
msg = tostring(message), src = src or "client" })
if #CONSOLE.log > CONSOLE.max then table.remove(CONSOLE.log, 1) end
end
pcall(function()
for _, e in ipairs(LogService:GetLogHistory()) do pushLog(e.message, e.messageType) end
end)
LogService.MessageOut:Connect(function(message, mtype) pushLog(message, mtype) end)
end
function readConsole(args)
args = args or {}
local onlyErrors = args.filter ~= "all"
local limit = math.clamp(tonumber(args.limit) or 40, 1, 100)
local picked = {}
for i = #CONSOLE.log, 1, -1 do
local e = CONSOLE.log[i]
if (not onlyErrors) or e.kind == "error" or e.kind == "warning" then
table.insert(picked, 1, e)
if #picked >= limit then break end
end
end
local lines, total, errors, warns = {}, 0, 0, 0
for _, e in ipairs(picked) do
if e.kind == "error" then errors += 1 elseif e.kind == "warning" then warns += 1 end
local m = e.msg
if #m > 400 then m = m:sub(1, 400) .. "...[truncated]" end
local line = string.format("[+%.1fs][%s][%s] %s", e.t, e.src, e.kind, m)
total += #line
if total > 6000 then
table.insert(lines, "...[more omitted]")
break
end
table.insert(lines, line)
end
return (#lines > 0 and table.concat(lines, "\n") or "(no matching console entries)"), errors, warns
end
local captureGame, encodePNG
do
local crcTable = {}
for i = 0, 255 do
local c = i
for _ = 1, 8 do
if bit32.band(c, 1) == 1 then c = bit32.bxor(bit32.rshift(c, 1), 0xEDB88320) else c = bit32.rshift(c, 1) end
end
crcTable[i] = c
end
local function crc32buf(b, from, to)
local c = 0xFFFFFFFF
for i = from, to do
c = bit32.bxor(crcTable[bit32.band(bit32.bxor(c, buffer.readu8(b, i)), 0xFF)], bit32.rshift(c, 8))
end
return bit32.bxor(c, 0xFFFFFFFF)
end
local function writeU32BE(b, off, v)
buffer.writeu8(b, off, bit32.band(bit32.rshift(v, 24), 255))
buffer.writeu8(b, off + 1, bit32.band(bit32.rshift(v, 16), 255))
buffer.writeu8(b, off + 2, bit32.band(bit32.rshift(v, 8), 255))
buffer.writeu8(b, off + 3, bit32.band(v, 255))
end
local B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
local B64c = {}
for i = 1, 64 do B64c[i - 1] = B64:sub(i, i) end
local function base64(b)
local len = buffer.len(b)
local out, n = {}, 0
for i = 0, len - 1, 3 do
local b1 = buffer.readu8(b, i)
local b2 = i + 1 < len and buffer.readu8(b, i + 1) or 0
local b3 = i + 2 < len and buffer.readu8(b, i + 2) or 0
local v = b1 * 65536 + b2 * 256 + b3
n += 1
out[n] = B64c[bit32.rshift(v, 18)] .. B64c[bit32.band(bit32.rshift(v, 12), 63)]
.. (i + 1 < len and B64c[bit32.band(bit32.rshift(v, 6), 63)] or "=")
.. (i + 2 < len and B64c[bit32.band(v, 63)] or "=")
end
return table.concat(out)
end
function encodePNG(img, targetW)
local sw, sh = img.Size.X, img.Size.Y
local tw = math.min(targetW, sw)
local th = math.max(1, math.floor(sh * tw / sw + 0.5))
local src = img:ReadPixelsBuffer(Vector2.zero, Vector2.new(sw, sh))
local stride = 1 + tw * 3
local rawLen = stride * th
local raw = buffer.create(rawLen)
for y = 0, th - 1 do
local sy = math.min(sh - 1, math.floor((y + 0.5) * sh / th))
local rowOff = y * stride
buffer.writeu8(raw, rowOff, 0)
for x = 0, tw - 1 do
local sx = math.min(sw - 1, math.floor((x + 0.5) * sw / tw))
local si = (sy * sw + sx) * 4
local di = rowOff + 1 + x * 3
buffer.writeu8(raw, di, buffer.readu8(src, si))
buffer.writeu8(raw, di + 1, buffer.readu8(src, si + 1))
buffer.writeu8(raw, di + 2, buffer.readu8(src, si + 2))
end
if y % 40 == 0 then task.wait() end
end
local a, bsum = 1, 0
for i = 0, rawLen - 1 do
a = (a + buffer.readu8(raw, i)) % 65521
bsum = (bsum + a) % 65521
end
local adler = bsum * 65536 + a
local blocks = math.ceil(rawLen / 65535)
local zLen = 2 + blocks * 5 + rawLen + 4
local out = buffer.create(8 + 25 + (12 + zLen) + 12)
local sig = { 137, 80, 78, 71, 13, 10, 26, 10 }
for i = 1, 8 do buffer.writeu8(out, i - 1, sig[i]) end
local p = 8
writeU32BE(out, p, 13); p += 4
local ihdr = p
buffer.writestring(out, p, "IHDR"); p += 4
writeU32BE(out, p, tw); p += 4
writeU32BE(out, p, th); p += 4
buffer.writeu8(out, p, 8); buffer.writeu8(out, p + 1, 2)
buffer.writeu8(out, p + 2, 0); buffer.writeu8(out, p + 3, 0); buffer.writeu8(out, p + 4, 0)
p += 5
writeU32BE(out, p, crc32buf(out, ihdr, p - 1)); p += 4
writeU32BE(out, p, zLen); p += 4
local idat = p
buffer.writestring(out, p, "IDAT"); p += 4
buffer.writeu8(out, p, 0x78); buffer.writeu8(out, p + 1, 0x01); p += 2
local pos = 0
while pos < rawLen do
local n = math.min(65535, rawLen - pos)
local nn = bit32.band(bit32.bnot(n), 0xFFFF)
buffer.writeu8(out, p, (pos + n >= rawLen) and 1 or 0)
buffer.writeu8(out, p + 1, bit32.band(n, 255)); buffer.writeu8(out, p + 2, bit32.rshift(n, 8))
buffer.writeu8(out, p + 3, bit32.band(nn, 255)); buffer.writeu8(out, p + 4, bit32.rshift(nn, 8))
p += 5
buffer.copy(out, p, raw, pos, n)
p += n
pos += n
end
writeU32BE(out, p, adler); p += 4
writeU32BE(out, p, crc32buf(out, idat, p - 1)); p += 4
writeU32BE(out, p, 0); p += 4
local iend = p
buffer.writestring(out, p, "IEND"); p += 4
writeU32BE(out, p, crc32buf(out, iend, p - 1))
return base64(out), tw, th
end
function captureGame()
local function encodedPNG(data)
if type(data) ~= "string" or #data < 64 then return nil end
if data:sub(1, 3) == "iVB" then return data end
if data:sub(2, 4) == "PNG" then return base64(buffer.fromstring(data)) end
return nil
end
local names = { "getScreenshot", "takeScreenshot", "screenshot", "captureScreenshot", "getScreenCapture" }
local function executorScreenshot()
local okEnv, env = pcall(function() return (getgenv and getgenv()) or _G end)
if not okEnv or type(env) ~= "table" then return nil end
for _, name in ipairs(names) do
local okFn, fn = pcall(function() return env[name] end)
if okFn and type(fn) == "function" then
local okOut, out = pcall(fn)
local encoded = okOut and encodedPNG(out)
if encoded then return encoded end
if okOut and type(out) == "string" and type(readfile) == "function" then
local okFile, data = pcall(readfile, out)
if okFile then
encoded = encodedPNG(data)
if encoded then return encoded end
end
end
end
end
return nil
end
gui.Enabled = false
task.wait(0.15)
local quick = executorScreenshot()
if quick then
gui.Enabled = true
return { base64 = quick, mediaType = "image/png" }
end
local contentId, done = nil, false
local okCap, errCap = pcall(function()
CaptureService:CaptureScreenshot(function(id)
contentId, done = id, true
end)
end)
local t0 = os.clock()
while okCap and not done and os.clock() - t0 < 5 do task.wait() end
gui.Enabled = true
if not okCap then return nil, tostring(errCap) end
if not contentId then return nil, "screenshot timed out" end
local okImg, img = pcall(function()
return AssetService:CreateEditableImageAsync(Content.fromUri(contentId))
end)
if not okImg then return nil, tostring(img) end
return img
end
end
local SCREENSHOT_WIDTH = 512
function captureAttachment()
local img, err = captureGame()
if not img then return nil, err end
if img.base64 then return img end
local ok, b64, w, h = pcall(encodePNG, img, SCREENSHOT_WIDTH)
if not ok then return nil, tostring(b64) end
return { editable = img, base64 = b64, mediaType = "image/png", w = w, h = h }
end
local TOOL_SCHEMAS = {
{
name = "read_console",
access = "read",
description = "Read recent Roblox console output (errors and warnings) from the running game. "
.. "Use RARELY: only when the user reports a bug, crash or unexpected behavior, or when runtime output is "
.. "truly needed to diagnose. Never for general questions. At most once per turn unless clearly insufficient.",
parameters = { filter = "'errors' (default) or 'all'", limit = "max entries, default 40" },
setting = "console",
},
{
name = "take_screenshot",
access = "read",
description = "Capture what the player currently sees in the game. Use only when visual state matters "
.. "(UI layout, rendering problems). Rarely needed.",
parameters = {},
setting = "screen",
},
{
name = "inspect_player",
access = "read",
description = "Look at the player and the live game. view='avatar' (default) returns their character: rig type, "
.. "body scales and colours, every accessory and clothing item, equipped tools, and attaches a picture "
.. "of the avatar. view='scene' returns the place, the players in the server and their distance, "
.. "your position, the camera, the time of day and the on-screen interfaces. Use this whenever the user "
.. "asks about their avatar, outfit, appearance, character, health, position, or what is around them.",
parameters = { view = "'avatar' (default) or 'scene'" },
},
{
name = "inspect_instance",
access = "read",
description = "Inspect an instance in the game by path (e.g. 'game.Workspace' or 'Players.LocalPlayer'). "
.. "Returns its class, key properties and children. Read only.",
parameters = { path = "dotted path to the instance" },
},
{
name = "read_script",
access = "read",
description = "Read the Source of a Script/LocalScript/ModuleScript by path. Returns the Luau source. "
.. "May be locked for some scripts.",
parameters = { path = "dotted path to the script" },
},
{
name = "read_file",
access = "read",
description = "Read a text file from your executor workspace by path.",
parameters = { path = "file path" },
},
{
name = "web_search",
access = "read",
description = "Search the web and return the top results with titles, URLs and snippets. "
.. "Use for current information, documentation, versions, prices, or anything you are unsure of. "
.. "When your answer relies on results, cite sources as markdown links: [title](https://example.com/page).",
parameters = { query = "search query" },
setting = "web",
},
{
name = "web_fetch",
access = "read",
description = "Fetch a web page by URL and return its readable text content. "
.. "Use to read in full a source found via web_search or a URL the user gave you.",
parameters = { url = "http(s) URL to read" },
setting = "web",
},
{
name = "set_property",
access = "write",
description = "Change a property on a game instance (e.g. Anchored, Transparency, Position). Modifies the live game.",
parameters = { path = "dotted path to the instance", property = "property name", value = "new value" },
},
{
name = "write_file",
access = "write",
description = "Write a text file to your executor workspace.",
parameters = { path = "file path", content = "file contents" },
},
{
name = "execute_luau",
access = "execute",
description = "Execute Luau code against the live game client and return printed output or errors. This runs real code.",
parameters = { code = "Luau source to run" },
},
}
function toolAccess(name)
for _, t in ipairs(TOOL_SCHEMAS) do if t.name == name then return t.access or "read" end end
return "read"
end
local SENSITIVE_FILES = { ["agentrouterrobloxai.json"] = true, [".env"] = true }
local function isSensitivePath(path)
local p = tostring(path or ""):lower():gsub("\\", "/")
return SENSITIVE_FILES[p:match("([^/]+)$") or p] == true
end
local function redactSecrets(text)
local s = tostring(text or "")
for _, key in ipairs({ settings.apiKey, settings.opencodeApiKey, settings.openrouterKey, AGENTROUTER_KEY, OPENCODE_KEY }) do
local k = tostring(key or ""):match("^%s*(.-)%s*$")
if #k >= 12 then
s = s:gsub(k:gsub("[%^%$%(%)%%%.%[%]%*%+%-%?]", "%%%1"), "[redacted]")
end
end
return s
end
function permissionDecision(access)
local p = settings.permission
if p == "readonly" then
return access == "read" and "run" or "deny"
end
-- A page the model just read is untrusted input. Nothing that changes state runs
-- without an explicit yes for the rest of that turn.
if access ~= "read" and webTainted then
return "confirm"
end
if p == "ask" then
return access == "read" and "run" or "confirm"
elseif p == "auto" then
return access == "execute" and "confirm" or "run"
end
return "run"
end
function findDeep(name, root, depth)
depth = depth or 0
if depth > 7 then return nil end
local ok, kids = pcall(function() return (root or workspace):GetChildren() end)
if not ok or not kids then return nil end
for _, ch in ipairs(kids) do
if ch.Name:lower() == name:lower() then return ch end
local f = findDeep(name, ch, depth + 1)
if f then return f end
end
return nil
end
local function resolveInstance(path)
if type(path) ~= "string" or path == "" then return nil end
local cleaned = path:gsub(":", ".")
local node = game
for seg in cleaned:gmatch("[^%.]+") do
if node == nil then return nil end
if node == game and seg:lower() == "game" then
node = game
elseif node == game and seg:lower() == "workspace" then
node = workspace
else
local nxt = nil
if node == game then
local ok, svc = pcall(function() return game:GetService(seg) end)
if ok and svc ~= nil then nxt = svc end
end
if nxt == nil then
local ok, ch = pcall(function() return node:FindFirstChild(seg) end)
if ok then nxt = ch end
end
node = nxt
end
end
if node == nil or node == game then node = findDeep((cleaned:gmatch("[^%.]+")() or cleaned)) or node end
if node == game then return nil end
return node
end
function confirmAction(toolName, access)
local resolved, decision = false, false
local overlay = new("Frame", { Name = "ConfirmOverlay", Size = UDim2.fromScale(1, 1), BackgroundColor3 = Color3.new(0, 0, 0), BackgroundTransparency = 0.5, ZIndex = 200 }, gui)
local box = new("Frame", { AnchorPoint = Vector2.new(0.5, 0.5), Position = UDim2.fromScale(0.5, 0.5), Size = UDim2.fromOffset(360, 176), BackgroundColor3 = C.panel, ZIndex = 201 }, overlay)
round(box, 14)
stroke(box)
text({ Position = UDim2.fromOffset(18, 16), Size = UDim2.new(1, -36, 0, 24), Text = "Allow this action?", Font = Enum.Font.GothamBold, TextSize = 17, ZIndex = 202 }, box)
text({ Position = UDim2.fromOffset(18, 48), Size = UDim2.new(1, -36, 0, 56), Text = tostring(toolName) .. "\nPermission level: " .. tostring(access) .. " · skips in 30s", TextSize = 13, TextColor3 = C.subtext, TextWrapped = true, ZIndex = 202 }, box)
local denyBtn = button({ Position = UDim2.new(0, 18, 1, -54), Size = UDim2.new(0.5, -27, 0, 36), BackgroundColor3 = C.hover, Text = "Deny", TextSize = 14, ZIndex = 202 }, box)
round(denyBtn, 9)
local allowBtn = button({ Position = UDim2.new(0.5, 9, 1, -54), Size = UDim2.new(0.5, -27, 0, 36), BackgroundColor3 = C.error, Text = "Allow", TextSize = 14, ZIndex = 202 }, box)
round(allowBtn, 9)
local countdown = text({ AnchorPoint = Vector2.new(1, 0), Position = UDim2.new(1, -18, 0, 14),
Size = UDim2.fromOffset(48, 20), Text = "30s", TextSize = 12, TextColor3 = C.subtext,
TextXAlignment = Enum.TextXAlignment.Right, TextWrapped = false, ZIndex = 202 }, box)
denyBtn.MouseButton1Click:Connect(function() decision = false resolved = true end)
allowBtn.MouseButton1Click:Connect(function() decision = true resolved = true end)
local t0 = os.clock()
while not resolved and overlay.Parent and os.clock() - t0 < 30 do
countdown.Text = tostring(math.max(0, math.ceil(30 - (os.clock() - t0)))) .. "s"
task.wait(0.05)
end
overlay:Destroy()
if not resolved then return "timeout" end
return decision and "allow" or "deny"
end
function activeToolSchemas()
local list = {}
for _, t in ipairs(TOOL_SCHEMAS) do
local shown = true
if t.setting ~= nil then shown = settings[t.setting] ~= false end
if shown and not (settings.permission == "readonly" and (t.access or "read") ~= "read") then
table.insert(list, t)
end
end
return list
end
local webSearch, webFetch
function runTool(call, approved)
local access = toolAccess(call.name)
if not approved then
local decision = permissionDecision(access)
if decision == "deny" then
return "Blocked by the Read only permission mode.", "Blocked (read only)", "denied"
end
if decision == "confirm" then
local answer = confirmAction(call.name, access)
if answer == "timeout" then
return "The approval prompt timed out after 30 seconds, so the action was skipped.", "Timed out", "denied"
elseif answer ~= "allow" then
return "The user declined this action.", "Declined", "denied"
end
end
end
if call.name == "read_console" then
if not settings.console then return "Console access is turned off by the user.", "Console access is off", "denied" end
local out, e, w = readConsole(call.args)
return out, string.format("Read console: %d error%s, %d warning%s",
e, e == 1 and "" or "s", w, w == 1 and "" or "s"), "done"
elseif call.name == "take_screenshot" then
if not settings.screen then return "Game screenshots are turned off by the user.", "Game vision is off", "denied" end
local att, err = captureAttachment()
if not att then
return "Screenshot failed: " .. tostring(err)
.. "\nThis executor blocks screen capture, so no image is available. Use inspect_player instead "
.. "(it reads the avatar and the scene and can attach the avatar picture), or ask the user to "
.. "attach a screenshot in the composer.", "Screenshot blocked", "error"
end
return "Screenshot attached.", "Took a screenshot", "done", att
elseif call.name == "inspect_player" then
local view = tostring((call.args and call.args.view) or "avatar"):lower()
if view ~= "scene" then view = "avatar" end
local report = API.playerReport(view)
if view == "avatar" then
local image = API.avatarImage()
if image then return report .. "\nAvatar picture attached.", "Looked at your avatar", "done", image end
end
return report, view == "scene" and "Looked around the game" or "Read your avatar", "done"
elseif call.name == "inspect_instance" then
local target = resolveInstance((call.args and call.args.path) or "")
if not target then return "Instance not found.", "Not found", "error" end
local lines = { target.ClassName .. " " .. target:GetFullName() }
for _, p in ipairs({ "Position", "Size", "Transparency", "Anchored", "Enabled", "Value", "ClassName" }) do
local ok, v = pcall(function() return target[p] end)
if ok and v ~= nil and type(v) ~= "userdata" then lines[#lines + 1] = p .. " = " .. tostring(v) end
if ok and typeof(v) == "Vector3" then lines[#lines + 1] = p .. " = " .. tostring(v) end
end
local kids = {}
pcall(function() kids = target:GetChildren() end)
lines[#lines + 1] = "Children (" .. #kids .. "):"
for i, ch in ipairs(kids) do
if i > 40 then lines[#lines + 1] = " ... +" .. (#kids - 40) .. " more"; break end
lines[#lines + 1] = " " .. ch.Name .. " (" .. ch.ClassName .. ")"
end
return table.concat(lines, "\n"), "Inspected " .. target.Name, "done"
elseif call.name == "read_script" then
local target = resolveInstance((call.args and call.args.path) or "")
if not target then return "Script not found.", "Not found", "error" end
local ok, src = API.withIdentity(function() return pcall(function() return target.Source end) end)
if not ok or type(src) ~= "string" then return "Could not read Source (locked or not a script).", "Read blocked", "error" end
return src, "Read " .. target.Name, "done"
elseif call.name == "read_file" then
local path = tostring((call.args and call.args.path) or "")
if path == "" then return "No path given.", "No path", "error" end
if isSensitivePath(path) then return "That file holds credentials and is not readable by tools.", "Blocked (credentials)", "denied" end
if type(readfile) ~= "function" then return "File API unavailable in this executor.", "No file API", "error" end
local ok, data = pcall(function() return readfile(path) end)
if not ok then return "File not found or unreadable: " .. path, "Read failed", "error" end
return tostring(data), "Read " .. path, "done"
elseif call.name == "web_search" then
if not settings.web then return "Web search is turned off by the user.", "Web search is off", "denied" end
local q = tostring((call.args and call.args.query) or ""):gsub("^%s+", ""):gsub("%s+$", "")
if q == "" then return "No search query given.", "No query", "error" end
local out, err = webSearch(q)
if not out then return "Search failed: " .. tostring(err), "Search failed", "error" end
webTainted = true
return out, "Searched the web: " .. (#q > 36 and q:sub(1, 33) .. "..." or q), "done"
elseif call.name == "web_fetch" then
if not settings.web then return "Web access is turned off by the user.", "Web access is off", "denied" end
local url = tostring((call.args and call.args.url) or ""):gsub("^%s+", ""):gsub("%s+$", "")
if url == "" then return "No URL given.", "No URL", "error" end
local out, err = webFetch(url)
if not out then return "Could not read the page: " .. tostring(err), "Fetch failed", "error" end
webTainted = true
return out, "Read " .. (url:match("^https?://([^/]+)") or url), "done"
elseif call.name == "set_property" then
local args = call.args or {}
local target = resolveInstance(args.path or "")
if not target then return "Instance not found.", "Not found", "error" end
local prop = tostring(args.property or "")
if prop == "" then return "No property named.", "No property", "error" end
local val = args.value
local okGet, cur = pcall(function() return target[prop] end)
if okGet and typeof(cur) == "Vector3" and type(val) == "table" then
val = Vector3.new(val[1] or val.x or 0, val[2] or val.y or 0, val[3] or val.z or 0)
end
local ok, err = API.withIdentity(function() return pcall(function() target[prop] = val end) end)
if not ok then return "Failed to set " .. prop .. ": " .. tostring(err), "Set failed", "error" end
return "Set " .. prop .. " = " .. tostring(val), "Set " .. prop, "done"
elseif call.name == "write_file" then
local args = call.args or {}
local path = tostring(args.path or "")
if path == "" then return "No path given.", "No path", "error" end
if isSensitivePath(path) then return "That file holds credentials and is not writable by tools.", "Blocked (credentials)", "denied" end
if type(writefile) ~= "function" then return "File API unavailable in this executor.", "No file API", "error" end
local content = tostring(args.content or "")
local ok, err = pcall(function() writefile(path, content) end)
if not ok then return "Write failed: " .. tostring(err), "Write failed", "error" end
return "Wrote " .. #content .. " bytes to " .. path, "Wrote " .. path, "done"
elseif call.name == "execute_luau" then
local code = tostring((call.args and call.args.code) or "")
if code == "" then return "No code provided.", "No code", "error" end
if type(loadstring) ~= "function" then
return "This executor does not provide loadstring, so code cannot run here.", "No loadstring", "error"
end
local out = {}
local function push(...)
local t = {}
for i = 1, select("#", ...) do t[#t + 1] = tostring(select(i, ...)) end
out[#out + 1] = table.concat(t, " ")
end
local okLoad, fn, cerr = pcall(loadstring, code)
if not okLoad then return "Compile error: " .. tostring(fn), "Compile error", "error" end
if not fn then return "Compile error: " .. tostring(cerr), "Compile error", "error" end
local env = setmetatable({ print = push, warn = push, write = push }, { __index = _G })
if type(setfenv) == "function" then pcall(setfenv, fn, env) end
local okRun, rerr = API.withIdentity(function() return pcall(fn) end)
local output = table.concat(out, "\n")
if not okRun then
local msg = "Runtime error: " .. tostring(rerr)
if output ~= "" then msg = output .. "\n" .. msg end
return msg, "Execution error", "error"
end
return output ~= "" and output or "Executed without output.", "Executed Luau", "done"
end
if call.name == "bash" or call.name == "edit" or call.name == "glob" or call.name == "grep" or call.name == "read" then
return "Shell and editor tools are not available here. Use inspect_instance, read_script, read_file, write_file, set_property or execute_luau instead.", "Tool unavailable", "denied"
end
return "Unknown tool.", "Unknown tool: " .. tostring(call.name), "error"
end
function parseSegments(src)
local segs, pos = {}, 1
while true do
local s, e = src:find("```", pos, true)
if not s then
table.insert(segs, { kind = "text", text = src:sub(pos) })
break
end
table.insert(segs, { kind = "text", text = src:sub(pos, s - 1) })
local close, closeE = src:find("```", e + 1, true)
local raw
if close then
raw = src:sub(e + 1, close - 1)
pos = closeE + 1
else
raw = src:sub(e + 1)
pos = #src + 1
end
local lang, body = raw:match("^([%w_%-]*)\n(.*)$")
if not lang then lang, body = "", raw end
table.insert(segs, { kind = "code", lang = lang, text = (body:gsub("\n$", "")) })
if not close then break end
end
return segs
end
codeHex = Color3.fromRGB(240, 165, 135):ToHex()
linkHex = Color3.fromRGB(96, 146, 245):ToHex()
function richify(s)
s = s:gsub("&", "&"):gsub("<", "<"):gsub(">", ">")
s = s:gsub("`([^`\n]+)`", '%1')
s = s:gsub("%*%*(.-)%*%*", "%1")
s = s:gsub("%[([^%[%]]+)%]%((https?://[^%s%)]+)%)", '%1')
s = s:gsub("https?://[%w%-%._~:/%?#%[%]@!$&'()*+,;=%%]+", function(u)
local clean = u:gsub("[%.%,%;%!%)%]]+$", "")
return '' .. clean .. "" .. u:sub(#clean + 1)
end)
local lines = {}
for line in (s .. "\n"):gmatch("(.-)\n") do
line = line:gsub("^#+%s+(.*)$", "%1")
line = line:gsub("^%s*[%-%*]%s+", "• ")
table.insert(lines, line)
end
return table.concat(lines, "\n")
end
function renderContent(container, str)
for _, ch in ipairs(container:GetChildren()) do
if ch:IsA("GuiObject") then ch:Destroy() end
end
local order = 0
for _, seg in ipairs(parseSegments(str)) do
if seg.kind == "text" then
local t = seg.text:match("^%s*(.-)%s*$")
if t ~= "" then
order += 1
text({
Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y, RichText = true,
Text = richify(t), TextSize = 15, LineHeight = 1.3, LayoutOrder = order,
}, container)
end
else
order += 1
local lineCount = 1
for _ in string.gmatch(seg.text, "\n") do lineCount = lineCount + 1 end
if lineCount > 50 then
local fileBtn = button({ Size = UDim2.new(1, 0, 0, 52), BackgroundColor3 = C.panel, LayoutOrder = order }, container)
round(fileBtn, 10)
stroke(fileBtn)
hover(fileBtn, C.hover, 0)
local iconHolder = new("Frame", { BackgroundTransparency = 1, Position = UDim2.fromOffset(12, 12), Size = UDim2.fromOffset(28, 28) }, fileBtn)
icon("file", iconHolder, C.text, 24)
text({ Position = UDim2.fromOffset(52, 8), Size = UDim2.new(1, -64, 0, 20), Text = (seg.lang ~= "" and seg.lang or "txt") .. " file", Font = Enum.Font.GothamMedium, TextSize = 14, TextWrapped = false }, fileBtn)
text({ Position = UDim2.fromOffset(52, 26), Size = UDim2.new(1, -64, 0, 16), Text = tostring(lineCount) .. " lines · Click to view", TextSize = 12, TextColor3 = C.subtext, TextWrapped = false }, fileBtn)
fileBtn.MouseButton1Click:Connect(function()
copyToClipboard(seg.text)
end)
else
local box = new("Frame", { Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y,
BackgroundColor3 = C.code, LayoutOrder = order }, container)
round(box, 10)
stroke(box)
new("UIListLayout", { SortOrder = Enum.SortOrder.LayoutOrder }, box)
local head = new("Frame", { Size = UDim2.new(1, 0, 0, 34), BackgroundTransparency = 1, LayoutOrder = 1 }, box)
local dot = new("Frame", { Position = UDim2.fromOffset(14, 13), Size = UDim2.fromOffset(8, 8),
BackgroundColor3 = Color3.fromRGB(240, 165, 135), BorderSizePixel = 0 }, head)
round(dot, 4)
local langName = (seg.lang ~= "" and seg.lang or "code"):lower()
text({ Position = UDim2.fromOffset(29, 0), Size = UDim2.new(1, -150, 1, 0),
Text = langName, TextSize = 11, TextColor3 = C.subtext,
Font = Enum.Font.GothamBold, TextWrapped = false }, head)
local copyBtn = button({ AnchorPoint = Vector2.new(1, 0.5), Position = UDim2.new(1, -10, 0.5, 0),
Size = UDim2.fromOffset(56, 24), BackgroundColor3 = C.hover, Text = "Copy", TextSize = 11 }, head)
round(copyBtn, 6)
hover(copyBtn, C.panel, 0)
copyBtn.MouseButton1Click:Connect(function()
if copyToClipboard(seg.text) then
copyBtn.Text = "Copied"
task.delay(1.2, function()
if copyBtn.Parent then copyBtn.Text = "Copy" end
end)
end
end)
new("Frame", { Position = UDim2.new(0, 0, 1, -1), Size = UDim2.new(1, 0, 0, 1),
BackgroundColor3 = C.stroke, BorderSizePixel = 0 }, head)
local body = new("Frame", { Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y,
BackgroundTransparency = 1, LayoutOrder = 2 }, box)
pad(body, 10, 14, 14, 14)
text({ Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y, Text = seg.text,
Font = Enum.Font.Code, TextSize = 13, TextColor3 = Color3.fromRGB(232, 228, 218) }, body)
end
end
end
end
function scrollToBottom()
task.defer(function()
RunService.Heartbeat:Wait()
messageArea.CanvasPosition = Vector2.new(0, math.max(0, messageArea.AbsoluteCanvasSize.Y - messageArea.AbsoluteSize.Y))
end)
end
local updateComposer, generate, send, refreshSidebar, refreshAttachStrip, attachScreenshot, refreshProjects
function drawToolChip(parent, tl, order)
local chip = new("Frame", { Size = UDim2.fromOffset(0, 28), AutomaticSize = Enum.AutomaticSize.X,
BackgroundColor3 = C.panel, LayoutOrder = order }, parent)
round(chip, 14)
stroke(chip)
pad(chip, 0, 12, 0, 0)
local kindMap = { web = "globe", web_search = "globe", web_fetch = "globe", note = "check", read_console = "terminal", take_screenshot = "image",
inspect_instance = "search", read_script = "file", read_file = "file", write_file = "file",
set_property = "settings", execute_luau = "terminal", task = "terminal" }
local kind = kindMap[tl.kind] or "terminal"
local col = tl.state == "error" and C.error or C.subtext
local holder = new("Frame", { BackgroundTransparency = 1, Position = UDim2.fromOffset(9, 6),
Size = UDim2.fromOffset(16, 16) }, chip)
icon(kind, holder, col, 16)
local label = tl.label
if #label > 64 then label = label:sub(1, 61) .. "..." end
if tl.state == "running" then label = label .. "..." end
text({ Position = UDim2.fromOffset(32, 0), Size = UDim2.new(0, 0, 1, 0), AutomaticSize = Enum.AutomaticSize.X,
Text = label, TextSize = 12, TextColor3 = col, TextWrapped = false }, chip)
end
function drawUser(msg, index)
local r = new("Frame", { Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y,
BackgroundTransparency = 1, LayoutOrder = index }, column)
new("UIListLayout", { HorizontalAlignment = Enum.HorizontalAlignment.Right, Padding = UDim.new(0, 6),
SortOrder = Enum.SortOrder.LayoutOrder }, r)
text({ Size = UDim2.fromOffset(0, 16), AutomaticSize = Enum.AutomaticSize.X, Text = player.DisplayName,
TextSize = 11, TextColor3 = C.subtext, TextWrapped = false, LayoutOrder = 0 }, r)
if msg.images and #msg.images > 0 then
local imgs = new("Frame", { Size = UDim2.fromOffset(0, 68), AutomaticSize = Enum.AutomaticSize.X,
BackgroundTransparency = 1, LayoutOrder = 1 }, r)
new("UIListLayout", { FillDirection = Enum.FillDirection.Horizontal, Padding = UDim.new(0, 6),
SortOrder = Enum.SortOrder.LayoutOrder }, imgs)
for i, att in ipairs(msg.images) do
local il = new("ImageLabel", { Size = UDim2.fromOffset(120, 68), BackgroundColor3 = C.userBubble,
ScaleType = Enum.ScaleType.Crop, LayoutOrder = i }, imgs)
round(il, 10)
stroke(il)
pcall(function() il.ImageContent = Content.fromObject(att.editable) end)
end
end
if msg.text ~= "" then
local bubble = new("Frame", { AutomaticSize = Enum.AutomaticSize.XY, Size = UDim2.fromOffset(0, 0),
BackgroundColor3 = C.userBubble, LayoutOrder = 2 }, r)
round(bubble, 16)
stroke(bubble, C.stroke)
pad(bubble, 10, 16, 10, 16)
local colW = column.AbsoluteSize.X
local lbl = text({ AutomaticSize = Enum.AutomaticSize.XY, Size = UDim2.fromOffset(0, 0),
Text = msg.text, TextSize = 15, LineHeight = 1.25 }, bubble)
new("UISizeConstraint", { MaxSize = Vector2.new(colW > 0 and math.min(480, colW * 0.8) or 420, math.huge) }, lbl)
end
end
function formatTime(seconds)
if seconds >= 60 then
local mins = math.floor(seconds / 60)
local secs = seconds % 60
return string.format("%dm %02ds", mins, secs)
else
return string.format("%ds", seconds)
end
end
function extractSources(str)
local seen, list = {}, {}
local function add(url, title)
url = url:gsub("[%.%,%;%!%)%]]+$", "")
if url:match("^https?://") and not seen[url] and #list < 10 then
seen[url] = true
list[#list + 1] = { url = url, title = title or (url:match("^https?://([^/]+)") or url) }
end
end
for title, url in str:gmatch("%[([^%[%]]+)%]%((https?://[^%s%)]+)%)") do
add(url, (title:gsub("^%s+", ""):gsub("%s+$", "")))
end
for url in str:gmatch("https?://[%w%-%._~:/%?#%[%]@!$&'()*+,;=%%]+") do
add(url)
end
return list
end
function drawSourceRow(parent, src, order)
local row = button({ Size = UDim2.new(1, 0, 0, 40), BackgroundColor3 = C.panel, LayoutOrder = order }, parent)
round(row, 9)
stroke(row)
hover(row, C.hover, 0)
local ih = new("Frame", { BackgroundTransparency = 1, Position = UDim2.fromOffset(11, 12), Size = UDim2.fromOffset(16, 16) }, row)
icon("globe", ih, C.subtext, 15)
text({ Position = UDim2.fromOffset(36, 5), Size = UDim2.new(1, -110, 0, 17),
Text = src.title, TextSize = 12, TextWrapped = false, TextTruncate = Enum.TextTruncate.AtEnd }, row)
local host = src.url:match("^https?://([^/]+)") or src.url
text({ Position = UDim2.fromOffset(36, 22), Size = UDim2.new(1, -110, 0, 15),
Text = host, TextSize = 11, TextColor3 = C.subtext, TextWrapped = false, TextTruncate = Enum.TextTruncate.AtEnd }, row)
local hint = text({ AnchorPoint = Vector2.new(1, 0.5), Position = UDim2.new(1, -12, 0.5, 0),
Size = UDim2.fromOffset(52, 16), Text = "Copy", TextSize = 11, TextColor3 = C.subtext,
TextXAlignment = Enum.TextXAlignment.Right, TextWrapped = false }, row)
row.MouseButton1Click:Connect(function()
if copyToClipboard(src.url) then
hint.Text = "Copied"
task.delay(1.2, function() if row.Parent then hint.Text = "Copy" end end)
end
end)
end
function drawAssistant(msg, index, isLast)
local r = new("Frame", { Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y,
BackgroundTransparency = 1, LayoutOrder = index }, column)
local logoHolder = new("Frame", { BackgroundTransparency = 1, Size = UDim2.fromOffset(26, 26) }, r)
local answering = modelById(msg.answeredById) or msg.model
local logo = brandLogo(logoHolder, answering, 22)
logo.Position = UDim2.fromOffset(2, 2)
local who = new("Frame", { BackgroundTransparency = 1, Position = UDim2.fromOffset(38, 1),
Size = UDim2.new(1, -38, 0, 22) }, r)
new("UIListLayout", { FillDirection = Enum.FillDirection.Horizontal, Padding = UDim.new(0, 6),
VerticalAlignment = Enum.VerticalAlignment.Center, SortOrder = Enum.SortOrder.LayoutOrder }, who)
text({ Size = UDim2.fromOffset(0, 20), AutomaticSize = Enum.AutomaticSize.X, Text = "Assistant",
Font = Enum.Font.GothamBold, TextSize = 13, TextWrapped = false, LayoutOrder = 1 }, who)
local whoLabel = answering.short or answering.name
if msg.answeredById and msg.fellBackReason and answering ~= msg.model then
whoLabel = whoLabel .. " · for " .. (msg.model.short or msg.model.name)
end
text({ Size = UDim2.fromOffset(0, 20), AutomaticSize = Enum.AutomaticSize.X, Text = whoLabel,
TextSize = 12, TextColor3 = C.subtext, TextWrapped = false, LayoutOrder = 2 }, who)
local content = new("Frame", { Position = UDim2.fromOffset(38, 26), Size = UDim2.new(1, -38, 0, 0),
AutomaticSize = Enum.AutomaticSize.Y, BackgroundTransparency = 1 }, r)
new("UIListLayout", { Padding = UDim.new(0, 10), SortOrder = Enum.SortOrder.LayoutOrder }, content)
local think = new("Frame", { Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y,
BackgroundTransparency = 1, LayoutOrder = 1, Visible = false }, content)
new("UIListLayout", { Padding = UDim.new(0, 6), SortOrder = Enum.SortOrder.LayoutOrder }, think)
local thinkHead = button({ Size = UDim2.fromOffset(0, 24), AutomaticSize = Enum.AutomaticSize.X,
LayoutOrder = 1 }, think)
new("UIListLayout", { FillDirection = Enum.FillDirection.Horizontal, Padding = UDim.new(0, 6),
VerticalAlignment = Enum.VerticalAlignment.Center, SortOrder = Enum.SortOrder.LayoutOrder }, thinkHead)
local thinkTitle = text({ Size = UDim2.fromOffset(0, 20), AutomaticSize = Enum.AutomaticSize.X,
Text = "Thinking...", TextSize = 13, TextColor3 = C.subtext, Font = Enum.Font.GothamMedium,
TextWrapped = false, LayoutOrder = 1 }, thinkHead)
local chevHolder = new("Frame", { BackgroundTransparency = 1, Size = UDim2.fromOffset(14, 14), LayoutOrder = 2 }, thinkHead)
local chevIcon = icon("chevron", chevHolder, C.subtext, 14)
local thinkBody = new("Frame", { Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y,
BackgroundTransparency = 1, LayoutOrder = 2 }, think)
new("Frame", { Size = UDim2.new(0, 2, 1, 0), BackgroundColor3 = C.stroke, BorderSizePixel = 0 }, thinkBody)
local thinkText = text({ Position = UDim2.fromOffset(14, 0), Size = UDim2.new(1, -14, 0, 0),
AutomaticSize = Enum.AutomaticSize.Y, Text = "", TextSize = 13, TextColor3 = C.subtext,
LineHeight = 1.25, RichText = true }, thinkBody)
thinkHead.MouseButton1Click:Connect(function()
msg.thinkOpen = not msg.thinkOpen
if msg._update then msg._update() end
end)
local taskF = new("Frame", { Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y,
BackgroundTransparency = 1, LayoutOrder = 2, Visible = false }, content)
new("UIListLayout", { Padding = UDim.new(0, 4), SortOrder = Enum.SortOrder.LayoutOrder }, taskF)
local toolsF = new("Frame", { Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y,
BackgroundTransparency = 1, LayoutOrder = 3, Visible = false }, content)
new("UIListLayout", { Padding = UDim.new(0, 6), SortOrder = Enum.SortOrder.LayoutOrder }, toolsF)
local toolSig = ""
local dots = text({ Size = UDim2.new(1, 0, 0, 20), Text = "Thinking", TextSize = 14,
TextColor3 = C.subtext, LayoutOrder = 4, Visible = false }, content)
local bodyC = new("Frame", { Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y,
BackgroundTransparency = 1, LayoutOrder = 5 }, content)
new("UIListLayout", { Padding = UDim.new(0, 12), SortOrder = Enum.SortOrder.LayoutOrder }, bodyC)
local bodyStroke = stroke(bodyC, C.error)
bodyStroke.Transparency = 1
local bodyPad = pad(bodyC, 0, 0, 0, 0)
local sourcesF = new("Frame", { Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y,
BackgroundTransparency = 1, LayoutOrder = 6, Visible = false }, content)
new("UIListLayout", { Padding = UDim.new(0, 4), SortOrder = Enum.SortOrder.LayoutOrder }, sourcesF)
local actions = new("Frame", { Size = UDim2.new(1, 0, 0, 24), BackgroundTransparency = 1,
LayoutOrder = 7, Visible = false }, content)
new("UIListLayout", { FillDirection = Enum.FillDirection.Horizontal, Padding = UDim.new(0, 12),
VerticalAlignment = Enum.VerticalAlignment.Center, SortOrder = Enum.SortOrder.LayoutOrder }, actions)
local retry = button({ Size = UDim2.fromOffset(0, 24), AutomaticSize = Enum.AutomaticSize.X,
Text = "Retry", TextSize = 12, TextColor3 = C.subtext, LayoutOrder = 1 }, actions)
retry.MouseEnter:Connect(function() retry.TextColor3 = C.text end)
retry.MouseLeave:Connect(function() retry.TextColor3 = C.subtext end)
retry.MouseButton1Click:Connect(function()
if busy then return end
table.remove(current.messages)
generate()
end)
text({ Size = UDim2.fromOffset(0, 24), AutomaticSize = Enum.AutomaticSize.X, Text = answering.name,
TextSize = 12, TextColor3 = C.subtext, TextWrapped = false, LayoutOrder = 2 }, actions)
local lastText = nil
local lastSources = nil
local function updateAssistantMessage()
if not r.Parent then return end
local waiting = (msg.status == "thinking" or msg.status == "reasoning" or msg.status == "planning" or msg.status == "editing" or msg.status == "testing")
local elapsed = math.floor(os.clock() - (msg.startClock or os.clock()))
local timeStr = formatTime(elapsed)
local doneTimeStr = formatTime(msg.thinkSecs or 0)
think.Visible = msg.thinkOn == true or (msg.thinking and msg.thinking ~= "")
thinkTitle.Text = waiting and ((msg.taskPhase or "Thinking") .. "... (" .. timeStr .. ")") or ("Thought for " .. doneTimeStr)
thinkBody.Visible = msg.thinkOpen == true
thinkText.Text = richify(msg.thinking or "")
chevIcon.Rotation = msg.thinkOpen and 0 or -90
if msg.taskPhase and msg.status ~= "done" then
taskF.Visible = true
for _, ch in ipairs(taskF:GetChildren()) do
if ch:IsA("GuiObject") then ch:Destroy() end
end
local phaseChip = new("Frame", { Size = UDim2.fromOffset(0, 26), AutomaticSize = Enum.AutomaticSize.X,
BackgroundColor3 = C.panel, LayoutOrder = 1 }, taskF)
round(phaseChip, 13)
stroke(phaseChip)
pad(phaseChip, 0, 12, 0, 10)
text({ Size = UDim2.new(0, 0, 1, 0), AutomaticSize = Enum.AutomaticSize.X,
Text = "Task: " .. tostring(msg.taskPhase), TextSize = 12, TextColor3 = MODELS[modelIndex].accent, TextWrapped = false }, phaseChip)
else
taskF.Visible = false
end
local sig = ""
for _, tl in ipairs(msg.tools or {}) do sig ..= tl.state .. tl.label .. "|" end
if sig ~= toolSig then
toolSig = sig
for _, ch in ipairs(toolsF:GetChildren()) do
if ch:IsA("GuiObject") then ch:Destroy() end
end
for i, tl in ipairs(msg.tools or {}) do drawToolChip(toolsF, tl, i) end
end
toolsF.Visible = msg.tools ~= nil and #msg.tools > 0
dots.Visible = (msg.status == "thinking" or msg.status == "planning") and not msg.thinkOn
dots.Text = (msg.taskPhase or "Thinking") .. string.rep(".", msg.dots or 0)
if msg.text ~= lastText then
lastText = msg.text
renderContent(bodyC, msg.text)
if msg.isError then
for _, d in ipairs(bodyC:GetDescendants()) do
if d:IsA("TextLabel") then d.TextColor3 = C.error end
end
end
end
local srcKey = (msg.status == "done" and not msg.isError) and msg.text or nil
if srcKey ~= lastSources then
lastSources = srcKey
for _, ch in ipairs(sourcesF:GetChildren()) do
if ch:IsA("GuiObject") then ch:Destroy() end
end
local srcs = srcKey and extractSources(srcKey) or {}
if #srcs > 0 then
text({ Size = UDim2.new(1, 0, 0, 16), Text = "Sources", Font = Enum.Font.GothamBold,
TextSize = 12, TextColor3 = C.subtext, LayoutOrder = 1 }, sourcesF)
for i, s in ipairs(srcs) do drawSourceRow(sourcesF, s, i + 1) end
end
sourcesF.Visible = #srcs > 0
end
if msg.isError then
bodyC.BackgroundColor3 = C.error
bodyC.BackgroundTransparency = 0.88
bodyStroke.Transparency = 0.55
bodyPad.PaddingTop = UDim.new(0, 10)
bodyPad.PaddingBottom = UDim.new(0, 10)
bodyPad.PaddingLeft = UDim.new(0, 12)
bodyPad.PaddingRight = UDim.new(0, 12)
else
bodyC.BackgroundTransparency = 1
bodyStroke.Transparency = 1
bodyPad.PaddingTop = UDim.new(0, 0)
bodyPad.PaddingBottom = UDim.new(0, 0)
bodyPad.PaddingLeft = UDim.new(0, 0)
bodyPad.PaddingRight = UDim.new(0, 0)
end
bodyC.Visible = msg.text ~= ""
actions.Visible = msg.status == "done" and isLast
end
msg._update = updateAssistantMessage
updateAssistantMessage()
end
function drawWelcome()
local h = math.max(300, messageArea.AbsoluteSize.Y - 40)
local wrap = new("Frame", { Size = UDim2.new(1, 0, 0, h), BackgroundTransparency = 1 }, column)
local mid = new("Frame", { AnchorPoint = Vector2.new(0.5, 0.5), Position = UDim2.fromScale(0.5, 0.5),
Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y, BackgroundTransparency = 1 }, wrap)
new("UIListLayout", { Padding = UDim.new(0, 16), SortOrder = Enum.SortOrder.LayoutOrder,
HorizontalAlignment = Enum.HorizontalAlignment.Center }, mid)
local m = MODELS[modelIndex]
local logoModel = settings.autoLogo == false and MODELS[1] or m
local logoHolder = new("Frame", { BackgroundTransparency = 1, Size = UDim2.fromOffset(56, 56), LayoutOrder = 1 }, mid)
brandLogo(logoHolder, logoModel, 52).Position = UDim2.fromOffset(2, 2)
text({ Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y,
Text = "What will we create, " .. player.DisplayName .. "?", Font = SERIF, TextSize = small and 22 or 30,
TextXAlignment = Enum.TextXAlignment.Center, LayoutOrder = 2 }, mid)
text({ Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y,
Text = "Your ideas. Your game. An agent to help make it happen.", TextSize = 13, TextColor3 = C.subtext,
TextXAlignment = Enum.TextXAlignment.Center, LayoutOrder = 3 }, mid)
local grid = new("Frame", { Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y,
BackgroundTransparency = 1, LayoutOrder = 4 }, mid)
new("UIGridLayout", { CellSize = UDim2.new(0.5, -6, 0, 92), CellPadding = UDim2.fromOffset(12, 12),
SortOrder = Enum.SortOrder.LayoutOrder, HorizontalAlignment = Enum.HorizontalAlignment.Center }, grid)
local cards = {
{ icon = "search", title = "Explore this game", desc = "Find your bearings in the world.",
prompt = "Give me a quick overview of this game - what is here and what can I do?" },
{ icon = "code", title = "Create something", desc = "Turn an idea into a working script.",
prompt = "Help me turn an idea into a working Luau script." },
{ icon = "settings", title = "Check performance", desc = "Understand FPS, memory, and latency.",
prompt = "Help me check FPS, memory, and latency in this game." },
{ icon = "person", title = "Inspect my character", desc = "See your character's current state.",
prompt = "Inspect my character's current state and stats." },
}
for i, c in ipairs(cards) do
local card = button({ BackgroundColor3 = C.panel, LayoutOrder = i, Text = "", TextXAlignment = Enum.TextXAlignment.Left }, grid)
round(card, 12)
stroke(card)
hover(card, C.hover, 0)
local ih = new("Frame", { BackgroundTransparency = 1, Position = UDim2.fromOffset(16, 14), Size = UDim2.fromOffset(24, 24) }, card)
icon(c.icon, ih, m.accent, 22)
text({ Position = UDim2.fromOffset(16, 46), Size = UDim2.new(1, -32, 0, 20), Text = c.title,
Font = Enum.Font.GothamMedium, TextSize = 15, TextWrapped = false, TextTruncate = Enum.TextTruncate.AtEnd }, card)
text({ Position = UDim2.fromOffset(16, 66), Size = UDim2.new(1, -32, 0, 18), Text = c.desc,
TextSize = 12, TextColor3 = C.subtext, TextWrapped = false, TextTruncate = Enum.TextTruncate.AtEnd }, card)
card.MouseButton1Click:Connect(function() send(c.prompt) end)
end
end
local function renderMessages()
for _, ch in ipairs(column:GetChildren()) do
if ch:IsA("GuiObject") then ch:Destroy() end
end
for _, m in ipairs(current.messages) do m._update = nil end
titleLabel.Text = current.title
updateModelChip()
updateHeaderSubtitle()
if #current.messages == 0 then
drawWelcome()
else
for i, m in ipairs(current.messages) do
if m.role == "user" then drawUser(m, i) else drawAssistant(m, i, i == #current.messages) end
end
end
scrollToBottom()
end
local function setSidebar(open, instant)
sidebarOpen = open
sideInner.Size = UDim2.new(0, SIDEBAR_W, 1, 0)
local dur = instant and 0 or 0.22
local function apply(inst, props)
if instant or dur == 0 then
for k, v in pairs(props) do inst[k] = v end
else
tween(inst, dur, props)
end
end
if small then
apply(sidebar, { Position = UDim2.fromOffset(open and 0 or -SIDEBAR_W - 8, 0), Size = UDim2.new(0, SIDEBAR_W, 1, 0) })
apply(main, { Position = UDim2.fromOffset(0, 0), Size = UDim2.new(1, 0, 1, 0) })
if not open then
tween(sidebar, dur, { BackgroundTransparency = 0.15 })
else
sidebar.BackgroundTransparency = 0
end
else
apply(sidebar, { Size = UDim2.new(0, open and SIDEBAR_W or 0, 1, 0) })
apply(main, { Position = UDim2.fromOffset(open and SIDEBAR_W or 0, 0),
Size = UDim2.new(1, open and -SIDEBAR_W or 0, 1, 0) })
end
end
sideCloseBtn.MouseButton1Click:Connect(function()
setSidebar(false)
end)
local function selectConversation(convo)
current = convo
renderMessages()
refreshSidebar()
if small then setSidebar(false) end
end
local function selectProject(proj)
currentProject = proj
conversations = proj.conversations
proj.expanded = true
updateHeaderSubtitle()
refreshSidebar()
end
local function newConversation(forceNew)
local list = currentProject and currentProject.conversations or conversations
if not forceNew and current and #current.messages == 0 then
selectConversation(current)
return
end
local convo = { title = "New chat", messages = {} }
table.insert(list, 1, convo)
selectConversation(convo)
end
local function newProject(name, context)
local p = { name = name or ("Project " .. tostring(#projects + 1)), conversations = {}, context = context or "", expanded = true }
table.insert(projects, 1, p)
return p
end
function newFolder()
local p = newProject("Folder " .. tostring(#projects + 1))
selectProject(p)
newConversation(true)
refreshSidebar()
end
-- Context Menu Logic --
local contextMenu
local function hideContextMenu()
if contextMenu then
contextMenu:Destroy()
contextMenu = nil
end
end
function showContextMenu(x, y, items)
hideContextMenu()
local h = #items * 32 + 8
contextMenu = new("Frame", {
Position = UDim2.fromOffset(x, y),
Size = UDim2.fromOffset(150, h),
BackgroundColor3 = C.panel,
ZIndex = 100
}, gui)
round(contextMenu, 8)
stroke(contextMenu)
new("UIListLayout", { Padding = UDim.new(0, 2), SortOrder = Enum.SortOrder.LayoutOrder }, contextMenu)
new("UIPadding", { PaddingTop = UDim.new(0, 4), PaddingBottom = UDim.new(0, 4) }, contextMenu)
for i, item in ipairs(items) do
local btn = button({
Size = UDim2.new(1, 0, 0, 30),
BackgroundTransparency = 1,
TextXAlignment = Enum.TextXAlignment.Left,
Text = item.text,
TextColor3 = item.color or C.text,
TextSize = 13,
ZIndex = 101,
LayoutOrder = i
}, contextMenu)
pad(btn, 0, 0, 0, 10)
hover(btn, C.hover, 0)
btn.MouseButton1Click:Connect(function()
hideContextMenu()
if item.callback then item.callback() end
end)
end
local catcher = new("TextButton", {
Size = UDim2.fromScale(1,1),
BackgroundTransparency = 1,
ZIndex = 99,
Text = ""
}, gui)
catcher.MouseButton1Click:Connect(hideContextMenu)
task.delay(0.1, function()
if catcher then catcher:Destroy() end
end)
end
local renameUI = {}
function showRenamePrompt(initialText, callback)
if renameUI.overlay then renameUI.overlay:Destroy() end
renameUI.overlay = new("Frame", {
Size = UDim2.fromScale(1, 1),
BackgroundColor3 = Color3.new(0, 0, 0),
BackgroundTransparency = 0.5,
ZIndex = 150
}, gui)
local box = new("Frame", {
AnchorPoint = Vector2.new(0.5, 0.5),
Position = UDim2.fromScale(0.5, 0.5),
Size = UDim2.fromOffset(300, 120),
BackgroundColor3 = C.panel,
ZIndex = 151
}, renameUI.overlay)
round(box, 12)
stroke(box)
text({ Position = UDim2.fromOffset(16, 12), Size = UDim2.new(1, -32, 0, 20), Text = "Rename", Font = Enum.Font.GothamBold, TextSize = 16, ZIndex = 152 }, box)
renameUI.box = new("TextBox", {
Position = UDim2.fromOffset(16, 40),
Size = UDim2.new(1, -32, 0, 32),
BackgroundColor3 = C.bg,
Text = initialText,
TextColor3 = C.text,
TextSize = 13,
Font = Enum.Font.Gotham,
ClearTextOnFocus = false,
TextXAlignment = Enum.TextXAlignment.Left,
ZIndex = 152
}, box)
round(renameUI.box, 6); stroke(renameUI.box, C.stroke); pad(renameUI.box, 0, 10, 0, 10)
local okBtn = button({ Position = UDim2.fromOffset(16, 80), Size = UDim2.new(0.5, -20, 0, 28), BackgroundColor3 = MODELS[modelIndex].accent, Text = "Save", TextSize = 13, ZIndex = 152 }, box)
round(okBtn, 6)
local cancelBtn = button({ Position = UDim2.new(0.5, 4, 0, 80), Size = UDim2.new(0.5, -20, 0, 28), BackgroundColor3 = C.hover, Text = "Cancel", TextSize = 13, ZIndex = 152 }, box)
round(cancelBtn, 6)
okBtn.MouseButton1Click:Connect(function()
callback(renameUI.box.Text)
renameUI.overlay:Destroy()
renameUI.overlay = nil
end)
cancelBtn.MouseButton1Click:Connect(function()
renameUI.overlay:Destroy()
renameUI.overlay = nil
end)
end
function refreshSidebar()
for _, ch in ipairs(convoList:GetChildren()) do
if ch:IsA("GuiObject") then ch:Destroy() end
end
local q = searchBox.Text:lower()
local order = 0
function sideHeader(label, onClick)
order = order + 1
local row = button({ Size = UDim2.new(1, 0, 0, 26), LayoutOrder = order }, convoList)
round(row, 7)
hover(row, C.hover, 0.6)
text({ Position = UDim2.fromOffset(10, 0), Size = UDim2.new(1, -44, 1, 0), Text = label, TextSize = 12,
Font = Enum.Font.GothamBold, TextColor3 = C.subtext, TextWrapped = false }, row)
local add = button({ AnchorPoint = Vector2.new(1, 0.5), Position = UDim2.new(1, -4, 0.5, 0),
Size = UDim2.fromOffset(22, 22) }, row)
round(add, 6)
icon("plus", add, C.subtext, 13)
hover(add, C.hover, 0.4)
add.MouseButton1Click:Connect(onClick)
end
sideHeader("Folders", function() if newFolder then newFolder() end end)
sideHeader("Chats", function() newConversation(true) end)
for _, proj in ipairs(projects) do
local hasMatching = q == "" or proj.name:lower():find(q, 1, true)
if not hasMatching then
for _, c in ipairs(proj.conversations) do
if c.title:lower():find(q, 1, true) then hasMatching = true break end
end
end
if hasMatching then
order = order + 1
-- Folder Header
local header = button({ Size = UDim2.new(1, 0, 0, 32), BackgroundTransparency = 1, LayoutOrder = order, Active = true }, convoList)
round(header, 8)
hover(header, C.hover, 0.5)
local chev = new("Frame", { BackgroundTransparency = 1, Position = UDim2.fromOffset(2, 8), Size = UDim2.fromOffset(16, 16) }, header)
local chevIcon = icon("chevron", chev, C.subtext, 14)
chevIcon.Rotation = proj.expanded and 0 or -90
local folderIcon = new("Frame", { BackgroundTransparency = 1, Position = UDim2.fromOffset(22, 8), Size = UDim2.fromOffset(16, 16) }, header)
icon("folder", folderIcon, C.subtext, 16)
text({ Position = UDim2.fromOffset(42, 0), Size = UDim2.new(1, -96, 1, 0), Text = proj.name, Font = Enum.Font.GothamMedium, TextSize = 13, TextWrapped = false, TextTruncate = Enum.TextTruncate.AtEnd }, header)
local addBtn = button({ AnchorPoint = Vector2.new(1, 0.5), Position = UDim2.new(1, -6, 0.5, 0), Size = UDim2.fromOffset(22, 22), BackgroundTransparency = 1, Active = true }, header)
icon("plus", addBtn, C.subtext, 14)
if #proj.conversations > 0 then
local pill = new("Frame", { AnchorPoint = Vector2.new(1, 0.5), Position = UDim2.new(1, -32, 0.5, 0),
Size = UDim2.fromOffset(20, 18), BackgroundColor3 = C.hover, BorderSizePixel = 0, ZIndex = 2 }, header)
round(pill, 9)
text({ Size = UDim2.fromScale(1, 1), Text = tostring(#proj.conversations), TextSize = 11,
TextColor3 = C.subtext, TextXAlignment = Enum.TextXAlignment.Center,
TextYAlignment = Enum.TextYAlignment.Center, TextWrapped = false, ZIndex = 3 }, pill)
end
header.InputBegan:Connect(function(io)
if io.UserInputType == Enum.UserInputType.MouseButton2 then
showContextMenu(io.Position.X, io.Position.Y, {
{ text = "Rename Folder", callback = function() showRenamePrompt(proj.name, function(newName) proj.name = newName refreshSidebar() end) end },
{ text = "Delete Folder", color = C.error, callback = function()
local idx = table.find(projects, proj)
if idx then table.remove(projects, idx) end
if #projects == 0 then newProject("General Chats") end
if proj == currentProject then selectProject(projects[1]) end
refreshSidebar()
end }
})
end
end)
header.MouseButton1Click:Connect(function()
proj.expanded = not proj.expanded
refreshSidebar()
end)
addBtn.MouseButton1Click:Connect(function()
selectProject(proj)
newConversation(true)
end)
if proj.expanded then
for _, convo in ipairs(proj.conversations) do
if q == "" or convo.title:lower():find(q, 1, true) then
order = order + 1
local selected = convo == current
local item = button({ Size = UDim2.new(1, -16, 0, 30), Position = UDim2.fromOffset(16, 0), BackgroundColor3 = C.hover, BackgroundTransparency = selected and 0 or 1, LayoutOrder = order, Active = true }, convoList)
round(item, 8)
text({ Position = UDim2.fromOffset(12, 0), Size = UDim2.new(1, -36, 1, 0), Text = convo.title, TextSize = 13, TextColor3 = selected and C.text or C.subtext, TextWrapped = false, TextTruncate = Enum.TextTruncate.AtEnd }, item)
local del = button({ AnchorPoint = Vector2.new(1, 0.5), Position = UDim2.new(1, -6, 0.5, 0), Size = UDim2.fromOffset(24, 24), Visible = selected, BackgroundTransparency = 1, Active = true }, item)
local dots = new("Frame", { BackgroundTransparency = 1, Size = UDim2.fromOffset(16, 16), Position = UDim2.fromOffset(4, 4) }, del)
for i = 1, 3 do
local dot = new("Frame", { BackgroundColor3 = C.subtext, Size = UDim2.fromOffset(2, 2), Position = UDim2.fromOffset(0, (i-2)*5), BorderSizePixel = 0 }, dots)
round(dot, 1)
end
item.InputBegan:Connect(function(io)
if io.UserInputType == Enum.UserInputType.MouseButton2 then
showContextMenu(io.Position.X, io.Position.Y, {
{ text = "Rename Chat", callback = function() showRenamePrompt(convo.title, function(newName) convo.title = newName refreshSidebar() if convo == current then titleLabel.Text = convo.title end end) end },
{ text = "Copy Text", callback = function()
local allText = ""
for _, m in ipairs(convo.messages) do
if m.text then allText = allText .. m.text .. "\n\n" end
end
copyToClipboard(allText)
end },
{ text = "Delete Chat", color = C.error, callback = function()
if convo == current and busy then cancelFlag = true end
local idx = table.find(proj.conversations, convo)
if idx then table.remove(proj.conversations, idx) end
if convo == current then
if #proj.conversations == 0 then
local c = { title = "New chat", messages = {} }
table.insert(proj.conversations, c)
selectConversation(c)
else
selectConversation(proj.conversations[math.min(idx, #proj.conversations)])
end
else
refreshSidebar()
end
end }
})
end
end)
item.MouseEnter:Connect(function()
del.Visible = true
if not selected then tween(item, 0.1, { BackgroundTransparency = 0.5 }) end
end)
item.MouseLeave:Connect(function()
del.Visible = selected
if not selected then tween(item, 0.1, { BackgroundTransparency = 1 }) end
end)
item.MouseButton1Click:Connect(function()
selectProject(proj)
selectConversation(convo)
end)
del.MouseButton1Click:Connect(function()
if convo == current and busy then cancelFlag = true end
local idx = table.find(proj.conversations, convo)
if idx then table.remove(proj.conversations, idx) end
if convo == current then
if #proj.conversations == 0 then
local c = { title = "New chat", messages = {} }
table.insert(proj.conversations, c)
selectConversation(c)
else
selectConversation(proj.conversations[math.min(idx, #proj.conversations)])
end
else
refreshSidebar()
end
end)
end
end
end
end
end
end
local function updateThinkPill()
local m = MODELS[modelIndex]
thinkLabel.Text = m.think
if thinkOn then
thinkPill.BackgroundColor3 = m.accent
thinkPill.BackgroundTransparency = 0.82
thinkStroke.Color = m.accent
thinkLabel.TextColor3 = m.accent
thinkDot.BackgroundColor3 = m.accent
else
thinkPill.BackgroundTransparency = 1
thinkStroke.Color = C.stroke
thinkLabel.TextColor3 = C.subtext
thinkDot.BackgroundColor3 = C.subtext
end
end
local function applyTheme(name)
local palette = THEMES[name] or THEMES["Claude"]
settings.theme = THEMES[name] and name or "Claude"
local oldPalette = ACTIVE_PALETTE
for key, value in pairs(palette) do C[key] = value end
ACTIVE_PALETTE = palette
local function replaceColor(color)
for key, oldColor in pairs(oldPalette) do
if color == oldColor then
return palette[key] or color
end
end
return color
end
for _, obj in ipairs(gui:GetDescendants()) do
if obj:IsA("GuiObject") then
obj.BackgroundColor3 = replaceColor(obj.BackgroundColor3)
if obj:IsA("TextLabel") or obj:IsA("TextButton") or obj:IsA("TextBox") then
obj.TextColor3 = replaceColor(obj.TextColor3)
if obj:IsA("TextBox") then
obj.PlaceholderColor3 = replaceColor(obj.PlaceholderColor3)
end
end
if obj:IsA("ImageLabel") or obj:IsA("ImageButton") then
obj.ImageColor3 = replaceColor(obj.ImageColor3)
end
end
if obj:IsA("UIStroke") then
obj.Color = replaceColor(obj.Color)
end
end
window.BackgroundColor3 = C.bg
sidebar.BackgroundColor3 = C.sidebar
composer.BackgroundColor3 = C.panel
searchBox.BackgroundColor3 = C.bg
messageArea.ScrollBarImageColor3 = C.stroke
convoList.ScrollBarImageColor3 = C.stroke
userCard.BackgroundColor3 = C.panel
avatar.BackgroundColor3 = C.hover
input.TextColor3 = C.text
input.PlaceholderColor3 = C.subtext
searchBox.TextColor3 = C.text
searchBox.PlaceholderColor3 = C.subtext
updateModelChip()
updateThinkPill()
updateHeaderSubtitle()
pcall(function() if current then renderMessages() end end)
pcall(function() refreshSidebar() end)
pcall(function() layoutSidebar() end)
end
local function applyModel()
local m = MODELS[modelIndex]
if settings.customModel and settings.customModel.id ~= m.id then
settings.customModel = nil
settings.modelId = nil
saveSettings()
end
if API.blockedModels then API.blockedModels = {} end
updateModelChip()
local logoModel = settings.autoLogo == false and MODELS[1] or m
if brandSpark then brandSpark:Destroy() end
brandSpark = brandLogo(sideInner, logoModel, 22)
brandSpark.Position = UDim2.fromOffset(18, 17)
if toggleSpark then toggleSpark:Destroy() end
toggleSpark = brandLogo(toggleBtn, logoModel, 22)
toggleSpark.Position = UDim2.fromOffset(12, 12)
input.PlaceholderText = "Message " .. m.brandName .. "..."
if sideProviderLabel then sideProviderLabel.Text = providerLabel(m) end
sendBtn.BackgroundColor3 = m.accent
recolor(plusIcon, m.accent)
updateThinkPill()
updateHeaderSubtitle()
if workspaceRefresh then workspaceRefresh() end
if current and #current.messages == 0 then renderMessages() end
end
thinkPill.MouseButton1Click:Connect(function()
thinkOn = not thinkOn
updateThinkPill()
end)
local menu, catcher
local function closeMenu()
if menu then
local m = menu
menu = nil
tween(m, 0.12, { BackgroundTransparency = 1 })
for _, d in ipairs(m:GetDescendants()) do
if d:IsA("GuiObject") then
pcall(function()
tween(d, 0.1, { BackgroundTransparency = 1, TextTransparency = 1 })
end)
end
end
task.delay(0.14, function() if m.Parent then m:Destroy() end end)
end
if catcher then catcher:Destroy() catcher = nil end
end
function openMenu()
if menu then closeMenu() return end
catcher = button({ Size = UDim2.fromScale(1, 1), ZIndex = 50 }, window)
catcher.MouseButton1Click:Connect(closeMenu)
local w, itemH = 220, 40
local maxH = math.min((#MODELS + #API.CATALOGUE_ORDER) * itemH + 10, 360)
menu = new("Frame", { Size = UDim2.fromOffset(w, maxH), BackgroundColor3 = C.panel, ZIndex = 51 }, window)
round(menu, 12)
stroke(menu)
menu.ClipsDescendants = true
local scroll = new("ScrollingFrame", {
Size = UDim2.fromScale(1, 1), BackgroundTransparency = 1, BorderSizePixel = 0,
ScrollBarThickness = 3, ScrollBarImageColor3 = C.stroke, CanvasSize = UDim2.new(),
AutomaticCanvasSize = Enum.AutomaticSize.Y, ZIndex = 52,
}, menu)
local x = modelBtn.AbsolutePosition.X + modelBtn.AbsoluteSize.X - window.AbsolutePosition.X - w
local y = modelBtn.AbsolutePosition.Y - window.AbsolutePosition.Y - maxH - 8
menu.Position = UDim2.fromOffset(math.max(8, x), math.max(8, y))
menu.BackgroundTransparency = 1
tween(menu, 0.14, { BackgroundTransparency = 0 })
for i, m in ipairs(MODELS) do
local it = button({ Position = UDim2.fromOffset(4, 4 + (i - 1) * itemH), Size = UDim2.new(1, -8, 0, itemH - 4),
BackgroundColor3 = C.hover, BackgroundTransparency = i == modelIndex and 0 or 1, ZIndex = 53 }, scroll)
round(it, 8)
hover(it, C.hover, 0)
local lh = new("Frame", { BackgroundTransparency = 1, Position = UDim2.fromOffset(8, 8),
Size = UDim2.fromOffset(18, 18), ZIndex = 54 }, it)
brandLogo(lh, m, 18)
text({ Position = UDim2.fromOffset(32, 2), Size = UDim2.new(1, -56, 0, 16), Text = m.short,
Font = Enum.Font.GothamMedium, TextSize = 12, TextWrapped = false, ZIndex = 54 }, it)
text({ Position = UDim2.fromOffset(32, 18), Size = UDim2.new(1, -56, 0, 14), Text = m.desc,
TextSize = 10, TextColor3 = C.subtext, TextWrapped = false, ZIndex = 54 }, it)
if i == modelIndex then
local ck = new("Frame", { BackgroundTransparency = 1, AnchorPoint = Vector2.new(1, 0.5),
Position = UDim2.new(1, -8, 0.5, 0), Size = UDim2.fromOffset(14, 14), ZIndex = 54 }, it)
icon("check", ck, m.accent, 14)
end
it.MouseButton1Click:Connect(function()
modelIndex = i
closeMenu()
applyModel()
end)
end
for i, key in ipairs(API.CATALOGUE_ORDER) do
local spec = API.CATALOGUES[key]
local catRow = button({ Position = UDim2.fromOffset(4, 4 + (#MODELS + i - 1) * itemH),
Size = UDim2.new(1, -8, 0, itemH - 4), BackgroundColor3 = C.hover,
BackgroundTransparency = 1, ZIndex = 53 }, scroll)
round(catRow, 8)
hover(catRow, C.hover, 0)
local catIcon = new("Frame", { BackgroundTransparency = 1, Position = UDim2.fromOffset(8, 8),
Size = UDim2.fromOffset(18, 18), ZIndex = 54 }, catRow)
icon("search", catIcon, C.subtext, 16)
text({ Position = UDim2.fromOffset(32, 2), Size = UDim2.new(1, -56, 0, 16), Text = spec.title,
Font = Enum.Font.GothamMedium, TextSize = 12, TextWrapped = false, ZIndex = 54 }, catRow)
text({ Position = UDim2.fromOffset(32, 18), Size = UDim2.new(1, -56, 0, 14), Text = spec.blurb,
TextSize = 10, TextColor3 = C.subtext, TextWrapped = false, ZIndex = 54 }, catRow)
catRow.MouseButton1Click:Connect(function()
closeMenu()
openCataloguePicker(key)
end)
end
end
modelBtn.MouseButton1Click:Connect(openMenu)
function placeholderFor(m) return "Message " .. m.brandName .. "..." end
local function flashPlaceholder(msgText)
input.PlaceholderText = msgText
task.delay(3, function() input.PlaceholderText = placeholderFor(MODELS[modelIndex]) end)
end
function refreshAttachStrip()
for _, ch in ipairs(attachRow:GetChildren()) do
if ch:IsA("GuiObject") then ch:Destroy() end
end
for i, att in ipairs(attachments) do
local th = new("ImageLabel", { Size = UDim2.fromOffset(100, 58), BackgroundColor3 = C.bg,
ScaleType = Enum.ScaleType.Crop, LayoutOrder = i }, attachRow)
round(th, 10)
stroke(th)
pcall(function() th.ImageContent = Content.fromObject(att.editable) end)
local x = button({ AnchorPoint = Vector2.new(1, 0), Position = UDim2.new(1, -3, 0, 3),
Size = UDim2.fromOffset(18, 18), BackgroundColor3 = Color3.new(0, 0, 0), BackgroundTransparency = 0.35 }, th)
round(x, 9)
icon("cross", x, Color3.new(1, 1, 1), 10)
x.MouseButton1Click:Connect(function()
table.remove(attachments, i)
refreshAttachStrip()
end)
end
updateComposer()
end
function attachScreenshot()
if #attachments >= MAX_ATTACH then
flashPlaceholder("Max " .. MAX_ATTACH .. " images per message")
return
end
flashPlaceholder("Capturing screenshot...")
local att, err = captureAttachment()
if not att then
flashPlaceholder("Screenshot failed: " .. tostring(err):sub(1, 70))
return
end
table.insert(attachments, att)
input.PlaceholderText = placeholderFor(MODELS[modelIndex])
refreshAttachStrip()
end
local function makeSwitch(parent, on, accent)
local function pos(v) return UDim2.new(0, v and 17 or 3, 0.5, 0) end
local sw = new("Frame", { AnchorPoint = Vector2.new(1, 0.5), Position = UDim2.new(1, -12, 0.5, 0),
Size = UDim2.fromOffset(34, 20), BackgroundColor3 = on and accent or C.stroke }, parent)
round(sw, 10)
local knob = new("Frame", { AnchorPoint = Vector2.new(0, 0.5), Position = pos(on),
Size = UDim2.fromOffset(14, 14), BackgroundColor3 = Color3.new(1, 1, 1) }, sw)
round(knob, 7)
return function(v)
tween(sw, 0.12, { BackgroundColor3 = v and accent or C.stroke })
tween(knob, 0.12, { Position = pos(v) })
end
end
function openToolsMenu()
if menu then closeMenu() return end
catcher = button({ Size = UDim2.fromScale(1, 1), ZIndex = 50 }, window)
catcher.MouseButton1Click:Connect(closeMenu)
local w, itemH = 300, 56
local rows = {
{ kind = "action", icon = "image", title = "Screenshot the game", desc = "Attach what you see" },
{ kind = "toggle", key = "console", icon = "terminal", title = "Console access", desc = "AI may read errors when needed" },
{ kind = "toggle", key = "screen", icon = "image", title = "Game vision", desc = "AI may look at the game itself" },
{ kind = "toggle", key = "web", icon = "globe", title = "Web search", desc = "AI may look things up online" },
}
local h = #rows * itemH + 12
menu = new("Frame", { Size = UDim2.fromOffset(w, h), BackgroundColor3 = C.panel, ZIndex = 51 }, window)
round(menu, 14)
stroke(menu)
local x = plusBtn.AbsolutePosition.X - window.AbsolutePosition.X
local y = plusBtn.AbsolutePosition.Y - window.AbsolutePosition.Y - h - 8
menu.Position = UDim2.fromOffset(math.max(8, math.min(x, window.AbsoluteSize.X - w - 8)), math.max(8, y))
local accent = MODELS[modelIndex].accent
for i, rw in ipairs(rows) do
local it = button({ Position = UDim2.fromOffset(6, 6 + (i - 1) * itemH), Size = UDim2.new(1, -12, 0, itemH - 4),
BackgroundColor3 = C.hover, BackgroundTransparency = 1 }, menu)
round(it, 10)
hover(it, C.hover, 0)
local ih = new("Frame", { BackgroundTransparency = 1, Position = UDim2.fromOffset(12, 14),
Size = UDim2.fromOffset(22, 22) }, it)
icon(rw.icon, ih, C.text, 20)
text({ Position = UDim2.fromOffset(46, 7), Size = UDim2.new(1, -110, 0, 20), Text = rw.title,
Font = Enum.Font.GothamMedium, TextSize = 14, TextWrapped = false }, it)
text({ Position = UDim2.fromOffset(46, 27), Size = UDim2.new(1, -110, 0, 16), Text = rw.desc,
TextSize = 12, TextColor3 = C.subtext, TextWrapped = false }, it)
if rw.kind == "toggle" then
local setSwitch = makeSwitch(it, settings[rw.key], accent)
it.MouseButton1Click:Connect(function()
settings[rw.key] = not settings[rw.key]
setSwitch(settings[rw.key])
end)
else
it.MouseButton1Click:Connect(function()
closeMenu()
task.spawn(attachScreenshot)
end)
end
end
end
plusBtn.MouseButton1Click:Connect(openToolsMenu)
function openPermissionMenu()
if menu then closeMenu() return end
catcher = button({ Size = UDim2.fromScale(1, 1), ZIndex = 50 }, window)
catcher.MouseButton1Click:Connect(closeMenu)
local w, itemH = 300, 58
local h = #PERMISSION_MODES * itemH + 12
menu = new("Frame", { Size = UDim2.fromOffset(w, h), BackgroundColor3 = C.panel, ZIndex = 51 }, window)
round(menu, 14)
stroke(menu)
local x = moreBtn.AbsolutePosition.X + moreBtn.AbsoluteSize.X - window.AbsolutePosition.X - w
local y = moreBtn.AbsolutePosition.Y - window.AbsolutePosition.Y - h - 8
menu.Position = UDim2.fromOffset(math.max(8, math.min(x, window.AbsoluteSize.X - w - 8)), math.max(8, y))
local accent = MODELS[modelIndex].accent
for i, mode in ipairs(PERMISSION_MODES) do
local selected = settings.permission == mode.id
local it = button({ Position = UDim2.fromOffset(6, 6 + (i - 1) * itemH), Size = UDim2.new(1, -12, 0, itemH - 4),
BackgroundColor3 = C.hover, BackgroundTransparency = selected and 0 or 1, Text = "",
TextXAlignment = Enum.TextXAlignment.Left, ZIndex = 52 }, menu)
round(it, 10)
hover(it, C.hover, 0)
local titleColor = mode.accent and accent or (selected and C.text or C.subtext)
text({ Position = UDim2.fromOffset(14, 8), Size = UDim2.new(1, -44, 0, 20), Text = mode.title,
Font = Enum.Font.GothamMedium, TextSize = 14, TextColor3 = titleColor, ZIndex = 53 }, it)
text({ Position = UDim2.fromOffset(14, 28), Size = UDim2.new(1, -44, 0, 16), Text = mode.desc,
TextSize = 11, TextColor3 = C.subtext, TextWrapped = false, TextTruncate = Enum.TextTruncate.AtEnd, ZIndex = 53 }, it)
if selected then
local ck = icon("check", it, accent, 16)
ck.AnchorPoint = Vector2.new(1, 0.5)
ck.Position = UDim2.new(1, -14, 0.5, 0)
ck.ZIndex = 53
end
it.MouseButton1Click:Connect(function()
settings.permission = mode.id
closeMenu()
end)
end
end
moreBtn.MouseButton1Click:Connect(openPermissionMenu)
local settingsPanel
function openSettings(initialTab)
if settingsPanel then
if API.settingsUI and API.settingsUI.close and API.settingsUI.isOpen
and API.settingsUI.isOpen() then
API.settingsUI.close()
return
end
local old = settingsPanel
settingsPanel = nil
tween(old, 0.12, { BackgroundTransparency = 1 })
task.delay(0.14, function() if old.Parent then old:Destroy() end end)
return
end
closeMenu()
local accent = MODELS[modelIndex].accent
local overlay = new("Frame", { Name = "SettingsOverlay", Size = UDim2.fromScale(1, 1),
BackgroundColor3 = Color3.new(0, 0, 0), BackgroundTransparency = 1, ZIndex = 60 }, window)
settingsPanel = overlay
local catcher = button({ Size = UDim2.fromScale(1, 1), BackgroundTransparency = 1, ZIndex = 60 }, overlay)
local panel = new("Frame", { Name = "SettingsPanel", AnchorPoint = Vector2.new(0.5, 0.5),
Position = UDim2.fromScale(0.5, 0.5), Size = UDim2.new(0.94, 0, 0.92, 0),
BackgroundColor3 = C.panel, ZIndex = 61 }, overlay)
round(panel, 16)
stroke(panel)
new("UISizeConstraint", { MinSize = Vector2.new(600, 420), MaxSize = Vector2.new(1000, 680) }, panel)
local panelScale = new("UIScale", { Scale = 0.97 }, panel)
tween(overlay, 0.16, { BackgroundTransparency = 0.45 })
tween(panelScale, 0.18, { Scale = 1 })
local closing = false
local keyConn
local function closePanel()
if closing then return end
closing = true
if settingsPanel == overlay then settingsPanel = nil end
if keyConn then pcall(function() keyConn:Disconnect() end) end
tween(panelScale, 0.13, { Scale = 0.96 })
tween(overlay, 0.13, { BackgroundTransparency = 1 })
task.delay(0.15, function() if overlay.Parent then overlay:Destroy() end end)
end
catcher.MouseButton1Click:Connect(closePanel)
text({ Position = UDim2.fromOffset(22, 13), Size = UDim2.new(1, -80, 0, 24), Text = "Settings",
Font = Enum.Font.GothamBold, TextSize = 19, ZIndex = 62 }, panel)
text({ Position = UDim2.fromOffset(22, 37), Size = UDim2.new(1, -80, 0, 16),
Text = "Changes apply immediately · Save keeps them for next time", TextSize = 11,
TextColor3 = C.subtext, TextWrapped = false, TextTruncate = Enum.TextTruncate.AtEnd,
ZIndex = 62 }, panel)
local close = button({ AnchorPoint = Vector2.new(1, 0), Position = UDim2.new(1, -12, 0, 12),
Size = UDim2.fromOffset(30, 30), BackgroundColor3 = C.hover, ZIndex = 62 }, panel)
round(close, 8)
hover(close, C.hover, 0)
icon("cross", close, C.subtext, 14)
close.MouseButton1Click:Connect(closePanel)
new("Frame", { Position = UDim2.fromOffset(0, 64), Size = UDim2.new(1, 0, 0, 1),
BackgroundColor3 = C.stroke, BorderSizePixel = 0, ZIndex = 62 }, panel)
local navCol = new("Frame", { Position = UDim2.fromOffset(12, 78), Size = UDim2.new(0, 200, 1, -146),
BackgroundTransparency = 1, ZIndex = 62 }, panel)
local navSearch = new("TextBox", { Size = UDim2.new(1, 0, 0, 32), BackgroundColor3 = C.bg,
Text = "", PlaceholderText = "Search settings", PlaceholderColor3 = C.subtext,
TextColor3 = C.text, TextSize = 12, Font = Enum.Font.Gotham, ClearTextOnFocus = false,
TextXAlignment = Enum.TextXAlignment.Left, ZIndex = 63 }, navCol)
round(navSearch, 8)
stroke(navSearch, C.stroke)
pad(navSearch, 0, 10, 0, 32)
local searchIcon = new("Frame", { BackgroundTransparency = 1, Position = UDim2.fromOffset(11, 10),
Size = UDim2.fromOffset(14, 14), ZIndex = 64 }, navSearch)
icon("search", searchIcon, C.subtext, 14)
local navScroll = new("ScrollingFrame", { Position = UDim2.fromOffset(0, 42),
Size = UDim2.new(1, 0, 1, -42), BackgroundTransparency = 1, BorderSizePixel = 0,
ScrollBarThickness = 3, ScrollBarImageColor3 = C.stroke, CanvasSize = UDim2.new(),
ZIndex = 63 }, navCol)
local content = new("ScrollingFrame", { Position = UDim2.fromOffset(228, 78),
Size = UDim2.new(1, -246, 1, -146), BackgroundTransparency = 1, BorderSizePixel = 0,
ScrollBarThickness = 3, ScrollBarImageColor3 = C.stroke, CanvasSize = UDim2.new(),
AutomaticCanvasSize = Enum.AutomaticSize.Y, ZIndex = 62 }, panel)
new("UIListLayout", { Padding = UDim.new(0, 16), SortOrder = Enum.SortOrder.LayoutOrder }, content)
pad(content, 4, 10, 20, 0)
local PAGES = {
{ id = "General", icon = "settings", group = "Assistant",
desc = "Workspace, agent behaviour and startup" },
{ id = "Personality", icon = "person", group = "Assistant",
desc = "Extra instructions added to every reply" },
{ id = "Providers", icon = "globe", group = "Models",
desc = "Where requests go and which model answers" },
{ id = "Appearance", icon = "image", group = "Workspace",
desc = "Theme, motion and interface scale" },
{ id = "Permissions", icon = "terminal", group = "Workspace",
desc = "What the assistant may read, run and change" },
{ id = "API keys", icon = "code", group = "Account",
desc = "Provider credentials and where they live" },
}
local PAGE_INFO = {}
for _, page in ipairs(PAGES) do PAGE_INFO[page.id] = page end
local SEARCH = {
{ page = "General", title = "Auto compact", desc = "Fold older turns into a summary" },
{ page = "General", title = "Logo follows model", desc = "Show the selected model's mark" },
{ page = "General", title = "Detected game", desc = "Context sent with every request" },
{ page = "Personality", title = "Personality", desc = "Custom instructions for the assistant" },
{ page = "Personality", title = "Starting points", desc = "Preset instruction chips" },
{ page = "Providers", title = "Provider", desc = "OpenCode Zen or AgentRouter" },
{ page = "Providers", title = "Active model", desc = "Chosen model for this session" },
{ page = "Providers", title = "OpenCode key", desc = "Free models use the shared pool" },
{ page = "Providers", title = "AgentRouter key", desc = "Required for Claude and GPT models" },
{ page = "Appearance", title = "Theme", desc = "Palette used across chats and panels" },
{ page = "Appearance", title = "Motion", desc = "Speed of panels, tabs and menus" },
{ page = "Appearance", title = "Interface scale", desc = "Zoom the window contents" },
{ page = "Appearance", title = "Transcript width", desc = "How wide assistant replies can grow" },
{ page = "Permissions", title = "Console access", desc = "Read output the game logs" },
{ page = "Permissions", title = "Game vision", desc = "Capture the screen for visual context" },
{ page = "Permissions", title = "Web search", desc = "Let the model look things up online" },
{ page = "Permissions", title = "Read only", desc = "Inspection only, nothing changes" },
{ page = "Permissions", title = "Ask first", desc = "Writes wait for confirmation" },
{ page = "Permissions", title = "Auto", desc = "Writes run, execution still asks" },
{ page = "Permissions", title = "Allow everything", desc = "No prompts at all" },
{ page = "API keys", title = "Settings file", desc = "Where keys and preferences are stored" },
}
local navButtons, builders = {}, {}
local rowOrder, cardCount = 0, {}
local openPage, currentPage = nil, "General"
local suppressSearch = false
local function indexOf(list, value)
for i, item in ipairs(list) do
if item == value then return i end
end
return 1
end
local function clearContent()
for _, child in ipairs(content:GetChildren()) do
if child:IsA("GuiObject") then child:Destroy() end
end
rowOrder, cardCount = 0, {}
end
local function pageHead(title, blurb)
rowOrder = rowOrder + 1
local head = new("Frame", { Size = UDim2.new(1, 0, 0, 44), BackgroundTransparency = 1,
LayoutOrder = rowOrder }, content)
text({ Position = UDim2.fromOffset(0, 2), Size = UDim2.new(1, 0, 0, 20), Text = title,
Font = Enum.Font.GothamBold, TextSize = 17, ZIndex = 64 }, head)
text({ Position = UDim2.fromOffset(0, 24), Size = UDim2.new(1, 0, 0, 16), Text = blurb or "",
TextSize = 11, TextColor3 = C.subtext, TextWrapped = false,
TextTruncate = Enum.TextTruncate.AtEnd, ZIndex = 64 }, head)
end
local function section(label)
rowOrder = rowOrder + 1
local wrap = new("Frame", { Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y,
BackgroundTransparency = 1, LayoutOrder = rowOrder }, content)
new("UIListLayout", { Padding = UDim.new(0, 6), SortOrder = Enum.SortOrder.LayoutOrder }, wrap)
text({ Size = UDim2.new(1, 0, 0, 14), Text = label:upper(), Font = Enum.Font.GothamBold,
TextSize = 10, TextColor3 = C.subtext, TextWrapped = false, LayoutOrder = 1,
ZIndex = 64 }, wrap)
local card = new("Frame", { Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y,
BackgroundColor3 = C.bg, LayoutOrder = 2 }, wrap)
round(card, 10)
stroke(card, C.stroke)
new("UIListLayout", { SortOrder = Enum.SortOrder.LayoutOrder }, card)
cardCount[card] = 0
return card
end
local function row(card, title, desc, height)
cardCount[card] = (cardCount[card] or 0) + 1
local index = cardCount[card]
local r = new("Frame", { Size = UDim2.new(1, 0, 0, height or 58), BackgroundTransparency = 1,
LayoutOrder = index }, card)
pad(r, 0, 14, 0, 14)
if index > 1 then
new("Frame", { Position = UDim2.new(0, 14, 0, 0), Size = UDim2.new(1, -28, 0, 1),
BackgroundColor3 = C.stroke, BackgroundTransparency = 0.5, BorderSizePixel = 0,
ZIndex = 63 }, r)
end
text({ Position = UDim2.fromOffset(0, 9), Size = UDim2.new(1, -216, 0, 18), Text = title,
TextSize = 13, TextWrapped = false, TextTruncate = Enum.TextTruncate.AtEnd,
ZIndex = 64 }, r)
if desc and desc ~= "" then
text({ Position = UDim2.fromOffset(0, 27), Size = UDim2.new(1, -216, 0, 16), Text = desc,
TextSize = 10, TextColor3 = C.subtext, TextWrapped = false,
TextTruncate = Enum.TextTruncate.AtEnd, ZIndex = 64 }, r)
end
return r
end
local function pill(parent, value, color, y)
local box = new("Frame", { AnchorPoint = Vector2.new(1, 0), Position = UDim2.new(1, 0, 0, y or 10),
Size = UDim2.fromOffset(0, 22), AutomaticSize = Enum.AutomaticSize.X,
BackgroundColor3 = C.panel, ZIndex = 64 }, parent)
round(box, 11)
stroke(box, C.stroke)
pad(box, 0, 10, 0, 10)
text({ Size = UDim2.fromOffset(0, 22), AutomaticSize = Enum.AutomaticSize.X, Text = value,
TextSize = 10, TextColor3 = color or C.subtext, TextWrapped = false, ZIndex = 65 }, box)
return box
end
local function segmented(parent, options, selectedIndex, onPick)
local box = new("Frame", { AnchorPoint = Vector2.new(1, 0.5), Position = UDim2.new(1, 0, 0.5, 0),
Size = UDim2.fromOffset(4, 30), AutomaticSize = Enum.AutomaticSize.X,
BackgroundColor3 = C.panel, ZIndex = 64 }, parent)
round(box, 9)
stroke(box, C.stroke)
new("UIListLayout", { FillDirection = Enum.FillDirection.Horizontal, Padding = UDim.new(0, 2),
VerticalAlignment = Enum.VerticalAlignment.Center,
SortOrder = Enum.SortOrder.LayoutOrder }, box)
pad(box, 2, 2, 2, 2)
local chips = {}
local function select(i, silent)
for idx, chip in ipairs(chips) do
tween(chip, 0.14, { BackgroundTransparency = idx == i and 0 or 1,
TextColor3 = idx == i and C.text or C.subtext })
end
if not silent and onPick then onPick(i) end
end
for i, label in ipairs(options) do
local chip = button({ Size = UDim2.fromOffset(0, 26), AutomaticSize = Enum.AutomaticSize.X,
BackgroundColor3 = C.hover, BackgroundTransparency = i == selectedIndex and 0 or 1,
Text = label, TextSize = 12, TextWrapped = false,
TextColor3 = i == selectedIndex and C.text or C.subtext,
LayoutOrder = i, ZIndex = 65 }, box)
round(chip, 7)
pad(chip, 0, 12, 0, 12)
chips[i] = chip
chip.MouseButton1Click:Connect(function() select(i) end)
end
return select
end
local function toggle(parent, value, onChange)
local on = value and true or false
local track = button({ AnchorPoint = Vector2.new(1, 0.5), Position = UDim2.new(1, 0, 0.5, 0),
Size = UDim2.fromOffset(40, 22), BackgroundColor3 = on and accent or C.hover,
ZIndex = 64 }, parent)
round(track, 11)
local knob = new("Frame", { AnchorPoint = Vector2.new(0, 0.5),
Position = UDim2.new(0, on and 19 or 3, 0.5, 0), Size = UDim2.fromOffset(16, 16),
BackgroundColor3 = Color3.new(1, 1, 1), BorderSizePixel = 0, ZIndex = 65 }, track)
round(knob, 8)
track.MouseButton1Click:Connect(function()
on = not on
tween(track, 0.14, { BackgroundColor3 = on and accent or C.hover })
tween(knob, 0.16, { Position = UDim2.new(0, on and 19 or 3, 0.5, 0) })
if onChange then onChange(on) end
end)
return track
end
local function optionRow(card, title, desc, selected, onPick)
cardCount[card] = (cardCount[card] or 0) + 1
local index = cardCount[card]
local r = button({ Size = UDim2.new(1, 0, 0, 64), BackgroundTransparency = 1,
LayoutOrder = index, ZIndex = 63 }, card)
pad(r, 0, 14, 0, 14)
if index > 1 then
new("Frame", { Position = UDim2.new(0, 14, 0, 0), Size = UDim2.new(1, -28, 0, 1),
BackgroundColor3 = C.stroke, BackgroundTransparency = 0.5, BorderSizePixel = 0 }, r)
end
text({ Position = UDim2.fromOffset(0, 12), Size = UDim2.new(1, -58, 0, 18), Text = title,
TextSize = 13, ZIndex = 64 }, r)
text({ Position = UDim2.fromOffset(0, 31), Size = UDim2.new(1, -58, 0, 16), Text = desc,
TextSize = 10, TextColor3 = C.subtext, TextWrapped = false,
TextTruncate = Enum.TextTruncate.AtEnd, ZIndex = 64 }, r)
local dot = new("Frame", { AnchorPoint = Vector2.new(1, 0.5),
Position = UDim2.new(1, 0, 0.5, 0), Size = UDim2.fromOffset(20, 20),
BackgroundColor3 = selected and accent or C.panel,
BackgroundTransparency = selected and 0 or 1, BorderSizePixel = 0, ZIndex = 64 }, r)
round(dot, 10)
if selected then
icon("check", dot, Color3.new(1, 1, 1), 12)
else
stroke(dot, C.stroke, 1.5)
end
r.MouseButton1Click:Connect(function() onPick() end)
end
local function themeGrid(card)
cardCount[card] = (cardCount[card] or 0) + 1
local holder = new("Frame", { Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y,
BackgroundTransparency = 1, LayoutOrder = cardCount[card] }, card)
pad(holder, 12, 14, 10, 14)
new("UIListLayout", { FillDirection = Enum.FillDirection.Horizontal, Wraps = true,
Padding = UDim.new(0, 8), SortOrder = Enum.SortOrder.LayoutOrder }, holder)
local borders = {}
local function paint(name)
for key, border in pairs(borders) do
border.Color = key == name and accent or C.stroke
border.Thickness = key == name and 2 or 1
end
end
for i, name in ipairs({ "Claude", "ChatGPT", "DeepSeek", "GLM", "Kimi" }) do
local pal = THEMES[name]
local b = button({ Size = UDim2.fromOffset(94, 68), BackgroundColor3 = pal.bg,
LayoutOrder = i, ZIndex = 64 }, holder)
round(b, 8)
borders[name] = stroke(b, C.stroke)
local lineA = new("Frame", { Position = UDim2.fromOffset(10, 12), Size = UDim2.fromOffset(42, 7),
BackgroundColor3 = pal.text, BorderSizePixel = 0, ZIndex = 65 }, b)
round(lineA, 3)
local lineB = new("Frame", { Position = UDim2.fromOffset(10, 24), Size = UDim2.fromOffset(60, 5),
BackgroundColor3 = pal.subtext, BorderSizePixel = 0, ZIndex = 65 }, b)
round(lineB, 2)
local swatch = new("Frame", { Position = UDim2.fromOffset(10, 38), Size = UDim2.fromOffset(30, 16),
BackgroundColor3 = pal.panel, BorderSizePixel = 0, ZIndex = 65 }, b)
round(swatch, 4)
text({ Position = UDim2.fromOffset(10, 51), Size = UDim2.new(1, -16, 0, 12), Text = name,
TextSize = 9, TextColor3 = pal.subtext, TextWrapped = false, ZIndex = 65 }, b)
b.MouseButton1Click:Connect(function()
applyTheme(name)
settings.theme = name
paint(name)
end)
end
paint(settings.theme)
end
local function openModelPicker()
local layer = new("Frame", { Size = UDim2.fromScale(1, 1), BackgroundColor3 = Color3.new(0, 0, 0),
BackgroundTransparency = 0.45, ZIndex = 80 }, panel)
local backdrop = button({ Size = UDim2.fromScale(1, 1), BackgroundTransparency = 1,
ZIndex = 80 }, layer)
local box = new("Frame", { AnchorPoint = Vector2.new(0.5, 0.5), Position = UDim2.fromScale(0.5, 0.5),
Size = UDim2.fromOffset(360, 380), BackgroundColor3 = C.panel, ZIndex = 81 }, layer)
round(box, 14)
stroke(box)
text({ Position = UDim2.fromOffset(16, 12), Size = UDim2.new(1, -32, 0, 20), Text = "Choose a model",
Font = Enum.Font.GothamBold, TextSize = 15, ZIndex = 82 }, box)
local list = new("ScrollingFrame", { Position = UDim2.fromOffset(8, 40),
Size = UDim2.new(1, -16, 1, -48), BackgroundTransparency = 1, BorderSizePixel = 0,
ScrollBarThickness = 3, ScrollBarImageColor3 = C.stroke, CanvasSize = UDim2.new(),
AutomaticCanvasSize = Enum.AutomaticSize.Y, ZIndex = 82 }, box)
new("UIListLayout", { Padding = UDim.new(0, 2) }, list)
for i, m in ipairs(MODELS) do
local item = button({ Size = UDim2.new(1, 0, 0, 46), BackgroundColor3 = C.hover,
BackgroundTransparency = i == modelIndex and 0 or 1, LayoutOrder = i,
ZIndex = 82 }, list)
round(item, 8)
hover(item, C.hover, 0.1)
local dot = new("Frame", { Position = UDim2.fromOffset(10, 14), Size = UDim2.fromOffset(18, 18),
BackgroundColor3 = m.accent, BorderSizePixel = 0, ZIndex = 83 }, item)
round(dot, 9)
text({ Position = UDim2.fromOffset(36, 6), Size = UDim2.new(1, -60, 0, 16),
Text = m.brandName .. " " .. m.short, TextSize = 12, ZIndex = 83 }, item)
text({ Position = UDim2.fromOffset(36, 23), Size = UDim2.new(1, -60, 0, 14), Text = m.desc,
TextSize = 10, TextColor3 = C.subtext, ZIndex = 83 }, item)
if i == modelIndex then
local mark = new("Frame", { AnchorPoint = Vector2.new(1, 0.5),
Position = UDim2.new(1, -10, 0.5, 0), Size = UDim2.fromOffset(14, 14),
BackgroundTransparency = 1, ZIndex = 83 }, item)
icon("check", mark, m.accent, 14)
end
item.MouseButton1Click:Connect(function()
modelIndex = i
applyModel()
layer:Destroy()
openPage("Providers")
end)
end
local orItem = button({ Size = UDim2.new(1, 0, 0, 46), BackgroundColor3 = C.hover,
BackgroundTransparency = 1, LayoutOrder = #MODELS + 1, ZIndex = 82 }, list)
round(orItem, 8)
hover(orItem, C.hover, 0.1)
local orDot = new("Frame", { Position = UDim2.fromOffset(10, 14), Size = UDim2.fromOffset(18, 18),
BackgroundTransparency = 1, ZIndex = 83 }, orItem)
icon("search", orDot, C.subtext, 16)
text({ Position = UDim2.fromOffset(36, 6), Size = UDim2.new(1, -60, 0, 16),
Text = "OpenRouter models", TextSize = 12, ZIndex = 83 }, orItem)
text({ Position = UDim2.fromOffset(36, 23), Size = UDim2.new(1, -60, 0, 14),
Text = "Search the live catalogue", TextSize = 10, TextColor3 = C.subtext, ZIndex = 83 }, orItem)
orItem.MouseButton1Click:Connect(function()
layer:Destroy()
openPage("Providers")
openOpenRouterPicker()
end)
backdrop.MouseButton1Click:Connect(function() layer:Destroy() end)
end
local function keyField(card, title, desc, key)
local r = row(card, title, desc, 98)
local state = pill(r, (settings[key] or "") ~= "" and "Set" or "Empty",
(settings[key] or "") ~= "" and Color3.fromRGB(106, 200, 145) or C.subtext, 12)
local stateLabel = state:FindFirstChildOfClass("TextLabel")
local box = new("TextBox", { Position = UDim2.fromOffset(0, 47), Size = UDim2.new(1, -196, 0, 32),
BackgroundColor3 = C.panel, Text = settings[key] or "", PlaceholderText = "Paste " .. title:lower(),
PlaceholderColor3 = C.subtext, TextColor3 = C.text, TextSize = 12, Font = Enum.Font.Code,
ClearTextOnFocus = false, TextXAlignment = Enum.TextXAlignment.Left, ZIndex = 64 }, r)
round(box, 8)
stroke(box, C.stroke)
pad(box, 0, 10, 0, 10)
box:GetPropertyChangedSignal("Text"):Connect(function()
settings[key] = box.Text:match("^%s*(.-)%s*$") or ""
if stateLabel and stateLabel.Parent then
local filled = settings[key] ~= ""
stateLabel.Text = filled and "Set" or "Empty"
stateLabel.TextColor3 = filled and Color3.fromRGB(106, 200, 145) or C.subtext
end
end)
local test = button({ AnchorPoint = Vector2.new(1, 0), Position = UDim2.new(1, 0, 0, 47),
Size = UDim2.fromOffset(88, 32), BackgroundColor3 = C.hover, Text = "Test", TextSize = 12,
ZIndex = 64 }, r)
round(test, 8)
stroke(test, C.stroke)
hover(test, C.panel, 0)
local clear = button({ AnchorPoint = Vector2.new(1, 0), Position = UDim2.new(1, -96, 0, 47),
Size = UDim2.fromOffset(84, 32), BackgroundColor3 = C.hover, Text = "Clear", TextSize = 12,
ZIndex = 64 }, r)
round(clear, 8)
stroke(clear, C.stroke)
hover(clear, C.panel, 0)
clear.MouseButton1Click:Connect(function()
box.Text = ""
settings[key] = ""
if stateLabel and stateLabel.Parent then
stateLabel.Text = "Empty"
stateLabel.TextColor3 = C.subtext
end
end)
test.MouseButton1Click:Connect(function()
if not testApiConnection then return end
if stateLabel and stateLabel.Parent then
stateLabel.Text = "Testing…"
stateLabel.TextColor3 = C.subtext
end
test.Text = "Testing…"
test.Active = false
local provider = key == "opencodeApiKey" and "opencode" or (key == "openrouterKey" and "openrouter" or "agentrouter")
local previous = settings.provider
settings.provider = provider
testApiConnection(settings[key], function(ok, message)
if stateLabel and stateLabel.Parent then
local short = tostring(message or (ok and "Works" or "Failed"))
if #short > 34 then short = short:sub(1, 31) .. "..." end
stateLabel.Text = short
stateLabel.TextColor3 = ok and Color3.fromRGB(106, 200, 145) or C.error
end
if test.Parent then
test.Text = "Test"
test.Active = true
end
settings.provider = previous
end, provider)
end)
end
builders.General = function()
pageHead("General", PAGE_INFO.General.desc)
local session = section("Session")
local gameRow = row(session, "Detected game", "Sent as context with every request")
pill(gameRow, gameName, C.subtext, 19)
toggle(row(session, "Auto compact", "Fold older turns into a summary when a session gets long"),
settings.autoCompact ~= false, function(v) settings.autoCompact = v end)
local startup = section("Startup")
toggle(row(startup, "Logo follows model", "Show the selected model's mark in the sidebar"),
settings.autoLogo ~= false, function(v)
settings.autoLogo = v
applyModel()
end)
end
builders.Personality = function()
pageHead("Personality", PAGE_INFO.Personality.desc)
local card = section("Custom instructions")
local r = row(card, "Personality", "Added to the system message of every conversation", 176)
local box = new("TextBox", { Position = UDim2.fromOffset(0, 47), Size = UDim2.new(1, -110, 0, 92),
BackgroundColor3 = C.panel, Text = settings.personality or "",
PlaceholderText = "Example: Be concise, explain the fix before the code.",
PlaceholderColor3 = C.subtext, TextColor3 = C.text, Font = Enum.Font.Gotham, TextSize = 12,
TextWrapped = true, TextYAlignment = Enum.TextYAlignment.Top,
TextXAlignment = Enum.TextXAlignment.Left, ClearTextOnFocus = false, MultiLine = true,
ZIndex = 64 }, r)
round(box, 8)
stroke(box, C.stroke)
pad(box, 8, 10, 8, 10)
local counter = text({ AnchorPoint = Vector2.new(1, 0), Position = UDim2.new(1, 0, 0, 47),
Size = UDim2.fromOffset(96, 16), Text = tostring(#(settings.personality or "")) .. " chars",
TextSize = 10, TextColor3 = C.subtext, TextXAlignment = Enum.TextXAlignment.Right,
TextWrapped = false, ZIndex = 64 }, r)
local clearBtn = button({ AnchorPoint = Vector2.new(1, 0), Position = UDim2.new(1, 0, 0, 70),
Size = UDim2.fromOffset(96, 30), BackgroundColor3 = C.hover, Text = "Clear", TextSize = 11,
ZIndex = 64 }, r)
round(clearBtn, 8)
hover(clearBtn, C.panel, 0)
clearBtn.MouseButton1Click:Connect(function()
box.Text = ""
settings.personality = ""
counter.Text = "0 chars"
end)
box:GetPropertyChangedSignal("Text"):Connect(function()
settings.personality = box.Text
if counter.Parent then counter.Text = tostring(#box.Text) .. " chars" end
end)
local presets = section("Starting points")
local holder = new("Frame", { Size = UDim2.new(1, 0, 0, 52), BackgroundTransparency = 1,
LayoutOrder = cardCount[presets] + 1 }, presets)
pad(holder, 12, 14, 12, 14)
new("UIListLayout", { FillDirection = Enum.FillDirection.Horizontal, Padding = UDim.new(0, 6),
VerticalAlignment = Enum.VerticalAlignment.Center,
SortOrder = Enum.SortOrder.LayoutOrder }, holder)
for i, preset in ipairs({
{ "Concise", "Be concise. Lead with the answer, then the details." },
{ "Explain code", "Explain code changes before showing them." },
{ "Ask first", "Ask before guessing when the request is ambiguous." },
{ "Roblox focus", "Prefer idiomatic Roblox Luau and mention performance costs." },
}) do
local chip = button({ Size = UDim2.fromOffset(0, 28), AutomaticSize = Enum.AutomaticSize.X,
BackgroundColor3 = C.hover, Text = preset[1], TextSize = 11, LayoutOrder = i,
ZIndex = 64 }, holder)
round(chip, 7)
pad(chip, 0, 12, 0, 12)
hover(chip, C.panel, 0)
chip.MouseButton1Click:Connect(function()
local current = settings.personality or ""
local addition = preset[2]
if current ~= "" then addition = current .. " " .. addition end
settings.personality = addition
box.Text = addition
end)
end
end
builders.Providers = function()
pageHead("Providers", PAGE_INFO.Providers.desc)
local routing = section("Routing")
local routingNames = { "OpenCode", "AgentRouter", "OpenRouter" }
local routingIds = { "opencode", "agentrouter", "openrouter" }
segmented(row(routing, "Provider", "Where model requests are sent"),
routingNames, math.max(1, indexOf(routingIds, settings.provider)), function(i)
settings.provider = routingIds[i]
if sideProviderLabel then
sideProviderLabel.Text = providerLabel(MODELS[modelIndex])
end
end)
local modelRow = row(routing, "Active model", "Chosen model for this session")
local picker = button({ AnchorPoint = Vector2.new(1, 0.5), Position = UDim2.new(1, 0, 0.5, 0),
Size = UDim2.fromOffset(196, 34), BackgroundColor3 = C.panel, ZIndex = 64 }, modelRow)
round(picker, 8)
stroke(picker, C.stroke)
hover(picker, C.hover, 0)
local dot = new("Frame", { Position = UDim2.fromOffset(10, 9), Size = UDim2.fromOffset(16, 16),
BackgroundColor3 = MODELS[modelIndex].accent, BorderSizePixel = 0, ZIndex = 65 }, picker)
round(dot, 8)
text({ Position = UDim2.fromOffset(34, 7), Size = UDim2.new(1, -44, 0, 20),
Text = MODELS[modelIndex].brandName .. " " .. MODELS[modelIndex].short, TextSize = 12,
TextWrapped = false, TextTruncate = Enum.TextTruncate.AtEnd, ZIndex = 65 }, picker)
picker.MouseButton1Click:Connect(openModelPicker)
local keys = section("Credentials")
keyField(keys, "OpenCode key", "Optional. Free models use the shared free pool.", "opencodeApiKey")
keyField(keys, "AgentRouter key", "Required for Claude and GPT models.", "apiKey")
keyField(keys, "OpenRouter key", "Unlocks the full OpenRouter catalogue.", "openrouterKey")
for _, prov in ipairs(API.CATALOGUE_ORDER) do
local spec = API.CATALOGUES[prov]
local browseRow = row(keys, spec.title, "Browse this provider's live catalogue")
local browseBtn = button({ AnchorPoint = Vector2.new(1, 0.5), Position = UDim2.new(1, 0, 0.5, 0),
Size = UDim2.fromOffset(110, 30), BackgroundColor3 = C.hover, Text = "Browse", TextSize = 11,
ZIndex = 64 }, browseRow)
round(browseBtn, 8)
stroke(browseBtn, C.stroke)
hover(browseBtn, C.panel, 0)
browseBtn.MouseButton1Click:Connect(function() openCataloguePicker(prov) end)
end
local status = section("Status")
local statusRow = row(status, "Routing preference", "Falls back automatically when a provider refuses")
local routingFirst = { opencode = "Zen first", agentrouter = "AgentRouter first", openrouter = "OpenRouter first" }
pill(statusRow, routingFirst[settings.provider] or "Zen first", C.subtext, 18)
end
builders.Appearance = function()
pageHead("Appearance", PAGE_INFO.Appearance.desc)
themeGrid(section("Theme"))
local motion = section("Motion")
segmented(row(motion, "Transitions", "How quickly panels, tabs and menus move"),
{ "Full", "Subtle", "Off" }, indexOf({ "Full", "Subtle", "Off" }, settings.motion), function(i)
settings.motion = ({ "Full", "Subtle", "Off" })[i]
MOTION = settings.motion == "Off" and 0 or (settings.motion == "Subtle" and 0.6 or 1)
end)
local layoutSection = section("Layout")
segmented(row(layoutSection, "Interface scale", "Zoom the window contents"),
{ "85%", "100%", "110%" }, indexOf({ 0.85, 1, 1.1 }, settings.uiScale), function(i)
settings.uiScale = ({ 0.85, 1, 1.1 })[i]
winScale.Scale = settings.uiScale
end)
segmented(row(layoutSection, "Transcript width", "How wide assistant replies can grow"),
{ "Narrow", "Wide" }, (settings.transcriptWidth or 740) >= 860 and 2 or 1, function(i)
settings.transcriptWidth = i == 2 and 880 or 740
local constraint = column:FindFirstChildOfClass("UISizeConstraint")
if constraint then
constraint.MaxSize = Vector2.new(settings.transcriptWidth, math.huge)
end
end)
end
builders.Permissions = function()
pageHead("Permissions", PAGE_INFO.Permissions.desc)
local access = section("Data access")
toggle(row(access, "Console access", "Read output the game logs"), settings.console ~= false,
function(v) settings.console = v end)
toggle(row(access, "Game vision", "Capture the screen for visual context"), settings.screen ~= false,
function(v) settings.screen = v end)
toggle(row(access, "Web search", "Let the model look things up online"), settings.web ~= false,
function(v) settings.web = v end)
local modes = section("Tool permissions")
for _, mode in ipairs(PERMISSION_MODES) do
optionRow(modes, mode.title, mode.desc, settings.permission == mode.id, function()
settings.permission = mode.id
openPage("Permissions")
end)
end
local note = section("How prompts work")
row(note, "Approvals time out", "A prompt left unanswered for 30 seconds counts as declined.")
end
builders["API keys"] = function()
pageHead("API keys", PAGE_INFO["API keys"].desc)
local keys = section("Credentials")
keyField(keys, "AgentRouter key", "Required for Claude and GPT models.", "apiKey")
keyField(keys, "OpenCode key", "Optional. Free models use the shared free pool.", "opencodeApiKey")
keyField(keys, "OpenRouter key", "Unlocks the full OpenRouter catalogue.", "openrouterKey")
local storage = section("Storage")
local fileRow = row(storage, "Settings file", "Keys and preferences are written here only")
pill(fileRow, "AgentRouterRobloxAI.json", C.subtext, 18)
row(storage, "Plain text", "Treat this file like a password. Nothing is uploaded.")
local danger = section("Reset")
local clearRow = row(danger, "Remove all keys", "Clears the fields above and saves an empty key")
local clearBtn = button({ AnchorPoint = Vector2.new(1, 0.5), Position = UDim2.new(1, 0, 0.5, 0),
Size = UDim2.fromOffset(120, 30), BackgroundColor3 = C.hover, Text = "Remove keys",
TextSize = 11, ZIndex = 64 }, clearRow)
round(clearBtn, 8)
stroke(clearBtn, C.stroke)
hover(clearBtn, C.error, 0.75)
local armed = false
clearBtn.MouseButton1Click:Connect(function()
if not armed then
armed = true
clearBtn.Text = "Click to confirm"
task.delay(3, function()
if clearBtn.Parent then
armed = false
clearBtn.Text = "Remove keys"
end
end)
return
end
settings.apiKey = ""
settings.opencodeApiKey = ""
settings.openrouterKey = ""
AGENTROUTER_KEY, OPENCODE_KEY = "", ""
saveSettings()
openPage("API keys")
end)
end
local function showResults(query)
clearContent()
local matches = {}
for _, entry in ipairs(SEARCH) do
if entry.title:lower():find(query, 1, true) or entry.desc:lower():find(query, 1, true) then
matches[#matches + 1] = entry
end
end
pageHead("Search", tostring(#matches) .. " setting" .. (#matches == 1 and "" or "s") .. " found")
local card = section("Results")
if #matches == 0 then
row(card, "Nothing matches", "Try a shorter word, like theme, key or motion.")
return
end
for _, entry in ipairs(matches) do
cardCount[card] = (cardCount[card] or 0) + 1
local index = cardCount[card]
local r = button({ Size = UDim2.new(1, 0, 0, 52), BackgroundTransparency = 1,
LayoutOrder = index, ZIndex = 63 }, card)
pad(r, 0, 14, 0, 14)
if index > 1 then
new("Frame", { Position = UDim2.new(0, 14, 0, 0), Size = UDim2.new(1, -28, 0, 1),
BackgroundColor3 = C.stroke, BackgroundTransparency = 0.5,
BorderSizePixel = 0 }, r)
end
text({ Position = UDim2.fromOffset(0, 9), Size = UDim2.new(1, -110, 0, 18), Text = entry.title,
TextSize = 13, ZIndex = 64 }, r)
text({ Position = UDim2.fromOffset(0, 27), Size = UDim2.new(1, -110, 0, 16), Text = entry.desc,
TextSize = 10, TextColor3 = C.subtext, TextWrapped = false,
TextTruncate = Enum.TextTruncate.AtEnd, ZIndex = 64 }, r)
pill(r, entry.page, C.subtext, 15)
r.MouseButton1Click:Connect(function()
suppressSearch = true
navSearch.Text = ""
suppressSearch = false
openPage(entry.page)
end)
end
end
openPage = function(name)
if not builders[name] then name = "General" end
currentPage = name
for pageName, entry in pairs(navButtons) do
local active = pageName == name
tween(entry.btn, 0.14, { BackgroundTransparency = active and 0.35 or 1 })
entry.label.TextColor3 = active and C.text or C.subtext
entry.bar.Visible = active
end
clearContent()
builders[name]()
content.CanvasPosition = Vector2.new(0, 0)
content.Position = UDim2.fromOffset(234, 78)
tween(content, 0.16, { Position = UDim2.fromOffset(228, 78) })
end
local navY, lastGroup = 2, nil
for _, page in ipairs(PAGES) do
if page.group ~= lastGroup then
lastGroup = page.group
text({ Position = UDim2.fromOffset(10, navY + 6), Size = UDim2.new(1, -20, 0, 12),
Text = page.group:upper(), Font = Enum.Font.GothamBold, TextSize = 9,
TextColor3 = C.subtext, TextWrapped = false, ZIndex = 64 }, navScroll)
navY = navY + 24
end
local b = button({ Position = UDim2.fromOffset(0, navY), Size = UDim2.new(1, 0, 0, 32),
BackgroundColor3 = C.hover, BackgroundTransparency = 1, ZIndex = 63 }, navScroll)
round(b, 8)
hover(b, C.hover, 0.35)
local bar = new("Frame", { Position = UDim2.fromOffset(0, 9), Size = UDim2.fromOffset(2, 14),
BackgroundColor3 = accent, BorderSizePixel = 0, Visible = false, ZIndex = 65 }, b)
round(bar, 1)
local ic = new("Frame", { BackgroundTransparency = 1, Position = UDim2.fromOffset(12, 8),
Size = UDim2.fromOffset(16, 16), ZIndex = 64 }, b)
icon(page.icon, ic, C.subtext, 16)
local label = text({ Position = UDim2.fromOffset(38, 0), Size = UDim2.new(1, -48, 1, 0),
Text = page.id, TextSize = 12, TextColor3 = C.subtext, TextWrapped = false,
ZIndex = 64 }, b)
navButtons[page.id] = { btn = b, label = label, bar = bar }
b.MouseButton1Click:Connect(function()
suppressSearch = true
navSearch.Text = ""
suppressSearch = false
openPage(page.id)
end)
navY = navY + 35
end
navScroll.CanvasSize = UDim2.new(0, 0, 0, navY + 6)
local function applySearch(raw)
local query = tostring(raw or ""):lower():match("^%s*(.-)%s*$")
for pageName, entry in pairs(navButtons) do
local visible = query == ""
if not visible then
visible = pageName:lower():find(query, 1, true) ~= nil
end
if not visible then
for _, item in ipairs(SEARCH) do
if item.page == pageName and (item.title:lower():find(query, 1, true)
or item.desc:lower():find(query, 1, true)) then
visible = true
break
end
end
end
entry.btn.Visible = visible
end
if query == "" then
openPage(currentPage)
else
showResults(query)
end
end
navSearch:GetPropertyChangedSignal("Text"):Connect(function()
if suppressSearch then return end
applySearch(navSearch.Text)
end)
text({ Position = UDim2.new(0, 20, 1, -60), Size = UDim2.new(0, 216, 0, 60),
Text = "Stored locally · AgentRouterRobloxAI.json", TextSize = 10,
TextColor3 = C.subtext, TextWrapped = false, ZIndex = 64 }, panel)
local saveNote = text({ AnchorPoint = Vector2.new(1, 0.5), Position = UDim2.new(1, -360, 1, -30),
Size = UDim2.fromOffset(100, 16), Text = "", TextSize = 10, TextColor3 = C.subtext,
TextXAlignment = Enum.TextXAlignment.Right, TextWrapped = false, ZIndex = 64 }, panel)
local resetBtn = button({ AnchorPoint = Vector2.new(1, 0.5), Position = UDim2.new(1, -256, 1, -30),
Size = UDim2.fromOffset(96, 34), BackgroundColor3 = C.hover, Text = "Reset all",
TextSize = 12, ZIndex = 64 }, panel)
round(resetBtn, 9)
stroke(resetBtn, C.stroke)
hover(resetBtn, C.panel, 0)
local saveBtn = button({ AnchorPoint = Vector2.new(1, 0.5), Position = UDim2.new(1, -20, 1, -30),
Size = UDim2.fromOffset(132, 34), BackgroundColor3 = accent, Text = "Save changes",
TextSize = 13, ZIndex = 64 }, panel)
round(saveBtn, 9)
hover(saveBtn, accent, 0.12)
local function applySettings()
AGENTROUTER_KEY = settings.apiKey or ""
OPENCODE_KEY = settings.opencodeApiKey or ""
winScale.Scale = settings.uiScale or 1
local constraint = column:FindFirstChildOfClass("UISizeConstraint")
if constraint then
constraint.MaxSize = Vector2.new(settings.transcriptWidth or 740, math.huge)
end
MOTION = settings.motion == "Off" and 0 or (settings.motion == "Subtle" and 0.6 or 1)
applyTheme(settings.theme)
return saveSettings()
end
local function saveNow()
local saved = applySettings()
if API.settingsUI then API.settingsUI.lastSave = saved and "Saved" or "Applied" end
saveNote.Text = saved and "Saved" or "Applied"
saveNote.TextColor3 = saved and Color3.fromRGB(106, 200, 145) or C.subtext
task.delay(2, function() if saveNote.Parent then saveNote.Text = "" end end)
end
local function resetNow()
settings.theme = "Claude"
settings.motion = "Full"
settings.uiScale = 1
settings.transcriptWidth = 740
settings.autoLogo = true
settings.autoCompact = true
settings.permission = "ask"
settings.console = true
settings.screen = true
settings.web = true
settings.personality = ""
winScale.Scale = 1
MOTION = 1
local constraint = column:FindFirstChildOfClass("UISizeConstraint")
if constraint then constraint.MaxSize = Vector2.new(740, math.huge) end
applyTheme("Claude")
saveSettings()
openPage(currentPage)
saveNote.Text = "Defaults restored"
saveNote.TextColor3 = Color3.fromRGB(106, 200, 145)
task.delay(2, function() if saveNote.Parent then saveNote.Text = "" end end)
end
local resetArmed = false
resetBtn.MouseButton1Click:Connect(function()
if not resetArmed then
resetArmed = true
resetBtn.Text = "Confirm reset"
task.delay(3, function()
if resetBtn.Parent then
resetArmed = false
resetBtn.Text = "Reset all"
end
end)
return
end
resetArmed = false
resetBtn.Text = "Reset all"
resetNow()
end)
saveBtn.MouseButton1Click:Connect(saveNow)
API.settingsUI = { openPage = function(name) openPage(name) end,
search = function(value)
suppressSearch = true
navSearch.Text = tostring(value or "")
suppressSearch = false
applySearch(value)
end,
save = saveNow, reset = resetNow, close = closePanel, panel = panel,
isOpen = function() return settingsPanel == overlay end }
pcall(function()
keyConn = UserInputService.InputBegan:Connect(function(input, processed)
if processed then return end
if input.KeyCode == Enum.KeyCode.Escape and settingsPanel == overlay then
closePanel()
end
end)
end)
openPage(initialTab or "General")
end
-- Measure the wrapped text ourselves. TextBox.TextBounds is unreliable while the box is
-- being resized, which made long messages scroll sideways instead of growing downwards.
function measureInputHeight()
-- AbsoluteSize includes UIScale; convert back to the unscaled units used by Size.
local scale = (winScale and winScale.Scale) or 1
if scale <= 0 then scale = 1 end
local width = input.AbsoluteSize.X / scale
if width < 80 then
width = math.max(120, composer.AbsoluteSize.X / scale - 32)
end
local probe = input.Text
if probe == "" then probe = "Ag" end
local ok, bounds = pcall(function()
return game:GetService("TextService"):GetTextSize(probe, 15, Enum.Font.Gotham, Vector2.new(width, 100000))
end)
local textH = (ok and bounds and bounds.Y) or 15
local winH = (window.AbsoluteSize.Y > 0 and window.AbsoluteSize.Y or 600) / scale
local maxH = math.clamp(winH * 0.42, 96, 220)
return math.clamp(math.ceil(textH) + 4, 24, maxH)
end
function updateComposer()
local h = measureInputHeight()
local ah = #attachments > 0 and 70 or 0
attachRow.Visible = ah > 0
input.Position = UDim2.fromOffset(16, 14 + ah)
input.Size = UDim2.new(1, -32, 0, h)
local ch = h + 68 + ah
composer.Size = UDim2.new(1, -32, 0, ch)
messageArea.Size = UDim2.new(1, 0, 1, -(52 + ch + 24))
local canSend = busy or input.Text:match("%S") ~= nil or #attachments > 0
tween(sendBtn, 0.12, { BackgroundTransparency = canSend and 0 or 0.65 })
arrowIcon.Visible = not busy
stopIcon.Visible = busy
end
input:GetPropertyChangedSignal("Text"):Connect(function()
if input.Text:sub(-1) == "\n" and not UserInputService:IsKeyDown(Enum.KeyCode.LeftShift) then
input.Text = input.Text:sub(1, -2)
if not busy then send() end
return
end
updateComposer()
end)
searchBox:GetPropertyChangedSignal("Text"):Connect(refreshSidebar)
-- AgentRouter fingerprints the client. Codex models accept the Codex CLI signature,
-- but Claude models are only accepted from a Claude Code client.
API.CODEX_SPOOF = {
["Originator"] = "codex_cli_rs",
["User-Agent"] = "codex_cli_rs/0.101.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464",
["Version"] = "0.101.0",
}
API.CLAUDE_SPOOF = {
["User-Agent"] = "claude-cli/2.1.119 (external, cli)",
["x-app"] = "cli",
["X-Stainless-Arch"] = "x64",
["X-Stainless-Lang"] = "js",
["X-Stainless-OS"] = "Linux",
["X-Stainless-Package-Version"] = "0.60.0",
["X-Stainless-Runtime"] = "node",
["X-Stainless-Runtime-Version"] = "v22.14.0",
["anthropic-beta"] = "interleaved-thinking-2025-05-14,redact-thinking-2026-02-12,context-management-2025-06-27,prompt-caching-scope-2026-01-05,claude-code-20250219",
["anthropic-dangerous-direct-browser-access"] = "true",
}
-- OpenCode Zen's free tier is gated on a client fingerprint, not on an account:
-- 1) User-Agent whose leading token is opencode/
-- 2) an x-opencode-session shaped ses_<12 lowercase hex><14 alnum>
-- 3) stream:true in the body
-- 4) at least five of OpenCode's own tool names in tools[] (bash/edit/glob/grep/read)
-- The free models accept "Authorization: Bearer public", so no key is required.
API.OPENCODE_UA = "opencode/1.18.31 ai-sdk/provider-utils/4.0.38 runtime/bun/1.3.14"
function newOpencodeSession()
local hex = "0123456789abcdef"
local alnum = "abcdefghijklmnopqrstuvwxyz0123456789"
local a, b = {}, {}
for _ = 1, 12 do local i = math.random(1, #hex) a[#a + 1] = hex:sub(i, i) end
for _ = 1, 14 do local i = math.random(1, #alnum) b[#b + 1] = alnum:sub(i, i) end
return "ses_" .. table.concat(a) .. table.concat(b)
end
API.OPENCODE_TOOLS = {
{ type = "function", ["function"] = { name = "bash", description = "Run a shell command.", parameters = { type = "object", properties = { command = { type = "string" } }, required = { "command" } } } },
{ type = "function", ["function"] = { name = "edit", description = "Edit a file.", parameters = { type = "object", properties = { filePath = { type = "string" }, oldString = { type = "string" }, newString = { type = "string" } }, required = { "filePath" } } } },
{ type = "function", ["function"] = { name = "glob", description = "Find files by pattern.", parameters = { type = "object", properties = { pattern = { type = "string" } }, required = { "pattern" } } } },
{ type = "function", ["function"] = { name = "grep", description = "Search file contents.", parameters = { type = "object", properties = { pattern = { type = "string" } }, required = { "pattern" } } } },
{ type = "function", ["function"] = { name = "read", description = "Read a file.", parameters = { type = "object", properties = { filePath = { type = "string" } }, required = { "filePath" } } } },
}
local function opencodeSpoof(key)
return {
["User-Agent"] = API.OPENCODE_UA,
["Accept"] = "text/event-stream",
["x-opencode-session"] = newOpencodeSession(),
["x-opencode-client"] = "cli",
["Authorization"] = "Bearer " .. ((key ~= nil and key ~= "") and key or "public"),
}
end
-- Walk a Server-Sent-Events body and stitch the streamed deltas back together.
function parseOpencodeStream(raw)
local text, thinking = {}, {}
local calls = {}
local ok0, decoded0 = pcall(function() return HttpService:JSONDecode(raw) end)
if ok0 and type(decoded0) == "table" and decoded0.choices then
return nil
end
for line in tostring(raw):gmatch("[^\r\n]+") do
local data = line:match("^data:%s*(.+)$")
if data and data ~= "[DONE]" then
local ok, chunk = pcall(function() return HttpService:JSONDecode(data) end)
if ok and type(chunk) == "table" then
if type(chunk.error) == "table" and chunk.error.message then
error("OpenCode: " .. tostring(chunk.error.message))
end
local choice = chunk.choices and chunk.choices[1]
local delta = choice and choice.delta
if type(delta) == "table" then
if type(delta.content) == "string" then table.insert(text, delta.content) end
if type(delta.reasoning) == "string" then table.insert(thinking, delta.reasoning) end
if type(delta.reasoning_content) == "string" then table.insert(thinking, delta.reasoning_content) end
if type(delta.tool_calls) == "table" then
for _, tc in ipairs(delta.tool_calls) do
if type(tc) == "table" then
local idx = #calls + 1
if tonumber(tc.index) ~= nil then idx = tonumber(tc.index) + 1 end
calls[idx] = calls[idx] or { id = "", name = "", args = "" }
if tc.id then calls[idx].id = tc.id end
local fn = tc["function"]
if type(fn) == "table" then
if fn.name then calls[idx].name = fn.name end
if type(fn.arguments) == "string" then calls[idx].args = calls[idx].args .. fn.arguments end
end
end
end
end
end
end
end
end
local answer = table.concat(text)
local reason = table.concat(thinking)
local toolCalls = nil
if #calls > 0 then
toolCalls = {}
for _, c in ipairs(calls) do
if c.name ~= "" then
local args = {}
if c.args ~= "" then
local okA, dec = pcall(function() return HttpService:JSONDecode(c.args) end)
if okA and type(dec) == "table" then args = dec end
end
local id = c.id ~= "" and c.id or ("call_" .. tostring(#toolCalls + 1))
table.insert(toolCalls, { id = id, name = c.name, args = args })
end
end
if #toolCalls == 0 then toolCalls = nil end
end
if toolCalls then
return { text = answer, thinking = reason ~= "" and reason or nil, searches = {}, toolCalls = toolCalls }
end
if answer == "" and reason ~= "" then
return { text = reason, thinking = nil, searches = {} }
end
if answer == "" then error("The free model returned an empty stream. Try another free model.") end
return { text = answer, thinking = reason ~= "" and reason or nil, searches = {} }
end
local function requestHttp(url, method, headers, body, spoof)
local fn = (syn and syn.request) or http_request or request
if type(fn) ~= "function" then
error("This executor has no HTTP request function. Use an executor that supports request/http_request.")
end
local merged = {}
for k, v in pairs(spoof or API.CODEX_SPOOF) do merged[k] = v end
for k, v in pairs(headers or {}) do merged[k] = v end
local ok, response, extra = pcall(fn, {
Url = url,
Method = method,
Headers = merged,
Body = body or "",
Timeout = 90,
})
if not ok then error("Executor HTTP failure: " .. tostring(response):sub(1, 350)) end
if type(response) ~= "table" then
local wrapped
pcall(function() wrapped = { StatusCode = response.StatusCode, Body = response.Body } end)
if type(wrapped) == "table" and (wrapped.StatusCode ~= nil or wrapped.Body ~= nil) then
response = wrapped
elseif type(response) == "string" then
response = { StatusCode = 200, Body = response }
else
local detail = type(response) .. ": " .. tostring(response):sub(1, 120)
if extra ~= nil then detail = detail .. " | " .. tostring(extra):sub(1, 160) end
error("Executor returned an unreadable HTTP response (" .. detail .. ")")
end
end
local status = tonumber(response.StatusCode or response.Status or response.status_code or response.status) or 0
local responseBody = response.Body or response.body or response.response or ""
if status == 0 and tostring(responseBody) ~= "" then status = 200 end
if status < 200 or status >= 300 then
local detail = tostring(responseBody):gsub("[%c]", " ")
local okDecode, decoded = pcall(function() return HttpService:JSONDecode(responseBody) end)
if okDecode and type(decoded) == "table" then
local err = decoded.error
if type(err) == "table" then detail = tostring(err.message or err.msg or err.code or responseBody)
elseif err ~= nil then detail = tostring(err)
elseif decoded.msg ~= nil then detail = tostring(decoded.msg)
elseif decoded.message ~= nil then detail = tostring(decoded.message) end
end
if detail == "" then detail = "(empty response body)" end
local freeLimit = detail:lower()
if freeLimit:find("free usage limit", 1, true) or freeLimit:find("freeusagelimit", 1, true)
or freeLimit:find("only be used from within opencode", 1, true) or freeLimit:find("freetiererror", 1, true) then
error("OPENCODE_FREE_TIER:" .. detail:sub(1, 200))
end
if status == 401 then
error("Authentication failed (HTTP 401). Check your API key for the selected provider. Response: " .. detail:sub(1, 350))
end
if status == 402 then
if url:find("openrouter.ai", 1, true) then
error("HTTP 402: OpenRouter says this request is not covered by your credits. Response: " .. detail:sub(1, 250))
end
error("HTTP 402: AgentRouter's premium-model batch quota is empty. This is separate from your account balance/credits. "
.. "Claude Opus 5.5 and GPT-6.x refill daily at 10:00 and 19:00 Beijing time. DeepSeek V4 Flash is unlimited.")
end
if status == 403 then
local dl = detail:lower()
if dl:find("freetier") or dl:find("free tier") or dl:find("within opencode") then
error("OPENCODE_FREE_TIER")
end
if dl:find("unauthorized client") then
error("AgentRouter rejected this client's fingerprint (unauthorized_client_error). Claude models require the Claude Code headers; other models require the Codex headers.")
end
error("Access denied (HTTP 403). " .. detail:sub(1, 200))
end
if status == 400 then
local dl = detail:lower()
if dl:find("content%-blocked") or dl:find("content_blocked") or dl:find("content blocked")
or dl:find("blocked by the provider") or dl:find("content filter") or dl:find("sensitive content") then
error("CONTENT_BLOCKED|" .. detail:sub(1, 200))
end
if dl:find("/v1/responses") or dl:find("responses") then
error("NEED_RESPONSES_API:" .. detail:sub(1, 200))
end
error("Bad request (HTTP 400): " .. detail:sub(1, 250))
end
if status == 404 then
error("Not found (HTTP 404). Endpoint or model ID is invalid. Response: " .. detail:sub(1, 350))
end
if status == 429 then
error("Rate limited (HTTP 429). Wait and retry, or switch model. Response: " .. detail:sub(1, 300))
end
if status >= 500 then
local dl = detail:lower()
if dl:find("无可用渠道", 1, true) or dl:find("no available channel", 1, true)
or dl:find("no channel available", 1, true) then
error("NO_CHANNEL|" .. detail:sub(1, 300))
end
if dl:find("sensitive words") or dl:find("sensitive content") or dl:find("content_blocked") then
error("CONTENT_BLOCKED|" .. detail:sub(1, 200))
end
error("Server error (HTTP " .. tostring(status) .. "). Temporary failure — retry later. Response: " .. detail:sub(1, 300))
end
error("HTTP " .. tostring(status) .. ": " .. detail:sub(1, 500))
end
return responseBody, status
end
API.modelCache = { at = 0, ids = nil, missAt = 0 }
API.blockedModels = {}
API.lastGoodModel = nil
API.agentRouterModels = function(key)
local now = os.time()
if API.modelCache.ids and now - API.modelCache.at < 300 then return API.modelCache.ids end
if not API.modelCache.ids and now - (API.modelCache.missAt or 0) < 60 then return nil end
if key == nil or key == "" then return nil end
local ok, raw = pcall(function()
return requestHttp("https://agentrouter.org/v1/models", "GET", {
["Accept"] = "application/json",
["Authorization"] = "Bearer " .. key,
})
end)
if not ok then API.modelCache.missAt = now return nil end
local okDecode, decoded = pcall(function() return HttpService:JSONDecode(raw) end)
if not okDecode or type(decoded) ~= "table" then API.modelCache.missAt = now return nil end
local ids = {}
for _, item in ipairs(decoded.data or decoded.models or {}) do
local id = type(item) == "table" and (item.id or item.model or item.name) or nil
if type(id) == "string" and id ~= "" then ids[#ids + 1] = id end
end
if #ids == 0 then return nil end
API.modelCache.at, API.modelCache.ids = now, ids
return ids
end
API.preferredModel = function(ids)
if type(ids) ~= "table" or #ids == 0 then return nil end
local have = {}
for _, id in ipairs(ids) do have[id] = true end
for _, id in ipairs({ "gpt-6.1-sol", "gpt-6-astra", "gpt-5.6-sol" }) do
if have[id] then return id end
end
for _, m in ipairs(MODELS) do
if m.format == "openai" and have[m.id] then return m.id end
end
for _, id in ipairs(ids) do
local lower = id:lower()
if not lower:find("embed") and not lower:find("whisper") and not lower:find("tts")
and not lower:find("image") and not lower:find("dall") and not lower:find("moderation") then
return id
end
end
return nil
end
do
local BROWSER_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
function urlEncode(s)
return (tostring(s):gsub("[^%w]", function(c) return string.format("%%%02X", string.byte(c)) end))
end
function urlDecode(s)
return (tostring(s):gsub("%%(%x%x)", function(h) return string.char(tonumber(h, 16)) end))
end
local function decodeEntities(s)
s = s:gsub("<", "<"):gsub(">", ">"):gsub(""", '"'):gsub("'", "'"):gsub("'", "'")
s = s:gsub(" ", " ")
return (s:gsub("&", "&"))
end
local BLOCK_TAGS = { p = true, div = true, li = true, ul = true, ol = true, dl = true, dt = true, dd = true,
h1 = true, h2 = true, h3 = true, h4 = true, h5 = true, h6 = true, br = true, hr = true,
tr = true, td = true, th = true, table = true, thead = true, tbody = true, tfoot = true, caption = true,
section = true, article = true, header = true, footer = true, main = true, nav = true, aside = true,
blockquote = true, pre = true, figure = true, figcaption = true, form = true, fieldset = true,
legend = true, address = true, details = true, summary = true, center = true, colgroup = true }
function stripHtml(s)
local SC = "[Ss][Cc][Rr][Ii][Pp][Tt]"
local ST = "[Ss][Tt][Yy][Ll][Ee]"
s = s:gsub("<" .. SC .. "[^>]*>.-" .. SC .. "[%s>]", " ")
s = s:gsub("<" .. ST .. "[^>]*>.-" .. ST .. "[%s>]", " ")
s = s:gsub("", " ")
s = s:gsub("<[^>]+>", function(tag)
local name = tag:match("^?[%a][%w%d%-]*")
if name then
name = name:lower():gsub("^?", "")
if BLOCK_TAGS[name] then return "\n" end
end
return " "
end)
s = decodeEntities(s)
s = s:gsub("[%c]", function(ch) if ch == "\n" or ch == "\t" then return ch end return " " end)
s = s:gsub("[ \t]+", " ")
s = s:gsub(" *\n *", "\n")
s = s:gsub("\n\n\n+", "\n\n")
return (s:gsub("^%s+", ""):gsub("%s+$", ""))
end
webSearch = function(query)
local ok, body = pcall(requestHttp,
"https://html.duckduckgo.com/html/?q=" .. urlEncode(query), "GET",
{ ["User-Agent"] = BROWSER_UA, ["Accept"] = "text/html,application/xhtml+xml" }, "", {})
if not ok then return nil, tostring(body):sub(1, 200) end
if type(body) ~= "string" or body == "" then return nil, "empty response" end
local results, pos = {}, 1
while #results < 8 do
local s, e, href, title = body:find('class="result__a" href="([^"]+)">([^<]*)', pos)
if not s then break end
href = href:gsub("&", "&")
local u = href:match("uddg=([^&]+)")
u = u and urlDecode(u) or nil
if u and u:match("^https?://") then
local snip = ""
local snipS, _, rawSnip = body:find('class="result__snippet"[^>]*>(.-)', e + 1)
if snipS and rawSnip then snip = decodeEntities((rawSnip:gsub("<[^>]+>", ""))) end
results[#results + 1] = { url = u, title = decodeEntities(title), snippet = snip }
end
pos = e + 1
end
if #results == 0 then return nil, "no results (page blocked or format changed)" end
local lines = {}
for i, r in ipairs(results) do
lines[#lines + 1] = i .. ". " .. r.title
lines[#lines + 1] = " " .. r.url
if r.snippet ~= "" then lines[#lines + 1] = " " .. r.snippet end
end
return table.concat(lines, "\n")
end
webFetch = function(url)
if not url:match("^https?://") then
if url:match("^%a[%w+.-]*://") then return nil, "only http(s) URLs can be read" end
url = "https://" .. url
end
local ok, body = pcall(requestHttp, url, "GET",
{ ["User-Agent"] = BROWSER_UA, ["Accept"] = "text/html,application/xhtml+xml,text/plain,*/*" }, "", {})
if not ok then return nil, tostring(body):sub(1, 200) end
if type(body) ~= "string" then return nil, "unreadable response" end
local out = stripHtml(body)
if #out > 6000 then
out = out:sub(1, 6000)
while #out > 0 and out:byte(-1) >= 128 do out = out:sub(1, -2) end
out = out .. "\n... (truncated)"
end
if out == "" then return nil, "page had no readable text" end
return out
end
end
local function requestOpenAICompatible(url, payload, headers, spoof)
local candidate = table.clone(payload)
local changed = {}
for attempt = 1, 4 do
local ok, result = pcall(function()
return requestHttp(url, "POST", headers, HttpService:JSONEncode(candidate), spoof)
end)
if ok then return result end
local message = tostring(result)
local lower = message:lower()
local unsupported = lower:find("unsupported", 1, true)
or lower:find("not support", 1, true)
or lower:find("unknown parameter", 1, true)
or lower:find("unrecognized parameter", 1, true)
or lower:find("invalid parameter", 1, true)
or lower:find("invalid value", 1, true)
if not unsupported then error(message) end
if lower:find("tool_choice", 1, true) and candidate.tool_choice ~= nil and not changed.tool_choice then
candidate.tool_choice = nil
changed.tool_choice = true
elseif lower:find("reasoning_effort", 1, true) and candidate.reasoning_effort ~= nil and not changed.reasoning_effort then
candidate.reasoning_effort = nil
changed.reasoning_effort = true
elseif lower:find("max_tokens", 1, true) and candidate.max_tokens ~= nil and not changed.max_tokens then
candidate.max_completion_tokens = candidate.max_tokens
candidate.max_tokens = nil
changed.max_tokens = true
elseif lower:find("max_completion_tokens", 1, true) and candidate.max_completion_tokens ~= nil and not changed.max_completion_tokens then
candidate.max_tokens = candidate.max_completion_tokens
candidate.max_completion_tokens = nil
changed.max_completion_tokens = true
else
if lower:find("model", 1, true) and (lower:find("not supported", 1, true) or lower:find("unsupported", 1, true) or lower:find("not found", 1, true)) then
error(message .. "\nCheck the model ID against models available for your key.")
end
error(message)
end
end
error("Request rejected after compatibility retries.")
end
local function apiKeyValue(value)
local key = tostring(value or ""):gsub("^%s+", ""):gsub("%s+$", "")
key = key:gsub('^"(.*)"$', "%1"):gsub("^'(.*)'$", "%1")
key = key:gsub("^[Bb][Ee][Aa][Rr][Ee][Rr]%s+", "")
return key:gsub("^%s+", ""):gsub("%s+$", "")
end
API.CATALOGUES = {
opencode = {
title = "OpenCode Zen models", blurb = "Search the free Zen catalogue",
url = API.OPENCODE_MODELS_URL, provider = "opencode", spoof = {},
accent = Color3.fromRGB(60, 130, 255), brand = "OpenCode Zen",
missing = "Zen free models still answer; a key unlocks the paid ones.",
},
agentrouter = {
title = "AgentRouter models", blurb = "Search the models this key can reach",
url = API.AGENTROUTER_MODELS_URL, provider = "agentrouter", spoof = API.CODEX_SPOOF,
accent = Color3.fromRGB(77, 107, 254), brand = "AgentRouter",
missing = "Add an AgentRouter key in Settings \u{2192} Providers to load this list.",
},
openrouter = {
title = "OpenRouter models", blurb = "Search the live catalogue",
url = API.OPENROUTER_MODELS_URL, provider = "openrouter", spoof = API.OPENROUTER_SPOOF,
accent = Color3.fromRGB(120, 100, 240), brand = "OpenRouter",
missing = "Add an OpenRouter key in Settings \u{2192} Providers to use these.",
},
}
API.CATALOGUE_ORDER = { "opencode", "agentrouter", "openrouter" }
API.catalogueCache, API.catalogueWaiting, API.catalogueLoading = {}, {}, {}
function API.catalogueKey(provider)
if provider == "openrouter" then return apiKeyValue(settings.openrouterKey) end
if provider == "opencode" then
local key = apiKeyValue(settings.opencodeApiKey)
if key == "" then key = apiKeyValue(OPENCODE_KEY) end
return key
end
local key = apiKeyValue(settings.apiKey)
if key == "" then key = apiKeyValue(AGENTROUTER_KEY) end
return key
end
function API.prettifyId(id)
local tail = tostring(id or ""):match("([^/]+)$") or tostring(id or "")
local words = {}
for word in tail:gmatch("[^%-_]+") do
words[#words + 1] = word:sub(1, 1):upper() .. word:sub(2)
end
local text = table.concat(words, " ")
if text == "" then return tostring(id or "") end
return text
end
function API.parseCatalogue(provider)
local spec = API.CATALOGUES[provider]
if not spec then return nil, "Unknown provider." end
local key = API.catalogueKey(provider)
if provider == "agentrouter" and key == "" then return nil, spec.missing end
local headers = { ["Accept"] = "application/json" }
if key ~= "" then headers["Authorization"] = "Bearer " .. key end
local function fetch(sent, spoof)
local ok, result = pcall(requestHttp, spec.url, "GET", sent, "", spoof or {})
if ok then return result end
return nil, tostring(result)
end
local raw, failure = fetch(headers)
if not raw then
local text = tostring(failure)
if text:find("431", 1, true) or text:find("unreadable HTTP", 1, true) then
local slim = {}
if headers["Authorization"] then slim["Authorization"] = headers["Authorization"] end
local slimRaw, slimFailure = fetch(slim)
if slimRaw then
raw = slimRaw
else
failure = tostring(slimFailure)
end
end
end
if not raw and type(spec.spoof) == "table" and next(spec.spoof) then
local spoofRaw, spoofFailure = fetch(headers, spec.spoof)
if spoofRaw then raw = spoofRaw else failure = tostring(spoofFailure) end
end
if not raw then
local text = tostring(failure or "unknown error")
if text:find("431", 1, true) then
text = "the executor's HTTP layer sent headers the server refused (HTTP 431). Restart the executor and try again; if it keeps failing, this executor cannot load the catalogue."
end
return nil, text
end
local okDecode, decoded = pcall(function() return HttpService:JSONDecode(tostring(raw)) end)
if not okDecode or type(decoded) ~= "table" then return nil, spec.title .. " sent a list this could not read." end
local rows = type(decoded.data) == "table" and decoded.data
or (type(decoded.models) == "table" and decoded.models or nil)
if not rows then return nil, spec.title .. " sent a list this could not read." end
local list = {}
for _, item in ipairs(rows) do
local id = ""
if type(item) == "table" then id = tostring(item.id or item.model or item.name or "") end
if id ~= "" and not (provider == "opencode" and id == "test") then
local price = type(item.pricing) == "table" and item.pricing or {}
local arch = type(item.architecture) == "table" and item.architecture or {}
local inputs = type(arch.input_modalities) == "table" and arch.input_modalities or {}
local vision = false
for _, mode in ipairs(inputs) do
if tostring(mode) == "image" then vision = true break end
end
local free
if price.prompt ~= nil or price.completion ~= nil then
free = tostring(price.prompt or "1") == "0" and tostring(price.completion or "1") == "0"
elseif provider == "opencode" then
free = id:find("free", 1, true) ~= nil
end
local name = item.name or item.display_name
if not name and provider == "opencode" then
name = API.prettifyId((id:gsub("%-free$", "")))
end
if vision == false and id:find("%-vl", 1, true) then vision = true end
list[#list + 1] = { id = id, name = tostring(name or API.prettifyId(id)),
ctx = tonumber(item.context_length or item.context) or 0, vision = vision, free = free }
end
end
if #list == 0 then return nil, spec.title .. " returned no models." end
table.sort(list, function(a, b)
if (a.free == true) ~= (b.free == true) then return a.free == true end
return a.name:lower() < b.name:lower()
end)
return list
end
function API.loadCatalogue(provider, callback)
local cached = API.catalogueCache[provider]
if cached then callback(cached) return end
local waiting = API.catalogueWaiting[provider] or {}
API.catalogueWaiting[provider] = waiting
table.insert(waiting, callback)
if API.catalogueLoading[provider] then return end
API.catalogueLoading[provider] = true
task.spawn(function()
local list, failure = API.parseCatalogue(provider)
API.catalogueLoading[provider] = false
if list then API.catalogueCache[provider] = list end
local queued = API.catalogueWaiting[provider] or {}
API.catalogueWaiting[provider] = {}
for _, fn in ipairs(queued) do pcall(fn, list, failure) end
end)
end
function API.openRouterCatalog(callback) API.loadCatalogue("openrouter", callback) end
function API.catalogueDescriptor(provider, entry)
local spec = API.CATALOGUES[provider] or API.CATALOGUES.openrouter
local id = tostring(entry.id or "")
local name = tostring(entry.name or id)
local short = id:match("([^/]+)$") or name
local desc
if provider == "openrouter" then
if entry.free then
desc = "OpenRouter · free"
elseif (tonumber(entry.ctx) or 0) >= 1000 then
desc = "OpenRouter · " .. tostring(math.floor(tonumber(entry.ctx) / 1000)) .. "k context"
else
desc = "OpenRouter · paid"
end
elseif provider == "opencode" then
desc = entry.free and "OpenCode Zen · free" or "OpenCode Zen"
else
desc = entry.free and "AgentRouter · free" or "AgentRouter"
end
local accent, format = spec.accent, "openai"
if provider == "agentrouter" then
if id:find("claude", 1, true) then
accent, format = Color3.fromRGB(205, 120, 80), "anthropic"
elseif id:find("gpt", 1, true) then
accent = Color3.fromRGB(16, 163, 127)
end
end
return { brand = provider, brandName = spec.brand, name = name, short = short, id = id,
format = format, provider = provider, endpoint = "chat", wire = "chat",
mark = string.upper(short:sub(1, 2)), accent = accent, desc = desc, think = "Think",
vision = entry.vision == true }
end
function API.openRouterDescriptor(entry) return API.catalogueDescriptor("openrouter", entry) end
function API.useCatalogueModel(provider, entry)
local spec = API.CATALOGUES[provider] or API.CATALOGUES.openrouter
local index
for i, m in ipairs(MODELS) do
if m.id == entry.id and (m.provider or "agentrouter") == spec.provider then index = i end
end
if not index then
MODELS[#MODELS + 1] = API.catalogueDescriptor(provider, entry)
index = #MODELS
end
modelIndex = index
settings.modelId = tostring(entry.id or "")
settings.customModel = { provider = provider, id = tostring(entry.id or ""),
name = tostring(entry.name or entry.id or ""), ctx = tonumber(entry.ctx) or 0,
vision = entry.vision == true, free = entry.free == true }
saveSettings()
applyModel()
end
function API.useOpenRouterModel(entry) API.useCatalogueModel("openrouter", entry) end
function API.restoreCatalogueModel()
local saved = settings.customModel
if type(saved) ~= "table" or type(saved.id) ~= "string" or saved.id == "" then return end
local provider = API.CATALOGUES[saved.provider] and saved.provider or "openrouter"
local index
for i, m in ipairs(MODELS) do
if m.id == saved.id and (m.provider or "") == provider then index = i end
end
if not index then
MODELS[#MODELS + 1] = API.catalogueDescriptor(provider, saved)
index = #MODELS
end
modelIndex = index
end
function API.restoreOpenRouterModel() API.restoreCatalogueModel() end
API.restoreCatalogueModel()
function closeCataloguePicker()
if openRouterModal then
openRouterModal:Destroy()
openRouterModal = nil
end
end
function closeOpenRouterPicker() closeCataloguePicker() end
function openCataloguePicker(provider)
local spec = API.CATALOGUES[provider] or API.CATALOGUES.openrouter
closeCataloguePicker()
local layer = new("Frame", { Size = UDim2.fromScale(1, 1), BackgroundTransparency = 1, ZIndex = 90 }, window)
openRouterModal = layer
local backdrop = button({ Size = UDim2.fromScale(1, 1), BackgroundColor3 = C.bg,
BackgroundTransparency = 0.45, ZIndex = 90 }, layer)
local box = new("Frame", { AnchorPoint = Vector2.new(0.5, 0.5), Position = UDim2.fromScale(0.5, 0.5),
Size = UDim2.fromOffset(400, 400), BackgroundColor3 = C.panel, ZIndex = 91 }, layer)
round(box, 14)
stroke(box)
text({ Position = UDim2.fromOffset(16, 12), Size = UDim2.new(1, -48, 0, 20), Text = spec.title,
Font = Enum.Font.GothamBold, TextSize = 15, ZIndex = 92 }, box)
local shut = button({ AnchorPoint = Vector2.new(1, 0), Position = UDim2.new(1, -10, 0, 10),
Size = UDim2.fromOffset(24, 24), BackgroundTransparency = 1, ZIndex = 92 }, box)
icon("cross", shut, C.subtext, 13)
shut.MouseButton1Click:Connect(closeCataloguePicker)
local search = new("TextBox", { Position = UDim2.fromOffset(12, 40), Size = UDim2.new(1, -24, 0, 32),
BackgroundColor3 = C.hover, PlaceholderText = "Search by name or id", PlaceholderColor3 = C.subtext,
TextColor3 = C.text, TextSize = 12, Text = "", ClearTextOnFocus = false,
TextXAlignment = Enum.TextXAlignment.Left, ZIndex = 92 }, box)
round(search, 8)
pad(search, 0, 10, 0, 10)
local note = text({ Position = UDim2.fromOffset(14, 78), Size = UDim2.new(1, -28, 0, 16),
Text = "Loading the catalogue\u{2026}", TextSize = 11, TextColor3 = C.subtext, ZIndex = 92 }, box)
local list = new("ScrollingFrame", { Position = UDim2.fromOffset(8, 98), Size = UDim2.new(1, -16, 1, -106),
BackgroundTransparency = 1, BorderSizePixel = 0, ScrollBarThickness = 3,
ScrollBarImageColor3 = C.stroke, CanvasSize = UDim2.new(),
AutomaticCanvasSize = Enum.AutomaticSize.Y, ZIndex = 92 }, box)
new("UIListLayout", { Padding = UDim.new(0, 2) }, list)
local entries = nil
local function render()
for _, child in ipairs(list:GetChildren()) do
if child:IsA("GuiObject") then child:Destroy() end
end
if not entries then return end
local query = tostring(search.Text or ""):lower()
local shown, total = 0, 0
for _, entry in ipairs(entries) do
total = total + 1
local hit = query == "" or entry.name:lower():find(query, 1, true) ~= nil
or entry.id:lower():find(query, 1, true) ~= nil
if hit and shown < 120 then
shown = shown + 1
local item = button({ Size = UDim2.new(1, 0, 0, 46), BackgroundColor3 = C.hover,
BackgroundTransparency = 1, LayoutOrder = shown, ZIndex = 93 }, list)
round(item, 8)
hover(item, C.hover, 0.1)
text({ Position = UDim2.fromOffset(10, 4), Size = UDim2.new(1, -20, 0, 16), Text = entry.name,
TextSize = 12, TextWrapped = false, TextTruncate = Enum.TextTruncate.AtEnd, ZIndex = 94 }, item)
local meta = entry.id
if entry.free then meta = meta .. " · free" end
if entry.vision then meta = meta .. " · vision" end
text({ Position = UDim2.fromOffset(10, 22), Size = UDim2.new(1, -20, 0, 14), Text = meta,
TextSize = 10, TextColor3 = C.subtext, TextWrapped = false,
TextTruncate = Enum.TextTruncate.AtEnd, ZIndex = 94 }, item)
item.MouseButton1Click:Connect(function()
API.useCatalogueModel(provider, entry)
closeCataloguePicker()
end)
end
end
local freeCount = 0
for _, entry in ipairs(entries) do
if entry.free then freeCount = freeCount + 1 end
end
if shown < total then
note.Text = "Showing " .. tostring(shown) .. " of " .. tostring(total) .. " · type to narrow"
elseif freeCount > 0 then
note.Text = tostring(total) .. " models · " .. tostring(freeCount) .. " free"
else
note.Text = tostring(total) .. " models"
end
end
local function warn()
if API.catalogueKey(provider) == "" and provider ~= "opencode" then
note.Text = spec.missing
note.TextColor3 = C.error
end
end
warn()
search:GetPropertyChangedSignal("Text"):Connect(render)
backdrop.MouseButton1Click:Connect(closeCataloguePicker)
API.loadCatalogue(provider, function(found, failure)
if not openRouterModal then return end
if found then
entries = found
note.TextColor3 = C.subtext
render()
warn()
elseif API.catalogueKey(provider) == "" and provider ~= "opencode" then
note.Text = spec.missing
note.TextColor3 = C.error
else
note.Text = "Could not load the catalogue: " .. tostring(failure or "unknown error"):sub(1, 200)
note.TextColor3 = C.error
end
end)
end
function openOpenRouterPicker() openCataloguePicker("openrouter") end
local function arKeyList()
local primary = apiKeyValue(settings.apiKey)
local legacy = apiKeyValue(AGENTROUTER_KEY)
local list = {}
if primary ~= "" then list[1] = primary end
if legacy ~= "" and legacy ~= primary then list[#list + 1] = legacy end
return list
end
local function arKeyValue()
return arKeyList()[1] or ""
end
API.describeError = function(err)
local message = tostring(err)
for _ = 1, 3 do message = message:gsub("^.-:%d+:%s*", "") end
local needsResponses = message:find("NEED_RESPONSES_API", 1, true) ~= nil
or message:find("/v1/responses", 1, true) ~= nil
message = message:gsub("^OPENCODE_FREE_TIER:?%s*", "")
message = message:gsub("^NEED_RESPONSES_API:?%s*", "")
if needsResponses then
return "This model only answers on the Responses API, and the request did not use it. "
.. "Retry, or pick a different model."
end
local blocked = message:match("^CONTENT_BLOCKED|(.*)$")
if blocked then
return "The provider blocked this request as sensitive content. Response: " .. blocked:sub(1, 200)
end
local detail = message:match("^NO_CHANNEL|(.*)$")
if not detail then return message:sub(1, 300) end
local group = detail:match("分组%s*([%w%-_]+)") or detail:match("group%s*([%w%-_]+)") or "default"
if arKeyValue() == "" then return message:sub(1, 300) end
local ids, listing = {}, "none listed for this key"
for _, key in ipairs(arKeyList()) do
local tried = API.agentRouterModels(key)
if tried and #tried > 0 then
ids = tried
break
end
end
local shown = {}
for i = 1, math.min(#ids, 10) do shown[i] = tostring(ids[i]) end
if #shown > 0 then
listing = table.concat(shown, ", ") .. (#ids > 10 and (" (+" .. tostring(#ids - 10) .. " more)") or "")
end
return "AgentRouter has no available channel for this model in your key's group (" .. group .. ").\n"
.. "Models this key can use: " .. listing .. ".\n"
.. "Pick one of those in the model menu, or check the token's group in the AgentRouter dashboard.\n"
.. "If the list is empty, the token may be invalid or its group may have no channels."
end
local function decodeJson(raw, label)
local ok, decoded = pcall(function() return HttpService:JSONDecode(raw) end)
if not ok or type(decoded) ~= "table" then error(label .. " returned invalid JSON.") end
return decoded
end
local function modelProvider(model)
if model and model.provider then return model.provider end
if model and model.brand == "opencode" then return "opencode" end
return settings.provider or "agentrouter"
end
function getActiveKey(forModel)
local prov = modelProvider(forModel or MODELS[modelIndex])
if prov == "openrouter" then
local k = apiKeyValue(settings.openrouterKey)
return k, "OpenRouter"
elseif prov == "opencode" then
local k = apiKeyValue(settings.opencodeApiKey)
if k == "" then k = apiKeyValue(OPENCODE_KEY) end
return k, "OpenCode"
else
local k = apiKeyValue(settings.apiKey)
if k == "" then k = apiKeyValue(AGENTROUTER_KEY) end
return k, "AgentRouter"
end
end
testApiConnection = function(key, callback, forceProvider)
key = apiKeyValue(key)
local testModel = MODELS[modelIndex]
local testOpenCode = forceProvider == "opencode" or modelProvider(testModel) == "opencode"
if key == "" and not testOpenCode then
callback(false, "Paste an API key first")
return
end
if forceProvider == "openrouter" then
task.spawn(function()
local ok, response = pcall(function()
return requestHttp(API.OPENROUTER_KEY_URL, "GET", {
["Content-Type"] = "application/json",
["Accept"] = "application/json",
["Authorization"] = "Bearer " .. key,
}, "", API.OPENROUTER_SPOOF)
end)
if ok then
callback(true, "Connected · key accepted")
else
callback(false, tostring(response):sub(1, 350))
end
end)
return
end
task.spawn(function()
local ok, result = pcall(function()
local model = MODELS[modelIndex]
if forceProvider == "opencode" and model.provider ~= "opencode" then
model = { id = "beam-free", format = "openai", provider = "opencode" }
end
local useOpenCode = forceProvider == "opencode" or modelProvider(model) == "opencode"
local payload = {
model = model.id,
messages = {{ role = "user", content = "Reply only with OK." }},
max_tokens = 8,
}
local headers = {
["Content-Type"] = "application/json",
["Accept"] = "application/json",
}
if model.format == "anthropic" and not useOpenCode then
headers["x-api-key"] = key
headers["Authorization"] = "Bearer " .. key
headers["anthropic-version"] = "2023-06-01"
payload = {
model = model.id,
max_tokens = 8,
messages = {{ role = "user", content = "Reply only with OK." }},
}
requestHttp(API.AGENTROUTER_MESSAGES_URL, "POST", headers, HttpService:JSONEncode(payload), API.CLAUDE_SPOOF)
elseif model.endpoint == "responses" and not useOpenCode then
headers["Authorization"] = "Bearer " .. key
payload = {
model = model.id,
input = "Reply only with OK.",
max_output_tokens = 8,
}
requestHttp(API.AGENTROUTER_RESPONSES_URL, "POST", headers, HttpService:JSONEncode(payload))
elseif useOpenCode then
payload.stream = true
payload.tools = API.OPENCODE_TOOLS
payload.tool_choice = "none"
headers["Accept"] = "text/event-stream"
headers["Authorization"] = nil
local ok1 = pcall(function()
requestOpenAICompatible(API.OPENCODE_URL, payload, headers, opencodeSpoof(key))
end)
if not ok1 then
local ok2 = pcall(function()
requestOpenAICompatible(API.OPENCODE_URL, payload, headers, opencodeSpoof("public"))
end)
if not ok2 then
requestOpenAICompatible(API.OPENCODE_INFERENCE_URL, payload, headers, opencodeSpoof("public"))
end
end
else
headers["Authorization"] = "Bearer " .. key
local ok1, err1 = pcall(function()
requestOpenAICompatible(API.AGENTROUTER_URL, payload, headers)
end)
if not ok1 then error(err1) end
end
return "Connected · model responded"
end)
if ok then
callback(true, result)
else
local message = tostring(result):gsub("^.-:%d+:%s*", "")
callback(false, message:sub(1, 350))
end
end)
end
do
local B64A = {}
for index = 1, 64 do B64A[index - 1] = ("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"):sub(index, index) end
local function hexColour(colour)
local ok, r, g, b = pcall(function()
return math.round(colour.R * 255), math.round(colour.G * 255), math.round(colour.B * 255)
end)
if not ok then return "unknown" end
return string.format("#%02X%02X%02X", r, g, b)
end
local function avatarLines(player)
local lines = {"Player: " .. player.DisplayName .. " (@" .. player.Name .. ", UserId " .. tostring(player.UserId)
.. ", account age " .. tostring(player.AccountAge or 0) .. " days)"}
local char = player.Character
local humanoid = char and char:FindFirstChildOfClass("Humanoid")
if humanoid then
lines[#lines + 1] = "Rig: " .. tostring(humanoid.RigType):gsub("RigType%.", "") .. ", health "
.. tostring(math.floor(humanoid.Health)) .. "/" .. tostring(math.floor(humanoid.MaxHealth))
.. ", walk speed " .. tostring(humanoid.WalkSpeed)
local scales = {}
for _, key in ipairs({ "HeightScale", "WidthScale", "DepthScale", "HeadScale", "BodyTypeScale", "ProportionScale" }) do
local ok, value = pcall(function() return humanoid[key] end)
if ok and type(value) == "number" then
scales[#scales + 1] = key:gsub("Scale", "") .. " " .. string.format("%.2f", value)
end
end
if #scales > 0 then lines[#lines + 1] = "Body scales: " .. table.concat(scales, ", ") end
end
local colours = char and char:FindFirstChild("BodyColors")
if colours then
local parts = {}
for _, key in ipairs({ "HeadColor3", "TorsoColor3", "LeftArmColor3", "RightArmColor3", "LeftLegColor3", "RightLegColor3" }) do
local ok, value = pcall(function() return colours[key] end)
if ok and typeof(value) == "Color3" then
parts[#parts + 1] = key:gsub("Color3", ""):gsub("(%u)", " %1") .. " " .. hexColour(value)
end
end
if #parts > 0 then lines[#lines + 1] = "Body colours:" .. table.concat(parts, ",") end
end
local worn, tools = {}, {}
for _, item in ipairs(char and char:GetDescendants() or {}) do
if item:IsA("Accessory") then
worn[#worn + 1] = tostring(item.AccessoryType) .. ": " .. item.Name
elseif item:IsA("Shirt") then
worn[#worn + 1] = "Shirt " .. tostring(item.ShirtTemplate)
elseif item:IsA("Pants") then
worn[#worn + 1] = "Pants " .. tostring(item.PantsTemplate)
elseif item:IsA("ShirtGraphic") then
worn[#worn + 1] = "T-shirt " .. tostring(item.Graphic)
elseif item:IsA("Tool") then
tools[#tools + 1] = item.Name
end
end
lines[#lines + 1] = #worn > 0 and ("Wearing (" .. tostring(#worn) .. "): " .. table.concat(worn, ", ")) or "Wearing: nothing equipped"
if #tools > 0 then lines[#lines + 1] = "Holding: " .. table.concat(tools, ", ") end
local okDesc, desc = pcall(function() return Players:GetHumanoidDescriptionFromUserId(player.UserId) end)
if okDesc and typeof(desc) == "Instance" then
local ids = {}
for _, key in ipairs({ "HairAccessory", "FaceAccessory", "NeckAccessory", "ShoulderAccessory",
"FrontAccessory", "BackAccessory", "WaistAccessory", "HatAccessory", "Face", "Head" }) do
local ok, value = pcall(function() return desc[key] end)
if ok and type(value) == "number" and value > 0 then ids[#ids + 1] = key .. " " .. tostring(value) end
end
if #ids > 0 then lines[#lines + 1] = "Avatar asset ids: " .. table.concat(ids, ", ") end
end
return lines
end
local function sceneLines()
local lines = {}
local lp = Players.LocalPlayer
lines[#lines + 1] = "Place: '" .. tostring(gameName) .. "' (PlaceId " .. tostring(placeId) .. "), "
.. tostring(#(Players:GetPlayers())) .. " player(s) online."
local char = lp and lp.Character
local humanoid = char and char:FindFirstChildOfClass("Humanoid")
if char then
local okPivot, pivot = pcall(function() return char:GetPivot().Position end)
if okPivot and typeof(pivot) == "Vector3" then
lines[#lines + 1] = "Your character stands at (" .. tostring(math.floor(pivot.X)) .. ", "
.. tostring(math.floor(pivot.Y)) .. ", " .. tostring(math.floor(pivot.Z)) .. ") in "
.. tostring(char.Parent and char.Parent.Name or "workspace") .. "."
end
end
if humanoid then
local states = {}
for _, state in ipairs({ "Running", "Jumping", "Climbing", "Swimming", "Dead" }) do
local ok, value = pcall(function() return humanoid:GetState() == Enum.HumanoidStateType[state] end)
if ok and value then states[#states + 1] = state end
end
if #states > 0 then lines[#lines + 1] = "State: " .. table.concat(states, ", ") .. "." end
end
local others = {}
for _, other in ipairs(Players:GetPlayers()) do
if other ~= lp then
local otherChar = other.Character
local otherHum = otherChar and otherChar:FindFirstChildOfClass("Humanoid")
local detail = other.DisplayName
if humanoid and otherHum and char then
local okDist, dist = pcall(function()
return math.floor((otherChar:GetPivot().Position - char:GetPivot().Position).Magnitude)
end)
if okDist then detail = detail .. " " .. tostring(dist) .. " studs away" end
end
if otherHum then detail = detail .. " (" .. tostring(math.floor(otherHum.Health)) .. " hp)" end
others[#others + 1] = detail
end
end
lines[#lines + 1] = #others > 0 and ("Nearby players: " .. table.concat(others, "; ")) or "No other players in this server."
local camera = workspace.CurrentCamera
if camera then
local okCam, camPos = pcall(function() return camera.CFrame.Position end)
if okCam then
lines[#lines + 1] = "Camera sits at (" .. tostring(math.floor(camPos.X)) .. ", "
.. tostring(math.floor(camPos.Y)) .. ", " .. tostring(math.floor(camPos.Z)) .. ")."
end
end
local okClock, clock = pcall(function() return Lighting.ClockTime end)
if okClock then lines[#lines + 1] = "Time of day: " .. string.format("%.1f", clock) .. "h." end
local guis = {}
local okGui, playerGui = pcall(function() return lp and lp:FindFirstChildOfClass("PlayerGui") end)
if okGui and playerGui then
for _, guiItem in ipairs(playerGui:GetChildren()) do
if guiItem:IsA("ScreenGui") and guiItem.Enabled then guis[#guis + 1] = guiItem.Name end
end
end
if #guis > 0 then lines[#lines + 1] = "On-screen interfaces: " .. table.concat(guis, ", ") end
return lines
end
local briefText, briefAt, briefTools = nil, 0, nil
function API.contextBrief(withTools)
if briefText and briefTools == withTools and os.clock() - briefAt < 20 then return briefText end
local ok, text = pcall(function()
local lp = Players.LocalPlayer
if not lp then return nil end
local bits = { "The user is " .. lp.DisplayName .. " (@" .. lp.Name .. ")." }
local char = lp.Character
local humanoid = char and char:FindFirstChildOfClass("Humanoid")
if humanoid then
local items = 0
for _, item in ipairs(char:GetDescendants()) do
if item:IsA("Accessory") or item:IsA("Shirt") or item:IsA("Pants") or item:IsA("ShirtGraphic") then
items = items + 1
end
end
bits[#bits + 1] = "They play as a " .. tostring(humanoid.RigType):gsub("RigType%.R", "R") .. " character wearing "
.. tostring(items) .. " avatar items."
end
bits[#bits + 1] = tostring(#(Players:GetPlayers())) .. " player(s) are online with them."
if withTools then
bits[#bits + 1] = "You can look at their avatar or the scene around them with inspect_player."
end
return table.concat(bits, " ")
end)
briefText, briefAt, briefTools = ok and text or nil, os.clock(), withTools
return briefText or ""
end
function API.playerReport(view)
local ok, text = pcall(function()
if view == "scene" then return table.concat(sceneLines(), "\n") end
local lp = Players.LocalPlayer
if not lp then return "No local player is available here, so there is no avatar to read." end
return table.concat(avatarLines(lp), "\n")
end)
if not ok then return "Could not read the character: " .. tostring(text) end
return text
end
function API.avatarImage()
local lp = Players.LocalPlayer
if not lp then return nil, "No local player is available here." end
local url = "https://thumbnails.roblox.com/v1/users/avatar?userIds=" .. tostring(lp.UserId)
.. "&size=420x420&format=Png&isCircular=false"
local okJson, body = pcall(requestHttp, url, "GET", { ["Accept"] = "application/json" }, "", {})
if not okJson then return nil, tostring(body) end
local okDecode, data = pcall(function() return HttpService:JSONDecode(tostring(body)) end)
local imageUrl
if okDecode and type(data) == "table" and type(data.data) == "table" then
for _, item in ipairs(data.data) do
if type(item) == "table" and type(item.imageUrl) == "string" then imageUrl = item.imageUrl end
end
end
if not imageUrl then return nil, "Roblox returned no avatar image for this account." end
local okPng, png = pcall(requestHttp, imageUrl, "GET", {}, "", {})
if not okPng then return nil, tostring(png) end
if type(png) ~= "string" or png:sub(2, 4) ~= "PNG" then return nil, "The avatar image could not be downloaded." end
local out, n = {}, 0
for index = 1, #png, 3 do
local b1 = png:byte(index) or 0
local b2 = png:byte(index + 1) or 0
local b3 = png:byte(index + 2) or 0
local value = b1 * 65536 + b2 * 256 + b3
n += 1
out[n] = B64A[bit32.rshift(value, 18)] .. B64A[bit32.band(bit32.rshift(value, 12), 63)]
.. (png:byte(index + 1) and B64A[bit32.band(bit32.rshift(value, 6), 63)] or "=")
.. (png:byte(index + 2) and B64A[bit32.band(value, 63)] or "=")
end
return { base64 = table.concat(out), mediaType = "image/png" }
end
end
local function getAIReply(history, model, opts, toolResults)
local useOpenCode = modelProvider(model) == "opencode"
local useOpenRouter = modelProvider(model) == "openrouter"
local apiKey, providerName = getActiveKey(model)
if apiKey == "" then
if useOpenCode then
apiKey = "public"
elseif useOpenRouter then
error("OpenRouter needs an API key. Open Settings → Providers, paste it in the OpenRouter key field, press Save changes, then Retry.")
else
error("Open Settings, paste your " .. providerName .. " API key, and click Save changes.")
end
end
-- One canonical tool list, translated into each provider's own envelope below.
local toolSchemas = (opts ~= nil and type(opts.tools) == "table") and opts.tools or nil
local hasTools = toolSchemas ~= nil and #toolSchemas > 0
local hasWeb = false
if hasTools then
for _, t in ipairs(toolSchemas) do
if t.name == "web_search" then hasWeb = true break end
end
end
local function schemaOf(t)
if type(t.schema) == "table" then return t.schema end
local props, names = {}, {}
for name, desc in pairs(t.parameters or {}) do
props[name] = { type = "string", description = tostring(desc) }
names[#names + 1] = name
end
if next(props) == nil then return { type = "object", properties = { note = { type = "string" } } } end
return { type = "object", properties = props, required = names }
end
local systemPrompt = (opts and opts.system) or "You are a general-purpose assistant in a Roblox chat interface. Respond directly to the user's actual request in clear English. Give accurate, useful answers. For code requests, return complete usable code when appropriate. Do not claim to have inspected the live game unless provided with evidence."
if hasTools then
systemPrompt = systemPrompt .. " When a tool is available and genuinely useful, call it instead of guessing, but never fabricate tool output."
end
if hasWeb then
systemPrompt = systemPrompt .. " You have live web access: use web_search before answering questions about current events, versions, prices, documentation, or anything you are unsure of, and web_fetch to read a page in full. When your answer relies on the web, cite each source inline as a markdown link: [title](https://example.com/page)."
end
systemPrompt = systemPrompt .. "\n\nContext: You are currently in the Roblox game '" .. gameName .. "' (PlaceId: " .. tostring(placeId) .. ")."
local okBrief, brief = pcall(API.contextBrief, hasTools)
if okBrief and type(brief) == "string" and brief ~= "" then
systemPrompt = systemPrompt .. "\n" .. brief
end
if useOpenCode and not hasTools then
systemPrompt = systemPrompt .. "\n\nAlways answer with plain text. Never call any tool or function."
end
if settings.personality ~= "" then systemPrompt ..= "\n\nUser personality instructions: " .. settings.personality end
if currentProject and currentProject.context and currentProject.context ~= "" then
systemPrompt ..= "\n\nProject context: " .. currentProject.context
end
local messages = {}
for _, item in ipairs(history) do
if (item.role == "user" or item.role == "assistant") and not item.isError
and not (item.role == "assistant" and (item.text or "") == "") then
local content = item.text or ""
if item.role == "user" and item.images and #item.images > 0 then
local parts = {}
if content ~= "" then table.insert(parts, { type = "text", text = content }) end
for _, image in ipairs(item.images) do
if image.base64 then
table.insert(parts, { type = "image_url", image_url = { url = "data:" .. (image.mediaType or "image/png") .. ";base64," .. image.base64 } })
end
end
content = parts
end
table.insert(messages, { role = item.role, content = content })
end
end
-- Append the assistant's tool-call turns and their results in order.
if toolResults then
for _, item in ipairs(toolResults) do
if type(item) == "table" and item.role then table.insert(messages, item) end
end
end
local selectedModel = model.id
local last = history[#history]
if last and last.images and #last.images > 0 and not model.vision and model.visionId then
selectedModel = model.visionId
end
if not model.vision and model.visionId and toolResults then
for _, item in ipairs(toolResults) do
if item.role == "user" and type(item.content) == "table" then
selectedModel = model.visionId
end
end
end
local raw
local opencodeStream = useOpenCode
local function parseToolCalls(message)
if type(message.tool_calls) ~= "table" or #message.tool_calls == 0 then return nil end
local list = {}
for _, tc in ipairs(message.tool_calls) do
if type(tc) == "table" then
local fn = type(tc["function"]) == "table" and tc["function"] or {}
local args = {}
if type(fn.arguments) == "string" and fn.arguments ~= "" then
local ok, decoded = pcall(function() return HttpService:JSONDecode(fn.arguments) end)
if ok and type(decoded) == "table" then args = decoded end
elseif type(fn.arguments) == "table" then
args = fn.arguments
end
local id = tostring(tc.id or "")
if id == "" then id = "call_" .. tostring(#list + 1) end
table.insert(list, { id = id, name = tostring(fn.name or ""), args = args })
end
end
if #list == 0 then return nil end
return list
end
local function parseChatAnswer(decoded)
local choice = decoded.choices and decoded.choices[1]
local message = choice and choice.message
if not message then
error(decoded.error and (decoded.error.message or decoded.error) or "No assistant message.")
end
local toolCalls = parseToolCalls(message)
local answer = message.content
if type(answer) == "table" then
local parts = {}
for _, part in ipairs(answer) do if type(part) == "table" and part.text then table.insert(parts, part.text) end end
answer = table.concat(parts, "\n")
end
-- Reasoning models can spend every token on reasoning and return empty content.
if (answer == nil or tostring(answer) == "") and type(message.reasoning_content) == "string" then
answer = message.reasoning_content
elseif (answer == nil or tostring(answer) == "") and type(message.reasoning) == "string" then
answer = message.reasoning
end
if answer == nil or tostring(answer) == "" then
if toolCalls then
return { text = "", thinking = nil, searches = {}, toolCalls = toolCalls }
end
if choice and tostring(choice.finish_reason or "") == "content_filter" then
error("The provider filtered this response. Rephrase the request or switch models.")
end
error("Empty assistant message. The model returned no text (it may have hit a token limit or been filtered). Try again or switch models.")
end
local thinking = nil
if type(message.reasoning_content) == "string" and message.reasoning_content ~= tostring(answer) then
thinking = message.reasoning_content
elseif opts.think then
thinking = "Reasoning mode was requested for this response."
end
return { text = tostring(answer), thinking = thinking, searches = {}, toolCalls = toolCalls }
end
local function parseResponsesAnswer(decoded)
local calls = {}
for _, item in ipairs(decoded.output or {}) do
if type(item) == "table" and item.type == "function_call" and item.name then
local args = {}
if type(item.arguments) == "string" and item.arguments ~= "" then
local okArgs, decArgs = pcall(function() return HttpService:JSONDecode(item.arguments) end)
if okArgs and type(decArgs) == "table" then args = decArgs end
end
table.insert(calls, { id = tostring(item.call_id or item.id or ("call_" .. tostring(#calls + 1))),
name = tostring(item.name), args = args })
end
end
if type(decoded.output_text) == "string" and decoded.output_text ~= "" then
return { text = decoded.output_text, thinking = opts.think and "Reasoning mode was requested for this response." or nil,
searches = {}, toolCalls = #calls > 0 and calls or nil }
end
local texts, thinking = {}, nil
for _, item in ipairs(decoded.output or {}) do
if type(item) == "table" then
if item.type == "message" then
for _, part in ipairs(item.content or {}) do
if type(part) == "table" and (part.type == "output_text" or part.type == "text") and part.text then
table.insert(texts, part.text)
end
end
elseif item.type == "reasoning" then
local bits = {}
for _, s in ipairs(item.summary or {}) do
if type(s) == "table" and s.text then table.insert(bits, s.text)
elseif type(s) == "string" then table.insert(bits, s) end
end
if #bits > 0 then thinking = table.concat(bits, "\n") end
end
end
end
if #texts == 0 and #calls == 0 then
error(decoded.error and (decoded.error.message or decoded.error) or "No response text.")
end
return { text = table.concat(texts, "\n"), thinking = thinking or (opts.think and "Reasoning mode was requested for this response." or nil),
searches = {}, toolCalls = #calls > 0 and calls or nil }
end
-- AgentRouter releases Claude/GPT capacity in shared daily batches. When a batch is
-- empty the gateway returns 402 even if the account still has credit. DeepSeek has
-- unlimited capacity, so keep the conversation usable instead of hard-failing.
local function isQuotaError(err)
local s = tostring(err):lower()
return s:find("http 402", 1, true) ~= nil
or s:find("budget pool", 1, true) ~= nil
or s:find("quota", 1, true) ~= nil
end
-- Keep text turns, drop image parts: the unlimited fallback model is text-only.
local function textOnly(item)
if item.role == "tool" or item.tool_calls then return nil end
if type(item.content) == "table" then
local bits = {}
for _, part in ipairs(item.content) do
if type(part) == "table" and part.type == "text" and part.text then
table.insert(bits, part.text)
end
end
if #bits == 0 then return nil end
return { role = item.role, content = table.concat(bits, "\n") }
end
return item
end
local function noChannelError(err)
return tostring(err):find("NO_CHANNEL", 1, true) ~= nil
end
local arKeys = arKeyList
local arKey = arKeyValue
local function isAvoided(avoid, id)
if not avoid then return false end
if type(avoid) == "string" then return id == avoid end
if not avoid[id] then return false end
return true
end
-- One ordered list feeds every automatic fallback: DeepSeek (the unlimited catch-all),
-- then the models this key can actually reach. Models that already refused this
-- conversation as sensitive content are skipped until the app restarts.
local function fallbackModelList(avoid, firstId)
local ordered = {}
local function add(id)
id = tostring(id or "")
if id == "" or table.find(ordered, id) then return end
ordered[#ordered + 1] = id
end
add(firstId)
if API.lastGoodModel then add(API.lastGoodModel) end
local okLive, liveIds = pcall(function() return API.agentRouterModels(arKey()) end)
if okLive and type(liveIds) == "table" then
local preferred = API.preferredModel(liveIds)
if preferred then add(preferred) end
for _, id in ipairs(liveIds) do
if #ordered >= 5 then break end
add(id)
end
end
add("gpt-6.1-sol")
local kept = {}
for _, id in ipairs(ordered) do
if not isAvoided(avoid, id) and not API.blockedModels[id] then kept[#kept + 1] = id end
end
if #kept == 0 then kept = ordered end
return kept
end
local function responsesInput()
local inputMsgs = {}
for _, item in ipairs(messages) do
if item.role == "tool" then
table.insert(inputMsgs, { type = "function_call_output", call_id = tostring(item.tool_call_id or ""),
output = tostring(item.content or "") })
elseif item.tool_calls then
if item.content ~= nil and tostring(item.content) ~= "" then
table.insert(inputMsgs, { role = "assistant", content = tostring(item.content) })
end
for _, tc in ipairs(item.tool_calls) do
local fn = tc["function"] or {}
table.insert(inputMsgs, { type = "function_call", call_id = tostring(tc.id or ""),
name = tostring(fn.name or ""), arguments = tostring(fn.arguments or "{}") })
end
else
table.insert(inputMsgs, item)
end
end
return inputMsgs
end
local function shortReason(text)
local s = tostring(text)
if s:find("NEED_RESPONSES_API", 1, true) or s:find("/v1/responses", 1, true) then
return "needs the Responses API"
end
if s:find("CONTENT_BLOCKED", 1, true) then return "content filter" end
if s:find("NO_CHANNEL", 1, true) or s:find("无可用渠道", 1, true) then
return "no channel in this key's group"
end
if s:find("HTTP 401", 1, true) or s:find("Authentication failed", 1, true) then return "key rejected" end
if s:find("HTTP 402", 1, true) then return "premium quota empty" end
if s:find("HTTP 429", 1, true) or s:find("Rate limited", 1, true) then return "rate limited" end
if s:find("HTTP 404", 1, true) then return "model id not served" end
return "failed"
end
local function arChatFallback(avoid, firstId)
local candidates = fallbackModelList(avoid, firstId)
local notes = {}
for _, key in ipairs(arKeys()) do
local arHeaders = {
["Content-Type"] = "application/json",
["Accept"] = "application/json",
["Authorization"] = "Bearer " .. key,
}
for _, fbId in ipairs(candidates) do
local chatPayload = {
model = fbId,
messages = {{ role = "system", content = systemPrompt }},
stream = false,
max_tokens = 4096,
}
for _, item in ipairs(messages) do
local t = textOnly(item)
if t then table.insert(chatPayload.messages, t) end
end
local okAr, outAr = pcall(function()
return requestOpenAICompatible(API.AGENTROUTER_URL, chatPayload, arHeaders)
end)
if okAr then
API.lastGoodModel = fbId
return outAr, fbId, nil, "chat"
end
local text = tostring(outAr)
if text:find("CONTENT_BLOCKED", 1, true) then API.blockedModels[fbId] = true end
if text:find("NEED_RESPONSES_API", 1, true) or text:find("/v1/responses", 1, true) then
local respPayload = {
model = fbId,
instructions = systemPrompt,
input = responsesInput(),
stream = false,
max_output_tokens = 4096,
}
local okResp, outResp = pcall(function()
return requestHttp(API.AGENTROUTER_RESPONSES_URL, "POST", arHeaders,
HttpService:JSONEncode(respPayload))
end)
if okResp then
API.lastGoodModel = fbId
return outResp, fbId, nil, "responses"
end
text = tostring(outResp)
end
notes[#notes + 1] = fbId .. ": " .. shortReason(text)
if text:find("HTTP 401", 1, true) or text:find("Authentication failed", 1, true) then break end
end
end
return nil, nil, table.concat(notes, " | "), nil
end
local function quotaFallback(err)
local quota = isQuotaError(err)
local noChannel = noChannelError(err)
if not quota and not noChannel then return nil end
if #arKeys() == 0 then return nil end
local firstId = nil
if noChannel then
local okLive, live = pcall(function() return API.preferredModel(API.agentRouterModels(arKey())) end)
if okLive then firstId = live end
end
local raw2, fbId, _, wire = arChatFallback(selectedModel, firstId)
if not fbId then return nil end
local okDecode, decoded2 = pcall(function() return decodeJson(raw2, "Fallback response") end)
if not okDecode then return nil end
local okParse, res2 = pcall(wire == "responses" and parseResponsesAnswer or parseChatAnswer, decoded2)
if not okParse then return nil end
res2.fellBack = fbId
res2.fellBackReason = noChannel and "nochannel" or "quota"
return res2
end
-- DeepSeek filters the whole conversation, not just the last message, so a chat with a
-- Roblox script in context is rejected before the prompt is even read. Rotate to another
-- model instead of telling the user to switch by hand.
local blockedNotes = nil
local function blockedFallback(err)
if not tostring(err):find("CONTENT_BLOCKED", 1, true) then return nil end
if #arKeys() == 0 then return nil end
API.blockedModels[selectedModel] = true
local rawB, idB, notes, wireB = arChatFallback(selectedModel)
blockedNotes = notes
if not idB then return nil end
local okDecode, decoded = pcall(function() return decodeJson(rawB, "Fallback response") end)
if not okDecode then return nil end
local okParse, res = pcall(wireB == "responses" and parseResponsesAnswer or parseChatAnswer, decoded)
if not okParse then return nil end
res.fellBack = idB
res.fellBackReason = "blocked"
return res
end
local function blockedMessage(extra)
return "AgentRouter refused this turn as sensitive content on every model it could try. "
.. "Pick another model in the menu, or remove the code from context and ask again."
.. (extra and ("\n" .. extra) or "")
.. (blockedNotes and ("\nTried: " .. blockedNotes) or "")
end
-- A model that already refused this conversation will refuse it again, so route straight
-- to the one that answered instead of paying for the same rejection every turn.
if not useOpenCode and API.blockedModels[selectedModel] and #arKeys() > 0 then
local recovered = blockedFallback("CONTENT_BLOCKED|already refused")
if recovered then
if opts then
opts._fellBack = recovered.fellBack
opts._fellBackReason = "blocked"
end
return recovered
end
end
if model.format == "anthropic" and not useOpenCode then
local anthropicMessages, pending = {}, nil
local function flushResults()
if pending then
table.insert(anthropicMessages, { role = "user", content = pending })
pending = nil
end
end
for _, item in ipairs(messages) do
if item.role == "tool" then
pending = pending or {}
table.insert(pending, { type = "tool_result", tool_use_id = tostring(item.tool_call_id or ""),
content = tostring(item.content or "") })
else
flushResults()
if item.tool_calls then
local blocks = {}
if item.content ~= nil and tostring(item.content) ~= "" then
table.insert(blocks, { type = "text", text = tostring(item.content) })
end
for _, tc in ipairs(item.tool_calls) do
local fn = tc["function"] or {}
local input = {}
if type(fn.arguments) == "string" and fn.arguments ~= "" then
local okArgs, decArgs = pcall(function() return HttpService:JSONDecode(fn.arguments) end)
if okArgs and type(decArgs) == "table" then input = decArgs end
end
table.insert(blocks, { type = "tool_use", id = tostring(tc.id or ""),
name = tostring(fn.name or ""), input = input })
end
table.insert(anthropicMessages, { role = "assistant", content = blocks })
else
local content = item.content
if type(content) == "table" then
local converted = {}
for _, part in ipairs(content) do
if type(part) == "table" and part.type == "text" then
table.insert(converted, { type = "text", text = part.text })
elseif type(part) == "table" and part.type == "image_url" and part.image_url and type(part.image_url.url) == "string" then
local mediaType, encoded = part.image_url.url:match("^data:([^;]+);base64,(.+)$")
if mediaType and encoded then table.insert(converted, { type = "image", source = { type = "base64", media_type = mediaType, data = encoded } }) end
end
end
content = converted
end
table.insert(anthropicMessages, { role = item.role, content = content })
end
end
end
flushResults()
local payload = {
model = selectedModel,
system = systemPrompt,
messages = anthropicMessages,
max_tokens = 4096,
}
if hasTools then
local defs = {}
for _, t in ipairs(toolSchemas) do
defs[#defs + 1] = { name = t.name, description = t.description, input_schema = schemaOf(t) }
end
payload.tools = defs
end
local okA, resA = pcall(function()
return requestHttp(API.AGENTROUTER_MESSAGES_URL, "POST", {
["Content-Type"] = "application/json",
["Accept"] = "application/json",
["x-api-key"] = apiKey,
["Authorization"] = "Bearer " .. apiKey,
["anthropic-version"] = "2023-06-01",
}, HttpService:JSONEncode(payload), API.CLAUDE_SPOOF)
end)
if not okA and isQuotaError(resA) then
local fb = quotaFallback(resA)
if fb then return fb end
end
if not okA then
local fbBlocked = blockedFallback(resA)
if fbBlocked then return fbBlocked end
end
if not okA then
local okB, resB = pcall(function()
return requestHttp(API.AGENTROUTER_MESSAGES_URL, "POST", {
["Content-Type"] = "application/json",
["Accept"] = "application/json",
["Authorization"] = "Bearer " .. apiKey,
["anthropic-version"] = "2023-06-01",
}, HttpService:JSONEncode(payload), API.CLAUDE_SPOOF)
end)
if not okB then
local fb = quotaFallback(resA) or quotaFallback(resB)
if fb then return fb end
-- Claude is also exposed over the OpenAI-compatible chat route on AgentRouter,
-- so try that wire before switching models.
local okChatClaude, resChatClaude = pcall(function()
local chatPayload = {
model = selectedModel,
messages = {{ role = "system", content = systemPrompt }},
stream = false,
max_tokens = 4096,
}
for _, item in ipairs(messages) do
local t = textOnly(item)
if t then table.insert(chatPayload.messages, t) end
end
if hasTools then
chatPayload.tools = {}
for _, t in ipairs(toolSchemas) do
chatPayload.tools[#chatPayload.tools + 1] = { type = "function", ["function"] = {
name = t.name, description = t.description, parameters = schemaOf(t) } }
end
chatPayload.tool_choice = "auto"
end
return requestOpenAICompatible(API.AGENTROUTER_URL, chatPayload, {
["Content-Type"] = "application/json",
["Accept"] = "application/json",
["Authorization"] = "Bearer " .. apiKey,
})
end)
if okChatClaude then
local okChatDecode, chatDecoded = pcall(function() return decodeJson(resChatClaude, "Chat response") end)
if okChatDecode then
local okChatParse, chatResult = pcall(parseChatAnswer, chatDecoded)
if okChatParse then return chatResult end
end
end
error(API.describeError(resA))
end
raw = resB
else
raw = resA
end
local decoded = decodeJson(raw, "Anthropic response")
local parts = decoded.content or {}
local answer, calls = {}, {}
for _, part in ipairs(parts) do
if type(part) == "table" then
if part.type == "text" and part.text then
table.insert(answer, part.text)
elseif part.type == "tool_use" and part.name then
table.insert(calls, { id = tostring(part.id or ("call_" .. tostring(#calls + 1))),
name = tostring(part.name), args = type(part.input) == "table" and part.input or {} })
end
end
end
if #answer == 0 and #calls == 0 then error(decoded.error and (decoded.error.message or decoded.error) or "No message content.") end
return { text = table.concat(answer, "\n"), thinking = opts.think and "Reasoning mode was requested for this response." or nil,
searches = {}, toolCalls = #calls > 0 and calls or nil }
end
local useResponses = (model.endpoint == "responses") and not useOpenCode and not useOpenRouter
local chatUrl = useOpenRouter and API.OPENROUTER_URL or API.AGENTROUTER_URL
local chatSpoof = useOpenRouter and API.OPENROUTER_SPOOF or nil
local headers = {
["Content-Type"] = "application/json",
["Accept"] = "application/json",
["Authorization"] = "Bearer " .. apiKey,
}
local function responsesTools(into)
if not hasTools then return end
local defs = {}
for _, t in ipairs(toolSchemas) do
defs[#defs + 1] = { type = "function", name = t.name, description = t.description,
parameters = schemaOf(t) }
end
into.tools = defs
into.tool_choice = "auto"
end
local payload
if useResponses then
payload = {
model = selectedModel,
instructions = systemPrompt,
input = responsesInput(),
stream = false,
max_output_tokens = 4096,
}
responsesTools(payload)
if opts.think then
payload.reasoning = { effort = "high", summary = "auto" }
end
else
payload = {
model = selectedModel,
messages = {{ role = "system", content = systemPrompt }},
stream = false,
max_tokens = 4096,
}
for _, item in ipairs(messages) do table.insert(payload.messages, item) end
if opts.think then payload.reasoning_effort = "high" end
local toolDefs = {}
if useOpenCode then
payload.stream = true
payload.reasoning_effort = nil
for _, t in ipairs(API.OPENCODE_TOOLS) do toolDefs[#toolDefs + 1] = t end
end
if hasTools then
for _, t in ipairs(toolSchemas) do
toolDefs[#toolDefs + 1] = { type = "function", ["function"] = {
name = t.name, description = t.description, parameters = schemaOf(t),
} }
end
end
if #toolDefs > 0 then
payload.tools = toolDefs
payload.tool_choice = hasTools and "auto" or "none"
end
end
local function isFreeTier(err)
local s = tostring(err):lower()
return s:find("opencode_free_tier", 1, true) ~= nil
or s:find("freetier", 1, true) ~= nil
or s:find("within opencode", 1, true) ~= nil
or s:find("free usage limit", 1, true) ~= nil
or s:find("freeusagelimit", 1, true) ~= nil
end
if useOpenCode then
headers["Accept"] = "text/event-stream"
headers["Authorization"] = nil -- the fingerprint spoof supplies the bearer
local function opencodeAttempt(bearer)
local ok1, err1 = pcall(function()
raw = requestOpenAICompatible(API.OPENCODE_URL, payload, headers, opencodeSpoof(bearer))
end)
if ok1 then return true end
if isFreeTier(err1) then return false, err1 end
local ok2, err2 = pcall(function()
raw = requestOpenAICompatible(API.OPENCODE_INFERENCE_URL, payload, headers, opencodeSpoof(bearer))
end)
if ok2 then return true end
return false, err1, err2
end
local okA, errA1, errA2
local stored = (apiKey ~= "" and apiKey ~= "public") and apiKey or ""
-- Zen refuses its free models to external clients once the shared free pool is empty.
-- Remember that for a while so every message does not wait on the same failure.
if os.time() >= opencodeBlockedUntil then
if stored ~= "" then okA, errA1, errA2 = opencodeAttempt(stored) end
if not okA and not isFreeTier(errA1) then okA, errA1, errA2 = opencodeAttempt("public") end
end
if not okA and not isFreeTier(errA1) and os.time() >= opencodeBlockedUntil then
-- A single saturated upstream worker does not mean the whole free pool is down,
-- so walk the other free models before handing the turn to AgentRouter.
for _, altId in ipairs({ "beam-free", "glm-5.3-flash-free", "mimo-v2.6-flash-free",
"flint-40b-free", "nemotron-3.5-lightning-free", "big-pickle" }) do
if altId ~= selectedModel then
local altPayload = table.clone(payload)
altPayload.model = altId
local okAlt, errAlt = pcall(function()
raw = requestOpenAICompatible(API.OPENCODE_URL, altPayload, headers,
opencodeSpoof(stored ~= "" and stored or "public"))
end)
if okAlt then
if opts then
opts._fellBack = altId
opts._fellBackReason = "zenbusy"
end
selectedModel = altId
okA = true
break
end
if isFreeTier(errAlt) then break end
end
end
end
if not okA then
-- Any Zen failure is worth a fallback, not only the free-tier refusal: a 429, a
-- retired endpoint or a bad stored Zen key all mean the same thing to the user.
local freeTier = isFreeTier(errA1) or isFreeTier(errA2)
local cooled = os.time() < opencodeBlockedUntil
if freeTier then opencodeBlockedUntil = os.time() + 180 end
if #arKeys() == 0 then
if freeTier or cooled then
error("OpenCode Zen's free pool is not serving requests right now, and no AgentRouter API key is set.\n"
.. "Open Settings → Providers, paste a key in the AgentRouter field, press Save changes, then Retry. "
.. "Free models then fall back to AgentRouter on their own.")
end
error(API.describeError(tostring(errA1 or errA2 or "OpenCode request failed.")))
end
local rawFb, idFb, notes, wireFb = arChatFallback(selectedModel)
if not idFb then
error(API.describeError(tostring(errA1 or errA2 or "OpenCode request failed."))
.. "\nAgentRouter fallback also failed."
.. (notes and (" Tried: " .. notes) or "")
.. "\nOpen Settings → Providers and pick another model, or retry in a minute.")
end
raw = rawFb
if opts then
opts._fellBack = idFb
opts._fellBackReason = "opencode"
end
opencodeStream = false
useResponses = wireFb == "responses"
end
elseif useResponses then
local okR, errR = pcall(function()
raw = requestHttp(API.AGENTROUTER_RESPONSES_URL, "POST", headers, HttpService:JSONEncode(payload))
end)
if not okR then
local errS = tostring(errR)
local fbQuota = quotaFallback(errR)
if fbQuota then return fbQuota end
if errS:find("CONTENT_BLOCKED", 1, true) then
local fbBlocked = blockedFallback(errS)
if fbBlocked then return fbBlocked end
error(blockedMessage())
end
local okC, errC = pcall(function()
local chatPayload = {
model = selectedModel,
messages = {{ role = "system", content = systemPrompt }},
stream = false,
max_tokens = 4096,
}
for _, item in ipairs(messages) do
local t = textOnly(item)
if t then table.insert(chatPayload.messages, t) end
end
if opts.think then chatPayload.reasoning_effort = "high" end
raw = requestOpenAICompatible(API.AGENTROUTER_URL, chatPayload, headers)
useResponses = false
end)
if not okC then
local fbQuota2 = quotaFallback(errC)
if fbQuota2 then return fbQuota2 end
if tostring(errC):find("CONTENT_BLOCKED", 1, true) then
local fbBlocked2 = blockedFallback(errC)
if fbBlocked2 then return fbBlocked2 end
error(blockedMessage())
end
error(API.describeError(errS))
end
end
else
local okC, errC = pcall(function()
raw = requestOpenAICompatible(chatUrl, payload, headers, chatSpoof)
end)
if not okC and payload.tools then
-- Some models reject the tool schema outright. Retry the same turn without tools
-- instead of failing the whole message.
local plain = table.clone(payload)
plain.tools = nil
plain.tool_choice = nil
local okPlain, errPlain = pcall(function()
raw = requestOpenAICompatible(chatUrl, plain, headers, chatSpoof)
end)
if okPlain then
okC, errC = true, nil
else
errC = errPlain
end
end
if not okC then
local errMsg = tostring(errC)
-- OpenRouter's own errors (no credits, no endpoint, bad key) are actionable, so
-- they are shown instead of being swapped for a reply from another provider.
if useOpenRouter then error(API.describeError(errMsg)) end
local fbQuota = quotaFallback(errMsg)
if fbQuota then return fbQuota end
if errMsg:find("CONTENT_BLOCKED", 1, true) then
local fbBlocked3 = blockedFallback(errMsg)
if fbBlocked3 then return fbBlocked3 end
error(blockedMessage())
end
if errMsg:find("NEED_RESPONSES_API", 1, true) or errMsg:lower():find("/v1/responses", 1, true) then
local respPayload = {
model = selectedModel,
instructions = systemPrompt,
input = responsesInput(),
stream = false,
max_output_tokens = 4096,
}
responsesTools(respPayload)
if opts.think then respPayload.reasoning = { effort = "high", summary = "auto" } end
local okResp, errResp = pcall(function()
raw = requestHttp(API.AGENTROUTER_RESPONSES_URL, "POST", headers, HttpService:JSONEncode(respPayload))
end)
if not okResp then
local fbQuota2 = quotaFallback(errResp)
if fbQuota2 then return fbQuota2 end
error(API.describeError(tostring(errResp)))
end
useResponses = true
else
error(API.describeError(errMsg))
end
end
end
local result
if opencodeStream then
result = parseOpencodeStream(raw)
if not result then
result = parseChatAnswer(decodeJson(raw, "Chat response"))
end
else
local decoded = decodeJson(raw, "Chat response")
if useResponses then
result = parseResponsesAnswer(decoded)
else
result = parseChatAnswer(decoded)
end
end
result.fellBack = opts and opts._fellBack or nil
result.fellBackReason = opts and opts._fellBackReason or nil
if not result.fellBack then API.lastGoodModel = selectedModel end
return result
end
function runAgent(msg, history, model, opts)
local TOOL_LIMITS = { read_console = 2, take_screenshot = 1, web_search = 4, web_fetch = 3, inspect_player = 2 }
local agentMessages = nil
webTainted = false
local used = {}
msg.taskPhase = "analysing"
if msg._update then msg._update() end
for step = 1, 4 do
if step == 2 then msg.taskPhase = "planning" elseif step == 3 then msg.taskPhase = "editing" elseif step == 4 then msg.taskPhase = "finishing" end
if msg._update then msg._update() end
-- On the last pass force a normal answer so the turn cannot end on a tool call.
local callOpts = opts
if step == 4 and opts then
callOpts = table.clone(opts)
callOpts.tools = nil
end
if callOpts then callOpts._fellBack, callOpts._fellBackReason = nil, nil end
local reply = getAIReply(history, model, callOpts, agentMessages)
for _, s in ipairs(reply.searches or {}) do
table.insert(msg.tools, { kind = "web", label = "Searched the web: " .. s, state = "done" })
end
if reply.note then
table.insert(msg.tools, { kind = "note", label = reply.note, state = "done" })
end
msg.answeredById, msg.fellBackReason = reply.fellBack, reply.fellBackReason
if reply.fellBack then
local picked = model.short or model.name
local got = modelById(reply.fellBack)
local gotName = got and (got.short or got.name) or tostring(reply.fellBack)
local why
if reply.fellBackReason == "opencode" then
why = picked .. " was unavailable"
elseif reply.fellBackReason == "zenbusy" then
why = picked .. " was busy upstream"
elseif reply.fellBackReason == "nochannel" then
why = picked .. " is not served by this key"
elseif reply.fellBackReason == "blocked" then
why = picked .. " rejected this as sensitive content"
else
why = "Premium quota was empty for " .. picked
end
table.insert(msg.tools, { kind = "note", label = why .. " · " .. gotName .. " answered instead", state = "done" })
end
if msg._update then msg._update() end
if not reply.toolCalls or #reply.toolCalls == 0 or step == 4 then
msg.taskPhase = nil
return reply
end
agentMessages = agentMessages or {}
local rawCalls = {}
for _, call in ipairs(reply.toolCalls) do
local encodedArgs = "{}"
if call.args and next(call.args) ~= nil then
encodedArgs = HttpService:JSONEncode(call.args)
end
table.insert(rawCalls, { id = call.id, type = "function",
["function"] = { name = call.name, arguments = encodedArgs } })
end
table.insert(agentMessages, { role = "assistant", content = reply.text or "", tool_calls = rawCalls })
for _, call in ipairs(reply.toolCalls) do
if cancelFlag then return { text = "Stopped." } end
local chip = { kind = call.name, label = "Using " .. tostring(call.name), state = "running" }
table.insert(msg.tools, chip)
if msg._update then msg._update() end
local content, label, state, image
if (used[call.name] or 0) >= (TOOL_LIMITS[call.name] or 1) then
chip.label, chip.state = "Skipped (limit reached): " .. tostring(call.name), "denied"
content = "Tool call limit reached for this turn."
else
used[call.name] = (used[call.name] or 0) + 1
content, label, state, image = runTool(call)
chip.label, chip.state = label, state or "done"
end
table.insert(agentMessages, { role = "tool", tool_call_id = call.id, content = redactSecrets(content) })
if image and image.base64 then
table.insert(agentMessages, { role = "user", content = {
{ type = "text", text = "Screenshot captured from the game:" },
{ type = "image_url", image_url = { url = "data:" .. (image.mediaType or "image/png") .. ";base64," .. image.base64 } },
} })
end
if msg._update then msg._update() end
end
end
msg.taskPhase = nil
return { text = "" }
end
function subUtf8(str, n, len)
if n >= len then return str end
return str:sub(1, utf8.offset(str, n + 1) - 1)
end
local function typeInto(msg, field, full, speed)
local len = utf8.len(full)
if not len then msg[field] = full return true end
local i = 0
while i < len do
if cancelFlag then return false end
i = math.min(len, i + speed)
msg[field] = subUtf8(full, i, len)
if msg._update then msg._update() end
scrollToBottom()
task.wait(0.02)
end
return true
end
function generate()
local convo = current
local model = MODELS[modelIndex]
local msg = {
role = "assistant", text = "", model = model, status = "thinking",
thinking = "", thinkOn = thinkOn, thinkOpen = thinkOn, thinkSecs = 0, tools = {},
startClock = os.clock(), taskPhase = "analysing",
}
table.insert(convo.messages, msg)
busy, cancelFlag = true, false
setLoading(true)
renderMessages()
updateComposer()
task.spawn(function()
while msg.status == "thinking" or msg.status == "planning" or msg.status == "reasoning" do
msg.dots = ((msg.dots or 0) + 1) % 4
if msg._update then msg._update() end
task.wait(0.35)
end
end)
local started = os.clock()
local history = { table.unpack(convo.messages, 1, #convo.messages - 1) }
local opts = { think = msg.thinkOn, web = settings.web, tools = activeToolSchemas() }
local ok, result = pcall(runAgent, msg, history, model, opts)
if not ok then
local errMsg = tostring(result):gsub("^.-:%d+:%s*", ""):gsub("CoreGui%.%S+%s*", "")
if errMsg:find("NO_CHANNEL", 1, true) then
errMsg = API.describeError(errMsg)
elseif errMsg:find("OPENCODE_FREE_TIER", 1, true) then
errMsg = "OpenCode refused the free-tier request (HTTP 403). The fingerprint may have changed — try another free model or switch to GPT-6.1 Sol."
elseif errMsg:find("CONTENT_BLOCKED", 1, true) or errMsg:lower():find("content%-blocked") then
errMsg = API.describeError(errMsg)
elseif errMsg:find("402", 1, true) or errMsg:lower():find("batch quota", 1, true) then
errMsg = "Claude/GPT quota is empty right now. AgentRouter releases it in daily batches (10:00 and 19:00 Beijing time); your account balance is not the limit. A free OpenCode Zen model still works meanwhile."
end
if #errMsg > 460 then errMsg = errMsg:sub(1, 460) .. "…" end
msg.status, msg.text, msg.isError = "done", errMsg, true
msg.taskPhase = nil
else
local reply = type(result) == "table" and result or { text = tostring(result) }
msg.answeredById = reply.fellBack
msg.fellBackReason = reply.fellBackReason
local finished = true
if msg.thinkOn and reply.thinking then
msg.status = "reasoning"
finished = typeInto(msg, "thinking", reply.thinking, 4)
end
msg.thinkSecs = math.max(1, math.floor(os.clock() - started + 0.5))
msg.thinkOpen = false
msg.taskPhase = nil
if finished then
msg.status = "streaming"
finished = typeInto(msg, "text", reply.text or "", 3)
end
msg.status = "done"
if not finished and msg.text == "" then msg.text = "Stopped." end
end
busy = false
setLoading(false)
if convo == current then renderMessages() end
updateComposer()
end
function send(override)
if busy then
cancelFlag = true
return
end
local t = override or input.Text:match("^%s*(.-)%s*$") or ""
if t == "" and #attachments == 0 then return end
local m = MODELS[modelIndex]
if #attachments > 0 and not (m.vision or m.visionId) then
flashPlaceholder(m.name .. " can't read images. Switch models or remove them.")
return
end
input.Text = ""
if #current.messages == 0 then
local title = t ~= "" and t or "Image chat"
current.title = #title > 28 and (title:sub(1, 28) .. "...") or title
refreshSidebar()
end
local images = nil
if #attachments > 0 then
images = attachments
attachments = {}
refreshAttachStrip()
end
table.insert(current.messages, { role = "user", text = t, images = images })
generate()
end
sendBtn.MouseButton1Click:Connect(function() send() end)
newBtn.MouseButton1Click:Connect(function() newConversation(false) end)
menuBtn.MouseButton1Click:Connect(function() setSidebar(not sidebarOpen) end)
local function getTargetScale()
local viewport = camera.ViewportSize
local fitX = math.max(0.45, math.min(1, (viewport.X - 12) / 340))
local fitY = math.max(0.45, math.min(1, (viewport.Y - 12) / 280))
return math.min(settings.uiScale or 1, fitX, fitY)
end
local function setVisible(v)
if v then
window.Visible = true
winScale.Scale = getTargetScale() * 0.94
tween(winScale, 0.18, { Scale = getTargetScale() })
else
closeMenu()
window.Visible = false
end
end
closeBtn.MouseButton1Click:Connect(function() setVisible(false) end)
minBtn.MouseButton1Click:Connect(function() setVisible(false) end)
toggleBtn.MouseButton1Click:Connect(function() setVisible(not window.Visible) end)
UserInputService.InputBegan:Connect(function(io, processed)
if not processed and io.KeyCode == Enum.KeyCode.RightControl then
setVisible(not window.Visible)
end
end)
do
local drag = { active = false, mouse = nil, pos = nil }
header.InputBegan:Connect(function(io)
if io.UserInputType == Enum.UserInputType.MouseButton1 or io.UserInputType == Enum.UserInputType.Touch then
drag.active, drag.mouse, drag.pos = true, io.Position, window.Position
end
end)
UserInputService.InputChanged:Connect(function(io)
if drag.active and (io.UserInputType == Enum.UserInputType.MouseMovement
or io.UserInputType == Enum.UserInputType.Touch) then
local d = io.Position - drag.mouse
window.Position = UDim2.new(drag.pos.X.Scale, drag.pos.X.Offset + d.X,
drag.pos.Y.Scale, drag.pos.Y.Offset + d.Y)
end
end)
UserInputService.InputEnded:Connect(function(io)
if io.UserInputType == Enum.UserInputType.MouseButton1 or io.UserInputType == Enum.UserInputType.Touch then
drag.active = false
end
end)
end
local function updateViewport()
local viewport = camera.ViewportSize
small = viewport.X < 700
SIDEBAR_W = small and math.max(150, math.floor(viewport.X * 0.82)) or 236
window.Size = UDim2.new(0.94, 0, 0.92, 0)
winScale.Scale = getTargetScale()
modelBtn.Size = UDim2.fromOffset(small and 116 or 140, 30)
contextPct.Visible = not small
titleLabel.Size = UDim2.new(1, small and -150 or -284, 0, 24)
subtitleLabel.Size = UDim2.new(1, small and -150 or -284, 0, 16)
if small then
sidebar.ZIndex = 20
setSidebar(false, true)
else
sidebar.ZIndex = 1
setSidebar(sidebarOpen, true)
end
updateComposer()
end
camera:GetPropertyChangedSignal("ViewportSize"):Connect(updateViewport)
setSidebar(sidebarOpen, true)
applyTheme(settings.theme)
updateViewport()
-- Initialize Folders and Game Context --
projects = {}
do
local gameProj = newProject("[Game] " .. gameName)
gameProj.context = "The player is currently in a Roblox game named '" .. gameName .. "'. PlaceId: " .. tostring(placeId) .. ". Use this context if the user asks for help related to the game."
newProject("General Chats")
currentProject = gameProj
conversations = gameProj.conversations
selectProject(gameProj)
end
newConversation(true)
applyModel()
updateComposer()