Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
"private": true,
"license": "SEE LICENSE IN LICENSE",
"type": "module",
"workspaces": [
"packages/*",
"plugin-for-vscode"
],
"repository": {
"type": "git",
"url": "https://github.com/Staks-sor/ai-free.git"
Expand Down
21 changes: 21 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "@ai-free/core",
"version": "0.4.24",
"description": "Shared core abstractions, model catalog, i18n, and pure utilities for AI Free",
"private": true,
"license": "SEE LICENSE IN LICENSE",
"type": "module",
"main": "./src/index.mjs",
"exports": {
".": "./src/index.mjs",
"./models": "./src/providers/model-catalog.mjs",
"./providers/*": "./src/providers/*.mjs",
"./i18n": "./src/i18n/index.mjs",
"./i18n/*": "./src/i18n/*.mjs",
"./code-agent/*": "./src/code-agent/*.mjs",
"./memory/*": "./src/memory/*.mjs"
},
"engines": {
"node": ">=18"
}
}
143 changes: 143 additions & 0 deletions packages/core/src/code-agent/parser.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// Парсер JSON-tool-call'а из ответа LLM.
// Устойчив к markdown-блокам, тексту до/после JSON, нескольким JSON-объектам.
//
// 3 стратегии последовательно:
// 1. Пройтись по всем fenced-блокам ```...``` (любой язык: json, python, tool_calls),
// искать tool в содержимом каждого.
// 2. Вырезать ВСЕ fenced-блоки (они часто содержат пояснения на python и т.п.)
// и искать tool в остатке. Спасает Qwen-кейс: ```python ...``` + текст +
// {"tool":"write_file",...} снаружи блока.
// 3. Fallback — искать в исходном тексте целиком.

export function parseToolCall(text) {
const trimmed = String(text || "").trim();

const xmlResult = findXmlToolCall(trimmed);
if (xmlResult) return xmlResult;

const fencedBlocks = [
...trimmed.matchAll(/```[a-zA-Z0-9]*\n?([\s\S]*?)```/gi),
];
for (const match of fencedBlocks) {
const result = findToolCallInText(match[1].trim());
if (result) return result;
}

const stripped = trimmed.replace(/```[a-zA-Z0-9]*\n?[\s\S]*?```/gi, " ");
const result2 = findToolCallInText(stripped);
if (result2) return result2;

return findToolCallInText(trimmed);
}

