local PING_PROMPT = 'reply with exactly: pong'; local DEFAULT_TIMEOUT_SECONDS = 1200; local B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; local function base64encode(s) local pad = (3 - #s % 3) % 3; s = s .. string.rep('\0', pad); local r = {}; for i = 1, #s, 3 do local a, b, c = s:byte(i), s:byte(i + 1), s:byte(i + 2); local n = a * 65536 + b * 256 + c; r[#r + 1] = B64:sub(math.floor(n / 262144) % 64 + 1, math.floor(n / 262144) % 64 + 1) .. B64:sub(math.floor(n / 4096) % 64 + 1, math.floor(n / 4096) % 64 + 1) .. B64:sub(math.floor(n / 64) % 64 + 1, math.floor(n / 64) % 64 + 1) .. B64:sub(n % 64 + 1, n % 64 + 1); end local result = table.concat(r); if pad > 0 then result = result:sub(1, #result - pad) .. string.rep('=', pad); end return result; end local function trimTrailingSlash(s) return (s:gsub('/+$', '')); end local function isBlank(s) return type(s) ~= 'string' or string.match(s, '^%s*$') ~= nil; end local function readAllAndClose(response) local body = response.readAll(); response.close(); return body; end local function statusCode(response) if response.getResponseCode then return response.getResponseCode(); end return nil; end local function extractTextParts(parts) local texts = {}; for _, part in ipairs(parts) do if part.type == 'text' and type(part.text) == 'string' then texts[#texts + 1] = part.text; end end return table.concat(texts, ''); end local function sessionTime(session) if type(session) ~= 'table' or type(session.time) ~= 'table' then return 0; end return tonumber(session.time.updated or session.time.created) or 0; end local function createAi(opts) opts = opts or {}; local httpLib = opts.http or http; local settingsLib = opts.settings or settings; local api = {}; local function resolveTimeout(options) local raw = options.timeoutSeconds; if raw == nil then raw = settingsLib.get('opencc.timeout_seconds'); end local n = tonumber(raw); if n and n > 0 then return n; end return DEFAULT_TIMEOUT_SECONDS; end local function resolveConfig(options) local url = options.serverUrl or settingsLib.get('opencc.server_url'); if not url or url == '' then return nil, 'missing opencc.server_url; run: set opencc.server_url '; end local username = options.username or settingsLib.get('opencc.username') or 'opencode'; local password = options.password or settingsLib.get('opencc.password') or ''; return { url = trimTrailingSlash(url), username = username, password = password, timeoutSeconds = resolveTimeout(options), }; end local function buildHeaders(cfg) local headers = { ['Content-Type'] = 'application/json', ['Accept'] = 'application/json', }; if cfg.password and cfg.password ~= '' then headers['Authorization'] = 'Basic ' .. base64encode(cfg.username .. ':' .. cfg.password); end return headers; end local function doGet(cfg, path) local response, _, errorResponse = httpLib.get({ url = cfg.url .. path, headers = buildHeaders(cfg), timeout = cfg.timeoutSeconds, }); response = response or errorResponse; if not response then return nil, 'serveur injoignable'; end local code = statusCode(response); local body = readAllAndClose(response); return body, code; end local function doPost(cfg, path, payload) local response, _, errorResponse = httpLib.post({ url = cfg.url .. path, body = textutils.serializeJSON(payload), headers = buildHeaders(cfg), timeout = cfg.timeoutSeconds, }); response = response or errorResponse; if not response then return nil, 'serveur injoignable'; end local code = statusCode(response); local body = readAllAndClose(response); return body, code; end function api.clearSession() settingsLib.unset('opencc.session_id'); if settingsLib.save then settingsLib.save(); end end function api.listSessions(options) options = options or {}; local cfg, err = resolveConfig(options); if not cfg then return false, err; end local body, code = doGet(cfg, '/session'); if not body then return false, code; end if code and code ~= 200 then return false, 'erreur serveur: HTTP ' .. tostring(code); end local decoded = textutils.unserializeJSON(body); if type(decoded) ~= 'table' then return false, 'reponse invalide'; end table.sort(decoded, function(a, b) return sessionTime(a) > sessionTime(b); end); return true, decoded; end function api.ask(prompt, options) options = options or {}; if isBlank(prompt) then return false, 'missing prompt; usage: ai '; end local cfg, err = resolveConfig(options); if not cfg then return false, err; end local sessionId = options.sessionId; if sessionId == nil then sessionId = settingsLib.get('opencc.session_id'); end if not sessionId or sessionId == '' then local body, code = doPost(cfg, '/session', { title = 'cc-ai' }); if not body then return false, code; end if code and code ~= 200 then return false, 'impossible de creer une session: HTTP ' .. tostring(code); end local decoded = textutils.unserializeJSON(body); if type(decoded) ~= 'table' or type(decoded.id) ~= 'string' then return false, 'reponse session invalide'; end sessionId = decoded.id; settingsLib.set('opencc.session_id', sessionId); if settingsLib.save then settingsLib.save(); end end local body, code = doPost(cfg, '/session/' .. sessionId .. '/message', { parts = { { type = 'text', text = prompt } }, }); if not body then return false, code; end if code == 404 then settingsLib.unset('opencc.session_id'); if settingsLib.save then settingsLib.save(); end return false, 'session introuvable; lance: ai new '; end if code and code ~= 200 then return false, 'erreur message: HTTP ' .. tostring(code); end local decoded = textutils.unserializeJSON(body); if type(decoded) ~= 'table' or type(decoded.parts) ~= 'table' then return false, 'reponse message invalide'; end local reply = extractTextParts(decoded.parts); if reply == '' then return false, 'reponse vide'; end return true, { reply = reply, sessionId = sessionId }; end function api.ping(options) return api.ask(PING_PROMPT, options); end return api; end return createAi;