cc-libs/apis/libai.lua

232 lines
6.6 KiB
Lua

local _VERSION = '0.4.1';
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 = {};
function api.version()
return _VERSION;
end
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 <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 <prompt>';
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 <prompt>';
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;