function findXmlToolCall(text) {
const match = text.match(/<tool_call\s+name=(["'])([^"']+)\1\s*>([\s\S]*?)<\/tool_call>/i);
if (!match) return null;

const tool = match[2];
const rawBody = match[3].trim();
if (!rawBody) return { tool };

try {
const parsed = JSON.parse(rawBody);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return { tool, ...parsed };
}
} catch {
// Fall through to JSON extraction below.
}

const json = extractFirstJsonObject(rawBody);
if (!json) return { tool };
try {
const parsed = JSON.parse(json);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return { tool, ...parsed };
}
} catch {
// ignore
}
return { tool };
}

// Ищет первый JSON-объект с полем "tool" (string) в тексте.
// Если первый {...} не tool-call — пропускает и берёт следующий.
function findToolCallInText(text) {
let offset = 0;

while (offset < text.length) {
const start = text.indexOf("{", offset);
if (start < 0) return null;

const candidate = extractFirstJsonObject(text.slice(start));
if (!candidate) {
return null;
}

try {
const parsed = normalizeToolCall(JSON.parse(candidate));
if (parsed) return parsed;
} catch {
// Невалидный JSON — пробуем следующий объект.
}

offset = start + Math.max(candidate.length, 1);
}

return null;
}

function normalizeToolCall(parsed) {
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
if (typeof parsed.tool === "string") return parsed;

// Some models emit ACP-ish or malformed tool JSON, for example:
// {"":"write_file","path":"x","content":""}
// {"name":"write_file","arguments":{"path":"x","content":""}}
const emptyKeyTool = parsed[""];
if (typeof emptyKeyTool === "string") {
const { [""]: _ignored, ...rest } = parsed;
return { tool: emptyKeyTool, ...rest };
}

if (typeof parsed.name === "string" && parsed.arguments && typeof parsed.arguments === "object") {
return { tool: parsed.name, ...parsed.arguments };
}

return null;
}

// Безопасный экстрактор первого валидного JSON-объекта из текста.
// Уважает строки и эскейпы, не путается на скобках внутри значений.
export function extractFirstJsonObject(text) {
const start = text.indexOf("{");
if (start < 0) return null;

let depth = 0;
let inString = false;
let escaped = false;

for (let index = start; index < text.length; index += 1) {
const char = text[index];

if (inString) {
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (char === '"') {
inString = false;
}
continue;
}

if (char === '"') inString = true;
else if (char === "{") depth += 1;
else if (char === "}") {
depth -= 1;
if (depth === 0) return text.slice(start, index + 1);
}
}

return null;
}
94 changes: 94 additions & 0 deletions packages/core/src/i18n/index.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { language as ru } from "./languages/ru.mjs";
import { language as en } from "./languages/en.mjs";
import { language as es } from "./languages/es.mjs";
import { language as pt } from "./languages/pt.mjs";
import { language as fr } from "./languages/fr.mjs";
import { language as de } from "./languages/de.mjs";
import { language as zh } from "./languages/zh.mjs";
import { language as hi } from "./languages/hi.mjs";
import { language as ar } from "./languages/ar.mjs";

export const DEFAULT_LANGUAGE = "ru";

export const LANGUAGES = Object.freeze({
ru,
en,
es,
pt,
fr,
de,
zh,
hi,
ar,
});

const ALIASES = Object.freeze({
"pt-br": "pt",
"pt-pt": "pt",
"zh-cn": "zh",
"zh-hans": "zh",
"zh-tw": "zh",
"zh-hant": "zh",
});

export function normalizeLanguage(value) {
const raw = String(value || "")
.trim()
.replace(/\..*$/, "")
.replace(/_/g, "-")
.toLowerCase();
if (!raw) return DEFAULT_LANGUAGE;
const exact = ALIASES[raw] || raw;
if (LANGUAGES[exact]) return exact;
const short = exact.split("-")[0];
return LANGUAGES[short] ? short : DEFAULT_LANGUAGE;
}

export function resolveUserLanguage(explicitLanguage = "") {
return normalizeLanguage(
explicitLanguage
|| process.env.AI_FREE_LANG
|| process.env.LC_ALL
|| process.env.LC_MESSAGES
|| process.env.LANG
|| DEFAULT_LANGUAGE,
);
}

export function getLanguageMeta(languageCode = DEFAULT_LANGUAGE) {
const language = LANGUAGES[normalizeLanguage(languageCode)] || LANGUAGES[DEFAULT_LANGUAGE];
return {
code: language.code,
name: language.name,
dir: language.dir || "ltr",
};
}

export function getMessages(languageCode = DEFAULT_LANGUAGE) {
const code = normalizeLanguage(languageCode);
const base = code === DEFAULT_LANGUAGE
? LANGUAGES[DEFAULT_LANGUAGE].messages
: LANGUAGES.en.messages;
return {
...base,
...(LANGUAGES[code]?.messages || {}),
};
}

export function formatMessage(template, vars = {}) {
return String(template || "").replace(/\{([a-zA-Z0-9_]+)\}/g, (match, key) => (
Object.prototype.hasOwnProperty.call(vars, key) ? String(vars[key]) : match
));
}

export function createTranslator(languageCode = DEFAULT_LANGUAGE) {
const language = getLanguageMeta(languageCode);
const messages = getMessages(language.code);
return {
language,
messages,
t(key, vars = {}) {
return formatMessage(messages[key] || key, vars);
},
};
}
Loading
Loading