@fraza/module-ai 0.0.0-stage → 0.4.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +152 -0
- package/LICENSE +21 -0
- package/README.md +72 -2
- package/THIRD-PARTY-NOTICES.md +11 -0
- package/changelog.json +459 -0
- package/dist/admin/AiActions.client.js +166 -0
- package/dist/admin/AiChoices.client.js +67 -0
- package/dist/admin/AiInputForm.client.js +57 -0
- package/dist/admin/AiPage.client.js +181 -0
- package/dist/admin/AiResults.client.js +76 -0
- package/dist/admin/AiStudio.client.js +453 -0
- package/dist/admin/AiUsageWidget.client.js +28 -0
- package/dist/admin/StudioBar.client.js +33 -0
- package/dist/admin/StudioCompose.client.js +457 -0
- package/dist/admin/StudioComposer.client.js +217 -0
- package/dist/admin/StudioFields.client.js +124 -0
- package/dist/admin/StudioPlan.client.js +94 -0
- package/dist/admin/StudioWorking.client.js +157 -0
- package/dist/admin/choices.js +22 -0
- package/dist/admin/format.js +19 -0
- package/dist/admin/input-form.js +59 -0
- package/dist/admin/paths.js +4 -0
- package/dist/admin/studio-compose.js +94 -0
- package/dist/admin/studio-diff.js +240 -0
- package/dist/admin/widgets.js +14 -0
- package/dist/admin.js +9 -0
- package/dist/api.server.js +155 -0
- package/dist/config.server.js +113 -0
- package/dist/cron.server.js +26 -0
- package/dist/db.server.js +65 -0
- package/dist/index.js +41 -0
- package/dist/integration.server.js +28 -0
- package/dist/migrations.js +4 -0
- package/dist/permissions.js +9 -0
- package/dist/prompts.server.js +149 -0
- package/dist/providers/anthropic.server.js +42 -0
- package/dist/providers/chat-completions.server.js +63 -0
- package/dist/providers/google.server.js +52 -0
- package/dist/providers/http.server.js +98 -0
- package/dist/providers/index.server.js +22 -0
- package/dist/providers/ollama.server.js +45 -0
- package/dist/register-server.js +34 -0
- package/dist/results.server.js +74 -0
- package/dist/run.server.js +166 -0
- package/dist/runs.server.js +106 -0
- package/dist/schema.js +190 -0
- package/dist/server.js +19 -0
- package/dist/source.js +4 -0
- package/dist/studio.js +205 -0
- package/dist/test-prompt.server.js +24 -0
- package/dist/version.js +4 -0
- package/dist/widgets.server.js +29 -0
- package/package.json +67 -4
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
const PROVIDERS = Object.freeze({
|
|
2
|
+
anthropic: { label: "Anthropic", needsKey: true, baseUrl: "https://api.anthropic.com" },
|
|
3
|
+
openai: { label: "OpenAI", needsKey: true, baseUrl: "https://api.openai.com/v1" },
|
|
4
|
+
google: { label: "Google", needsKey: true, baseUrl: "https://generativelanguage.googleapis.com/v1beta" },
|
|
5
|
+
mistral: { label: "Mistral", needsKey: true, baseUrl: "https://api.mistral.ai/v1" },
|
|
6
|
+
"openai-compatible": { label: "Compatible OpenAI", needsKey: false, baseUrl: null },
|
|
7
|
+
ollama: { label: "Ollama", needsKey: false, baseUrl: "http://localhost:11434" }
|
|
8
|
+
});
|
|
9
|
+
const DEFAULT_TIMEOUT_MS = 12e4;
|
|
10
|
+
const DEFAULT_RETENTION_DAYS = 90;
|
|
11
|
+
const TIMEOUT_BOUNDS = [5e3, 6e5];
|
|
12
|
+
const RETENTION_BOUNDS = [1, 3650];
|
|
13
|
+
class AiConfigError extends Error {
|
|
14
|
+
constructor(message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "AiConfigError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function env(key) {
|
|
20
|
+
const value = process.env[key];
|
|
21
|
+
return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
|
|
22
|
+
}
|
|
23
|
+
function boundedInteger(key, fallback, [min, max]) {
|
|
24
|
+
const raw = env(key);
|
|
25
|
+
if (raw === null) return fallback;
|
|
26
|
+
const value = Number(raw);
|
|
27
|
+
if (!Number.isInteger(value) || value < min || value > max) {
|
|
28
|
+
throw new AiConfigError(`${key} doit \xEAtre un entier entre ${min} et ${max}.`);
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
function readBaseUrl(provider) {
|
|
33
|
+
const raw = env("FRAZA_MODULE_AI_BASE_URL") ?? PROVIDERS[provider].baseUrl;
|
|
34
|
+
if (!raw) throw new AiConfigError(`FRAZA_MODULE_AI_BASE_URL est requise pour le fournisseur \xAB ${provider} \xBB.`);
|
|
35
|
+
let url;
|
|
36
|
+
try {
|
|
37
|
+
url = new URL(raw);
|
|
38
|
+
} catch {
|
|
39
|
+
throw new AiConfigError("FRAZA_MODULE_AI_BASE_URL doit \xEAtre une adresse compl\xE8te, comme \xAB https://api.exemple.com/v1 \xBB.");
|
|
40
|
+
}
|
|
41
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
42
|
+
throw new AiConfigError("FRAZA_MODULE_AI_BASE_URL doit commencer par https:// ou http://.");
|
|
43
|
+
}
|
|
44
|
+
return raw.replace(/\/+$/, "");
|
|
45
|
+
}
|
|
46
|
+
function getAiConfig() {
|
|
47
|
+
const provider = env("FRAZA_MODULE_AI_PROVIDER");
|
|
48
|
+
if (!provider) throw new AiConfigError("FRAZA_MODULE_AI_PROVIDER est requise.");
|
|
49
|
+
if (!Object.hasOwn(PROVIDERS, provider)) {
|
|
50
|
+
throw new AiConfigError(`FRAZA_MODULE_AI_PROVIDER doit valoir ${Object.keys(PROVIDERS).join(", ")}.`);
|
|
51
|
+
}
|
|
52
|
+
const model = env("FRAZA_MODULE_AI_MODEL");
|
|
53
|
+
if (!model) throw new AiConfigError("FRAZA_MODULE_AI_MODEL est requise : le nom du mod\xE8le, tel que le fournisseur le donne.");
|
|
54
|
+
const apiKey = env("FRAZA_MODULE_AI_API_KEY");
|
|
55
|
+
if (PROVIDERS[provider].needsKey && !apiKey) {
|
|
56
|
+
throw new AiConfigError(`FRAZA_MODULE_AI_API_KEY est requise pour le fournisseur \xAB ${provider} \xBB.`);
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
provider,
|
|
60
|
+
model,
|
|
61
|
+
apiKey,
|
|
62
|
+
baseUrl: readBaseUrl(provider),
|
|
63
|
+
timeoutMs: boundedInteger("FRAZA_MODULE_AI_TIMEOUT_MS", DEFAULT_TIMEOUT_MS, TIMEOUT_BOUNDS)
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function getRetentionDays() {
|
|
67
|
+
return boundedInteger("FRAZA_MODULE_AI_RETENTION_DAYS", DEFAULT_RETENTION_DAYS, RETENTION_BOUNDS);
|
|
68
|
+
}
|
|
69
|
+
function isAiEnabled() {
|
|
70
|
+
return env("FRAZA_MODULE_AI") === "true";
|
|
71
|
+
}
|
|
72
|
+
function describeAiConfig() {
|
|
73
|
+
const provider = env("FRAZA_MODULE_AI_PROVIDER");
|
|
74
|
+
const known = provider && Object.hasOwn(PROVIDERS, provider);
|
|
75
|
+
const summary = {
|
|
76
|
+
configured: false,
|
|
77
|
+
provider: provider ?? null,
|
|
78
|
+
providerLabel: known ? PROVIDERS[provider].label : null,
|
|
79
|
+
model: env("FRAZA_MODULE_AI_MODEL"),
|
|
80
|
+
hasKey: env("FRAZA_MODULE_AI_API_KEY") !== null,
|
|
81
|
+
// Only the address of a self-hosted service: it says where the data goes.
|
|
82
|
+
baseUrl: known && !PROVIDERS[provider].needsKey ? env("FRAZA_MODULE_AI_BASE_URL") ?? PROVIDERS[provider].baseUrl : null,
|
|
83
|
+
problem: null
|
|
84
|
+
};
|
|
85
|
+
try {
|
|
86
|
+
getAiConfig();
|
|
87
|
+
summary.configured = true;
|
|
88
|
+
} catch (err) {
|
|
89
|
+
summary.problem = err instanceof AiConfigError ? err.message : "Configuration illisible.";
|
|
90
|
+
}
|
|
91
|
+
if (summary.configured) {
|
|
92
|
+
try {
|
|
93
|
+
getRetentionDays();
|
|
94
|
+
} catch (err) {
|
|
95
|
+
summary.problem = err.message;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return summary;
|
|
99
|
+
}
|
|
100
|
+
function isAiConfigured() {
|
|
101
|
+
return describeAiConfig().configured;
|
|
102
|
+
}
|
|
103
|
+
export {
|
|
104
|
+
AiConfigError,
|
|
105
|
+
DEFAULT_RETENTION_DAYS,
|
|
106
|
+
DEFAULT_TIMEOUT_MS,
|
|
107
|
+
PROVIDERS,
|
|
108
|
+
describeAiConfig,
|
|
109
|
+
getAiConfig,
|
|
110
|
+
getRetentionDays,
|
|
111
|
+
isAiConfigured,
|
|
112
|
+
isAiEnabled
|
|
113
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { schedule } from "@fraza/core/cron";
|
|
2
|
+
import { fail } from "@fraza/core/shared/logger";
|
|
3
|
+
import { getRetentionDays } from "./config.server.js";
|
|
4
|
+
import { purgeDismissedResults } from "./results.server.js";
|
|
5
|
+
import { purgeRuns } from "./runs.server.js";
|
|
6
|
+
const RETENTION_JOB = "ai-retention";
|
|
7
|
+
async function runRetention({ signal } = {}) {
|
|
8
|
+
const days = getRetentionDays();
|
|
9
|
+
const runs = await purgeRuns({ days, signal });
|
|
10
|
+
const results = await purgeDismissedResults({ days, signal });
|
|
11
|
+
return { runs, results };
|
|
12
|
+
}
|
|
13
|
+
async function registerCronJobs() {
|
|
14
|
+
try {
|
|
15
|
+
await schedule(RETENTION_JOB, "17 3 * * *", ({ signal }) => runRetention({ signal }), {
|
|
16
|
+
lock: { ttlSeconds: 900 }
|
|
17
|
+
});
|
|
18
|
+
} catch (err) {
|
|
19
|
+
fail(`AI: job ${RETENTION_JOB} could not be scheduled`, err);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export {
|
|
23
|
+
RETENTION_JOB,
|
|
24
|
+
registerCronJobs,
|
|
25
|
+
runRetention
|
|
26
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { query, tableExists } from "@fraza/core/database";
|
|
2
|
+
const TABLES = [
|
|
3
|
+
{
|
|
4
|
+
name: "fraza_ai_run",
|
|
5
|
+
sql: [
|
|
6
|
+
`CREATE TABLE fraza_ai_run (
|
|
7
|
+
id BIGSERIAL PRIMARY KEY,
|
|
8
|
+
prompt VARCHAR(120) NOT NULL,
|
|
9
|
+
source VARCHAR(214),
|
|
10
|
+
trigger VARCHAR(20) NOT NULL,
|
|
11
|
+
provider VARCHAR(40) NOT NULL,
|
|
12
|
+
model VARCHAR(200),
|
|
13
|
+
status VARCHAR(20) NOT NULL CHECK (status IN ('success', 'error')),
|
|
14
|
+
error_code VARCHAR(40),
|
|
15
|
+
error_message TEXT,
|
|
16
|
+
input_tokens INTEGER,
|
|
17
|
+
output_tokens INTEGER,
|
|
18
|
+
attempts SMALLINT NOT NULL DEFAULT 1,
|
|
19
|
+
duration_ms INTEGER,
|
|
20
|
+
user_id TEXT,
|
|
21
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
22
|
+
)`,
|
|
23
|
+
`CREATE INDEX fraza_ai_run_created_idx ON fraza_ai_run (created_at)`
|
|
24
|
+
]
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: "fraza_ai_result",
|
|
28
|
+
sql: [
|
|
29
|
+
`CREATE TABLE fraza_ai_result (
|
|
30
|
+
id BIGSERIAL PRIMARY KEY,
|
|
31
|
+
target_type VARCHAR(120) NOT NULL,
|
|
32
|
+
target_id VARCHAR(120) NOT NULL,
|
|
33
|
+
prompt VARCHAR(120) NOT NULL,
|
|
34
|
+
data JSONB NOT NULL,
|
|
35
|
+
model VARCHAR(200),
|
|
36
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
37
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
38
|
+
dismissed_at TIMESTAMPTZ,
|
|
39
|
+
CONSTRAINT fraza_ai_result_target_unique UNIQUE (target_type, target_id, prompt)
|
|
40
|
+
)`
|
|
41
|
+
]
|
|
42
|
+
}
|
|
43
|
+
];
|
|
44
|
+
async function createTables() {
|
|
45
|
+
const created = [];
|
|
46
|
+
const skipped = [];
|
|
47
|
+
for (const table of TABLES) {
|
|
48
|
+
if (await tableExists(table.name)) {
|
|
49
|
+
skipped.push(table.name);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
for (const statement of table.sql) await query(statement);
|
|
53
|
+
created.push(table.name);
|
|
54
|
+
}
|
|
55
|
+
return { created, skipped };
|
|
56
|
+
}
|
|
57
|
+
async function dropTables() {
|
|
58
|
+
for (const table of [...TABLES].reverse()) {
|
|
59
|
+
await query(`DROP TABLE IF EXISTS ${table.name} CASCADE`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export {
|
|
63
|
+
createTables,
|
|
64
|
+
dropTables
|
|
65
|
+
};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { MODULE_VERSION } from "./version.js";
|
|
2
|
+
import { AI_PERMISSIONS } from "./permissions.js";
|
|
3
|
+
const manifest = {
|
|
4
|
+
name: "@fraza/module-ai",
|
|
5
|
+
version: MODULE_VERSION,
|
|
6
|
+
permissions: [
|
|
7
|
+
{
|
|
8
|
+
key: AI_PERMISSIONS.USE,
|
|
9
|
+
name: "Utiliser l'IA",
|
|
10
|
+
description: "Lancer les boutons d'IA des \xE9crans de l'admin et voir les propositions de l'IA",
|
|
11
|
+
group_name: "IA"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
key: AI_PERMISSIONS.VIEW,
|
|
15
|
+
name: "Voir la page IA",
|
|
16
|
+
description: "Configuration du fournisseur, prompts d\xE9clar\xE9s, historique des appels et jetons consomm\xE9s",
|
|
17
|
+
group_name: "IA"
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
envVars: [
|
|
21
|
+
{ key: "FRAZA_MODULE_AI", required: true, description: "Active le module IA (true / false)." },
|
|
22
|
+
{ key: "FRAZA_MODULE_AI_PROVIDER", required: true, description: "Fournisseur d'IA : anthropic, openai, google, mistral, openai-compatible ou ollama." },
|
|
23
|
+
{ key: "FRAZA_MODULE_AI_MODEL", required: true, description: "Mod\xE8le, tel que le fournisseur le nomme." },
|
|
24
|
+
{ key: "FRAZA_MODULE_AI_API_KEY", required: false, description: "Cl\xE9 d'API du fournisseur. Requise sauf pour Ollama et un service compatible sans cl\xE9." },
|
|
25
|
+
{ key: "FRAZA_MODULE_AI_BASE_URL", required: false, description: "Adresse de l'API. Requise pour openai-compatible ; http://localhost:11434 par d\xE9faut pour ollama." },
|
|
26
|
+
{ key: "FRAZA_MODULE_AI_TIMEOUT_MS", required: false, description: "Attente maximale d'un appel, en millisecondes. 120000 par d\xE9faut." },
|
|
27
|
+
{ key: "FRAZA_MODULE_AI_RETENTION_DAYS", required: false, description: "Jours d'historique des appels conserv\xE9s. 90 par d\xE9faut." }
|
|
28
|
+
]
|
|
29
|
+
};
|
|
30
|
+
async function register() {
|
|
31
|
+
await import("./register-server.js");
|
|
32
|
+
}
|
|
33
|
+
import { createTables, dropTables } from "./db.server.js";
|
|
34
|
+
import { migrations } from "./migrations.js";
|
|
35
|
+
export {
|
|
36
|
+
createTables,
|
|
37
|
+
dropTables,
|
|
38
|
+
manifest,
|
|
39
|
+
migrations,
|
|
40
|
+
register
|
|
41
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { provideIntegration } from "@fraza/core/integrations";
|
|
2
|
+
import { isAiConfigured } from "./config.server.js";
|
|
3
|
+
import { definePrompt } from "./prompts.server.js";
|
|
4
|
+
import { deleteResults, dismissResult, getResults, saveResult } from "./results.server.js";
|
|
5
|
+
import { runPrompt } from "./run.server.js";
|
|
6
|
+
import { getUsage } from "./runs.server.js";
|
|
7
|
+
import { SOURCE } from "./source.js";
|
|
8
|
+
const AI_CONTRACT = 1;
|
|
9
|
+
const AI_FEATURES = Object.freeze(["html", "hints", "compose", "plan", "hint-groups"]);
|
|
10
|
+
function provideAiIntegration() {
|
|
11
|
+
provideIntegration("ai", {
|
|
12
|
+
contract: AI_CONTRACT,
|
|
13
|
+
features: AI_FEATURES,
|
|
14
|
+
definePrompt,
|
|
15
|
+
runPrompt,
|
|
16
|
+
saveResult,
|
|
17
|
+
getResults,
|
|
18
|
+
dismissResult,
|
|
19
|
+
deleteResults,
|
|
20
|
+
isAiConfigured,
|
|
21
|
+
getUsage
|
|
22
|
+
}, { source: SOURCE });
|
|
23
|
+
}
|
|
24
|
+
export {
|
|
25
|
+
AI_CONTRACT,
|
|
26
|
+
AI_FEATURES,
|
|
27
|
+
provideAiIntegration
|
|
28
|
+
};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { checkSchema } from "./schema.js";
|
|
2
|
+
import { checkHints, checkSuggestions, STUDIO_INPUT } from "./studio.js";
|
|
3
|
+
const NAME_PATTERN = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)*$/;
|
|
4
|
+
const MODES = /* @__PURE__ */ new Set(["fill", "choose", "studio"]);
|
|
5
|
+
const DEFAULT_MAX_TOKENS = 16e3;
|
|
6
|
+
const MAX_TOKENS_LIMIT = 128e3;
|
|
7
|
+
const PROJECT_SOURCE = "projet";
|
|
8
|
+
const STATE_KEY = /* @__PURE__ */ Symbol.for("__FRAZA_AI_PROMPTS__");
|
|
9
|
+
function state() {
|
|
10
|
+
globalThis[STATE_KEY] ??= { prompts: /* @__PURE__ */ new Map(), customizations: /* @__PURE__ */ new Map(), global: { instructions: null } };
|
|
11
|
+
return globalThis[STATE_KEY];
|
|
12
|
+
}
|
|
13
|
+
function isText(value) {
|
|
14
|
+
return typeof value === "string" && value.trim() !== "";
|
|
15
|
+
}
|
|
16
|
+
function optionalText(value, key, name) {
|
|
17
|
+
if (value !== void 0 && typeof value !== "string") throw new TypeError(`definePrompt(${name}): ${key} must be a string`);
|
|
18
|
+
}
|
|
19
|
+
function checkDefinition(definition) {
|
|
20
|
+
if (!definition || typeof definition !== "object") throw new TypeError("definePrompt: the definition must be an object");
|
|
21
|
+
const { name } = definition;
|
|
22
|
+
if (typeof name !== "string" || !NAME_PATTERN.test(name)) {
|
|
23
|
+
throw new TypeError(`definePrompt: the name must be lowercase words, e.g. 'posts.write' (got ${JSON.stringify(name)})`);
|
|
24
|
+
}
|
|
25
|
+
const at = JSON.stringify(name);
|
|
26
|
+
if (definition.target !== void 0 && !(typeof definition.target === "string" && NAME_PATTERN.test(definition.target))) {
|
|
27
|
+
throw new TypeError(`definePrompt(${at}): target must be lowercase words, e.g. 'posts.post'`);
|
|
28
|
+
}
|
|
29
|
+
if (definition.target !== void 0 && !isText(definition.label)) {
|
|
30
|
+
throw new TypeError(`definePrompt(${at}): a prompt with a target needs a label, the text of its button`);
|
|
31
|
+
}
|
|
32
|
+
if (definition.mode !== void 0 && !MODES.has(definition.mode)) throw new TypeError(`definePrompt(${at}): mode must be 'fill', 'choose' or 'studio'`);
|
|
33
|
+
if (definition.mode === "studio") {
|
|
34
|
+
if (definition.input !== void 0) throw new TypeError(`definePrompt(${at}): a studio prompt has no input, its form is the request`);
|
|
35
|
+
checkSuggestions(definition.suggestions, `definePrompt(${at})`);
|
|
36
|
+
checkHints(definition.hints, `definePrompt(${at})`);
|
|
37
|
+
} else if (definition.suggestions !== void 0 || definition.hints !== void 0) {
|
|
38
|
+
throw new TypeError(`definePrompt(${at}): suggestions and hints belong to a studio prompt`);
|
|
39
|
+
}
|
|
40
|
+
for (const key of ["label", "heading", "description", "permission", "system", "instructions"]) optionalText(definition[key], key, at);
|
|
41
|
+
if (typeof definition.build !== "function") throw new TypeError(`definePrompt(${at}): build({ input, context, scope, instructions }) is required`);
|
|
42
|
+
if (typeof definition.output !== "function") checkSchema(definition.output, `${name}.output`);
|
|
43
|
+
if (definition.input !== void 0) {
|
|
44
|
+
checkSchema(definition.input, `${name}.input`);
|
|
45
|
+
const nested = Object.entries(definition.input.properties).filter(([, node]) => [node.type].flat().some((type) => type === "array" || type === "object")).map(([key]) => key);
|
|
46
|
+
if (nested.length > 0) throw new TypeError(`definePrompt(${at}): input takes texts, numbers and booleans only (${nested.join(", ")})`);
|
|
47
|
+
}
|
|
48
|
+
const { maxTokens } = definition;
|
|
49
|
+
if (maxTokens !== void 0 && !(Number.isInteger(maxTokens) && maxTokens > 0 && maxTokens <= MAX_TOKENS_LIMIT)) {
|
|
50
|
+
throw new TypeError(`definePrompt(${at}): maxTokens must be an integer from 1 to ${MAX_TOKENS_LIMIT}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function definePrompt(definition, { source = PROJECT_SOURCE } = {}) {
|
|
54
|
+
checkDefinition(definition);
|
|
55
|
+
const { prompts } = state();
|
|
56
|
+
const existing = prompts.get(definition.name);
|
|
57
|
+
if (existing && existing.source !== source) {
|
|
58
|
+
throw new TypeError(`definePrompt: ${JSON.stringify(definition.name)} is already declared by ${existing.source}`);
|
|
59
|
+
}
|
|
60
|
+
const studio = definition.mode === "studio";
|
|
61
|
+
prompts.set(definition.name, Object.freeze({
|
|
62
|
+
mode: "fill",
|
|
63
|
+
maxTokens: DEFAULT_MAX_TOKENS,
|
|
64
|
+
...definition,
|
|
65
|
+
...studio ? { input: STUDIO_INPUT, suggestions: checkSuggestions(definition.suggestions, definition.name), hints: checkHints(definition.hints, definition.name) } : {},
|
|
66
|
+
source
|
|
67
|
+
}));
|
|
68
|
+
return definition.name;
|
|
69
|
+
}
|
|
70
|
+
const CUSTOMIZABLE = /* @__PURE__ */ new Set(["system", "instructions", "label", "heading", "description", "enabled"]);
|
|
71
|
+
function customizePrompt(name, changes) {
|
|
72
|
+
if (typeof name !== "string" || !NAME_PATTERN.test(name)) throw new TypeError(`customizePrompt: unknown prompt name ${JSON.stringify(name)}`);
|
|
73
|
+
if (!changes || typeof changes !== "object") throw new TypeError(`customizePrompt(${JSON.stringify(name)}): the changes must be an object`);
|
|
74
|
+
for (const [key, value] of Object.entries(changes)) {
|
|
75
|
+
if (!CUSTOMIZABLE.has(key)) {
|
|
76
|
+
throw new TypeError(`customizePrompt(${JSON.stringify(name)}): ${key} cannot be changed; declare a prompt of the project for another shape`);
|
|
77
|
+
}
|
|
78
|
+
const ok = key === "enabled" ? typeof value === "boolean" : key === "system" || key === "instructions" ? typeof value === "string" || typeof value === "function" : typeof value === "string";
|
|
79
|
+
if (!ok) throw new TypeError(`customizePrompt(${JSON.stringify(name)}): ${key} has the wrong type`);
|
|
80
|
+
}
|
|
81
|
+
const { customizations } = state();
|
|
82
|
+
customizations.set(name, { ...customizations.get(name), ...changes });
|
|
83
|
+
}
|
|
84
|
+
function configureAi({ instructions } = {}) {
|
|
85
|
+
if (instructions !== void 0 && instructions !== null && typeof instructions !== "string") {
|
|
86
|
+
throw new TypeError("configureAi: instructions must be a string");
|
|
87
|
+
}
|
|
88
|
+
state().global.instructions = isText(instructions) ? instructions.trim() : null;
|
|
89
|
+
}
|
|
90
|
+
function applyText(base, change) {
|
|
91
|
+
if (typeof change === "function") {
|
|
92
|
+
const value = change(base ?? "");
|
|
93
|
+
return typeof value === "string" ? value : base;
|
|
94
|
+
}
|
|
95
|
+
return typeof change === "string" ? change : base;
|
|
96
|
+
}
|
|
97
|
+
function resolvePrompt(name) {
|
|
98
|
+
const { prompts, customizations, global } = state();
|
|
99
|
+
const definition = prompts.get(name);
|
|
100
|
+
if (!definition) return null;
|
|
101
|
+
const custom = customizations.get(name) ?? {};
|
|
102
|
+
if (custom.enabled === false) return null;
|
|
103
|
+
const system = [applyText(definition.system, custom.system), global.instructions].filter(isText).join("\n\n");
|
|
104
|
+
return {
|
|
105
|
+
...definition,
|
|
106
|
+
label: custom.label ?? definition.label,
|
|
107
|
+
heading: custom.heading ?? definition.heading,
|
|
108
|
+
description: custom.description ?? definition.description,
|
|
109
|
+
system: system || void 0,
|
|
110
|
+
instructions: applyText(definition.instructions, custom.instructions) ?? "",
|
|
111
|
+
customized: Object.keys(custom).length > 0
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function promptsForTarget(target) {
|
|
115
|
+
return [...state().prompts.keys()].map(resolvePrompt).filter((prompt) => prompt && prompt.target === target);
|
|
116
|
+
}
|
|
117
|
+
function listPrompts() {
|
|
118
|
+
const { prompts, customizations } = state();
|
|
119
|
+
return [...prompts.values()].map((definition) => {
|
|
120
|
+
var _a, _b;
|
|
121
|
+
return {
|
|
122
|
+
name: definition.name,
|
|
123
|
+
label: ((_a = customizations.get(definition.name)) == null ? void 0 : _a.label) ?? definition.label ?? null,
|
|
124
|
+
target: definition.target ?? null,
|
|
125
|
+
mode: definition.mode,
|
|
126
|
+
source: definition.source,
|
|
127
|
+
customized: customizations.has(definition.name),
|
|
128
|
+
enabled: ((_b = customizations.get(definition.name)) == null ? void 0 : _b.enabled) !== false
|
|
129
|
+
};
|
|
130
|
+
}).sort((a, b) => a.name.localeCompare(b.name));
|
|
131
|
+
}
|
|
132
|
+
function listUnmatchedCustomizations() {
|
|
133
|
+
const { prompts, customizations } = state();
|
|
134
|
+
return [...customizations.keys()].filter((name) => !prompts.has(name)).sort();
|
|
135
|
+
}
|
|
136
|
+
function clearPrompts() {
|
|
137
|
+
delete globalThis[STATE_KEY];
|
|
138
|
+
}
|
|
139
|
+
export {
|
|
140
|
+
PROJECT_SOURCE,
|
|
141
|
+
clearPrompts,
|
|
142
|
+
configureAi,
|
|
143
|
+
customizePrompt,
|
|
144
|
+
definePrompt,
|
|
145
|
+
listPrompts,
|
|
146
|
+
listUnmatchedCustomizations,
|
|
147
|
+
promptsForTarget,
|
|
148
|
+
resolvePrompt
|
|
149
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { emptyAnswer, postJson, refusedAnswer, tokens, truncatedAnswer } from "./http.server.js";
|
|
2
|
+
const API_VERSION = "2026-10-21";
|
|
3
|
+
function contentOf(parts) {
|
|
4
|
+
return parts.map((part) => part.type === "image" ? { type: "image", source: { type: "base64", media_type: part.mimeType, data: part.data } } : { type: "text", text: part.text });
|
|
5
|
+
}
|
|
6
|
+
function anthropicRequest({ model, system, messages, schema, maxTokens }) {
|
|
7
|
+
return {
|
|
8
|
+
model,
|
|
9
|
+
max_tokens: maxTokens,
|
|
10
|
+
...system ? { system } : {},
|
|
11
|
+
messages: messages.map((message) => ({ role: message.role, content: contentOf(message.content) })),
|
|
12
|
+
output_config: { format: { type: "json_schema", schema } }
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
function anthropicAnswer(body) {
|
|
16
|
+
var _a, _b, _c, _d;
|
|
17
|
+
if (body.stop_reason === "refusal") throw refusedAnswer(((_a = body.stop_details) == null ? void 0 : _a.explanation) ?? null);
|
|
18
|
+
if (body.stop_reason === "max_tokens") throw truncatedAnswer();
|
|
19
|
+
const text = (_b = (body.content ?? []).find((block) => block.type === "text")) == null ? void 0 : _b.text;
|
|
20
|
+
if (typeof text !== "string" || text.trim() === "") throw emptyAnswer();
|
|
21
|
+
return {
|
|
22
|
+
text,
|
|
23
|
+
model: typeof body.model === "string" ? body.model : null,
|
|
24
|
+
usage: {
|
|
25
|
+
inputTokens: tokens((_c = body.usage) == null ? void 0 : _c.input_tokens),
|
|
26
|
+
outputTokens: tokens((_d = body.usage) == null ? void 0 : _d.output_tokens)
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
async function generate(config, request, { signal } = {}) {
|
|
31
|
+
const body = await postJson(`${config.baseUrl}/v1/messages`, anthropicRequest({ ...request, model: config.model }), {
|
|
32
|
+
headers: { "x-api-key": config.apiKey, "anthropic-version": API_VERSION },
|
|
33
|
+
timeoutMs: config.timeoutMs,
|
|
34
|
+
signal
|
|
35
|
+
});
|
|
36
|
+
return anthropicAnswer(body);
|
|
37
|
+
}
|
|
38
|
+
export {
|
|
39
|
+
anthropicAnswer,
|
|
40
|
+
anthropicRequest,
|
|
41
|
+
generate
|
|
42
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { emptyAnswer, postJson, refusedAnswer, tokens, truncatedAnswer } from "./http.server.js";
|
|
2
|
+
const DIALECTS = Object.freeze({
|
|
3
|
+
// OpenAI's reasoning models refuse `max_tokens`: they take
|
|
4
|
+
// `max_completion_tokens`, which its other models accept too.
|
|
5
|
+
openai: { imageAsObject: true, strict: true, maxTokensKey: "max_completion_tokens" },
|
|
6
|
+
mistral: { imageAsObject: false, strict: true, maxTokensKey: "max_tokens" },
|
|
7
|
+
"openai-compatible": { imageAsObject: true, strict: false, maxTokensKey: "max_tokens" }
|
|
8
|
+
});
|
|
9
|
+
function imagePart(part, dialect) {
|
|
10
|
+
const url = `data:${part.mimeType};base64,${part.data}`;
|
|
11
|
+
return { type: "image_url", image_url: dialect.imageAsObject ? { url } : url };
|
|
12
|
+
}
|
|
13
|
+
function contentOf(parts, dialect) {
|
|
14
|
+
if (parts.every((part) => part.type === "text")) return parts.map((part) => part.text).join("\n\n");
|
|
15
|
+
return parts.map((part) => part.type === "image" ? imagePart(part, dialect) : { type: "text", text: part.text });
|
|
16
|
+
}
|
|
17
|
+
function chatCompletionsRequest(provider, { model, system, messages, schema, maxTokens }) {
|
|
18
|
+
const dialect = DIALECTS[provider];
|
|
19
|
+
return {
|
|
20
|
+
model,
|
|
21
|
+
[dialect.maxTokensKey]: maxTokens,
|
|
22
|
+
messages: [
|
|
23
|
+
...system ? [{ role: "system", content: system }] : [],
|
|
24
|
+
...messages.map((message) => ({ role: message.role, content: contentOf(message.content, dialect) }))
|
|
25
|
+
],
|
|
26
|
+
response_format: {
|
|
27
|
+
type: "json_schema",
|
|
28
|
+
json_schema: { name: "answer", schema, strict: dialect.strict }
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function chatCompletionsAnswer(body) {
|
|
33
|
+
var _a, _b, _c, _d, _e;
|
|
34
|
+
const choice = (_a = body.choices) == null ? void 0 : _a[0];
|
|
35
|
+
if (!choice) throw emptyAnswer();
|
|
36
|
+
if (typeof ((_b = choice.message) == null ? void 0 : _b.refusal) === "string" && choice.message.refusal) throw refusedAnswer(choice.message.refusal);
|
|
37
|
+
if (choice.finish_reason === "length") throw truncatedAnswer();
|
|
38
|
+
if (choice.finish_reason === "content_filter") throw refusedAnswer(null);
|
|
39
|
+
const text = (_c = choice.message) == null ? void 0 : _c.content;
|
|
40
|
+
if (typeof text !== "string" || text.trim() === "") throw emptyAnswer();
|
|
41
|
+
return {
|
|
42
|
+
text,
|
|
43
|
+
model: typeof body.model === "string" ? body.model : null,
|
|
44
|
+
usage: {
|
|
45
|
+
inputTokens: tokens((_d = body.usage) == null ? void 0 : _d.prompt_tokens),
|
|
46
|
+
outputTokens: tokens((_e = body.usage) == null ? void 0 : _e.completion_tokens)
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
async function generate(config, request, { signal } = {}) {
|
|
51
|
+
const body = await postJson(`${config.baseUrl}/chat/completions`, chatCompletionsRequest(config.provider, { ...request, model: config.model }), {
|
|
52
|
+
headers: config.apiKey ? { authorization: `Bearer ${config.apiKey}` } : {},
|
|
53
|
+
timeoutMs: config.timeoutMs,
|
|
54
|
+
signal
|
|
55
|
+
});
|
|
56
|
+
return chatCompletionsAnswer(body);
|
|
57
|
+
}
|
|
58
|
+
export {
|
|
59
|
+
DIALECTS,
|
|
60
|
+
chatCompletionsAnswer,
|
|
61
|
+
chatCompletionsRequest,
|
|
62
|
+
generate
|
|
63
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { emptyAnswer, postJson, refusedAnswer, tokens, truncatedAnswer } from "./http.server.js";
|
|
2
|
+
function partsOf(parts) {
|
|
3
|
+
return parts.map((part) => part.type === "image" ? { inlineData: { mimeType: part.mimeType, data: part.data } } : { text: part.text });
|
|
4
|
+
}
|
|
5
|
+
function googleRequest({ system, messages, schema, maxTokens }) {
|
|
6
|
+
return {
|
|
7
|
+
...system ? { systemInstruction: { parts: [{ text: system }] } } : {},
|
|
8
|
+
contents: messages.map((message) => ({
|
|
9
|
+
role: message.role === "assistant" ? "model" : "user",
|
|
10
|
+
parts: partsOf(message.content)
|
|
11
|
+
})),
|
|
12
|
+
generationConfig: {
|
|
13
|
+
responseMimeType: "application/json",
|
|
14
|
+
responseJsonSchema: schema,
|
|
15
|
+
maxOutputTokens: maxTokens
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function googleAnswer(body) {
|
|
20
|
+
var _a, _b, _c, _d, _e;
|
|
21
|
+
if ((_a = body.promptFeedback) == null ? void 0 : _a.blockReason) throw refusedAnswer(body.promptFeedback.blockReason);
|
|
22
|
+
const candidate = (_b = body.candidates) == null ? void 0 : _b[0];
|
|
23
|
+
if (!candidate) throw emptyAnswer();
|
|
24
|
+
if (candidate.finishReason === "MAX_TOKENS") throw truncatedAnswer();
|
|
25
|
+
if (["SAFETY", "RECITATION", "PROHIBITED_CONTENT", "BLOCKLIST", "SPII"].includes(candidate.finishReason)) {
|
|
26
|
+
throw refusedAnswer(candidate.finishReason);
|
|
27
|
+
}
|
|
28
|
+
const text = (((_c = candidate.content) == null ? void 0 : _c.parts) ?? []).filter((part) => typeof part.text === "string" && !part.thought).map((part) => part.text).join("");
|
|
29
|
+
if (text.trim() === "") throw emptyAnswer();
|
|
30
|
+
return {
|
|
31
|
+
text,
|
|
32
|
+
model: typeof body.modelVersion === "string" ? body.modelVersion : null,
|
|
33
|
+
usage: {
|
|
34
|
+
inputTokens: tokens((_d = body.usageMetadata) == null ? void 0 : _d.promptTokenCount),
|
|
35
|
+
outputTokens: tokens((_e = body.usageMetadata) == null ? void 0 : _e.candidatesTokenCount)
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
async function generate(config, request, { signal } = {}) {
|
|
40
|
+
const url = `${config.baseUrl}/models/${encodeURIComponent(config.model)}:generateContent`;
|
|
41
|
+
const body = await postJson(url, googleRequest(request), {
|
|
42
|
+
headers: { "x-goog-api-key": config.apiKey },
|
|
43
|
+
timeoutMs: config.timeoutMs,
|
|
44
|
+
signal
|
|
45
|
+
});
|
|
46
|
+
return googleAnswer(body);
|
|
47
|
+
}
|
|
48
|
+
export {
|
|
49
|
+
generate,
|
|
50
|
+
googleAnswer,
|
|
51
|
+
googleRequest
|
|
52
|
+
};
|