diff --git a/extensions/KimosFrontender/ai.js b/extensions/KimosFrontender/ai.js new file mode 100644 index 0000000000..c45d076b7a --- /dev/null +++ b/extensions/KimosFrontender/ai.js @@ -0,0 +1,1326 @@ +// Name: AI +// ID: ai +// Description: Chat with AI in Scratch. Supports OpenAI and Anthropic APIs with streaming responses. +// By: KimosFrontender +// License: MIT + +// @ts-nocheck +(function (Scratch) { + // This extension is compatible down to ES6 at minimum. + "use strict"; + var BLOCK_ICON_URI = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAACXBIWXMAAAsSAAALEgHS3X78AAADKUlEQVR4nO2b4XGcMBCFn9OAKeE68JVACddB6CBXgtPBdWCuA3cQ0gHu4NIBqeD5BzDmlF0hkNAyk9sZZjw6Cd5+aFcLyE8k8T/bN2sB1vYAYC3A2h4ArAVY2wOAtQBrswZwAXC0FGAJoAbwA0ADQwhWAM4Avg9/P6OHUFoIsQLg3vFnAL8AVLmFWAGoAFyF9jdkhmCZAyrsAMLWAAoAB8/vFYCfQvsbgNf0cgQjueVRk+xIHmf6VZSt3ljfpgDOE0d2C2GrE58ER3YJYYuTHgdnJesGJ33jS2V8Q7LYO4CC5E1xfmpzEDSIbWoIqQE0gmgNyC4gpHS+FsS+e37bBYRUzlcBItdCKIZzuRaSVLMAKBeIOwt9TSHEOn+gPEVLzxhptphBiHFeE3R2+kniYiA0CgQf9E0AvAtCasH5jnLSWgsBXJ9PkgG4CBdvnT4F78Oj5b+zwRzCGucl0Tfe32EtPC6B5yPJ1xwQljovrctSEgoJjxAIvjHj8aqMDQG4CIA7pUdzaWvhMVe4xEBolGsmBSBNaZdySHikhiDVFsGVYqjztXCRRuhX8n6WuOFxYB8ePnEShG4YG9o3uC6IAUDKyebIrweg06R9mhjn7tAUpOaQ9pywqChaEgIaBDcMRmc150drKd9VF6TkUGg+SgoA1Gv5embcQQBAritjlyyxyQGA67N1qlp+6RKbHIAPwlyC80E4ecaNx9olNjkAUH93Nyeo4LoKToJ+i3E+FgAY98ZmCYRS6LebFyI+CHMCNQjThKadPyRksgAA47J8pUCoqb9llmaJKQAwLstrEKQ7XyfUvMl3gbVZvlIcnlqTWO9mn8ZqxYFqZpwW7+QGH0W2BJAagvYwtGsAPghzZeuRX6GUZLmzAgDGl85zMybqeCKz/L9AhX7Xh2tXGGyMmlouAIAO4TeAE4Aul5Cp5QQA9NvjGvTb4qb2gX6fYHYIuXeJtegd/eu0v6AHU2TWY7JNboTwx2l/GX475BRjtU+wRR8OH057h8xhYLlRskM/E0YIJnnAerv8COEKoySYexXYnVnPAHN7ALAWYG0PANYCrO0BwFqAtX0CJHH8H1ebuYYAAAAASUVORK5CYII="; + var DATA_PROCESSING_URI = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAACXBIWXMAAAsSAAALEgHS3X78AAACTUlEQVR4nO2a4U3DMBBGH4gFwggwAoxQRggjlBHKCGWEdoQyQhmhjAAjNCMcP+IK54DWSXw5BH5SRVz5nM8fbmxffCYi/GfOvQV4UwzwFuBNMcBbgDfFAG8B3hQDvAV4M6UBN8AC2AMSrg/Mw3eH72dTibqY6D4r2k6msAx/n4F7GzmfTDECNqR3PqYOsbaIiOVnJl9ZHqm//Kb+zFKjtQEr1ZlFQsxCxWwsNZ6J7Xb4DbiKypdAkxC3B6pw3YQ4E6yfAXHnX0nrPMBLdF2pdrJiaYAWndr5vnVHURZC3gK8KQZ4C/CmGOAtwJtiwIT3GjO3V6erDOO3LoTeT7SVDUsDblRZd+oYuq5uKxtWm6GKdiMUD91r0k3Q8Q1w2yM+GYsRUANbup1f0098AzxF5Sq0WY9Wp8m8v36Tr2wz5hMk3CObZutZYA3cjYh/oDsSsmNtwBzYMXwa29LNHufHIM00E5GdGrZD0lo6P7gTkTq3XstZYEd3/r6lzQqlxu+jckM7i2RPlFj9BBra339Mn5cduu4aoyyR5TPgRZX7rOZ03dSR0xtLA/R/rM+DUBuQfQF0YMrN0JgNjVmStGyHvQV4UwzwFuBNMcBbgDfFAMO29eKlzzrALAmqsT4fMPQ9/585H6Df86ecFarpjgC9p8jKFBmhmBXHExyLUCfG1ACzszfRZyPfE58Xmv9Qx/R8kEyQE4T2rN/zgLg/c04Q2o7cAY+c3tk9hrrmnQf7WeDXUxZC3gK8KQZ4C/CmGOAtwJtigLcAb4oB3gK8+QAub0WlJ923VAAAAABJRU5ErkJggg=="; + + class AI { + constructor() { + /** + * @type {Map} + */ + this.chats = new Map(); + /** + * @type {Map} + */ + this.presets = new Map(); + } + getInfo() { + return { + id: "ai", + color1: "#3a3a3a", + color2: "#444444", + color3: "#777777", + name: Scratch.translate("AI"), + blocks: [ + { + opcode: "isInternetConnected", + blockIconURI: BLOCK_ICON_URI, + text: Scratch.translate("Internet connected?"), + blockType: Scratch.BlockType.BOOLEAN, + arguments: {}, + }, + { + opcode: "createNewChat", + blockIconURI: BLOCK_ICON_URI, + text: Scratch.translate("Create a new chat with id[ID]"), + blockType: Scratch.BlockType.COMMAND, + arguments: { + ID: { + type: Scratch.ArgumentType.STRING, + defaultValue: Scratch.translate("chat1"), + }, + }, + }, + { + opcode: "deleteChat", + blockIconURI: BLOCK_ICON_URI, + text: Scratch.translate("Delete chat with id[ID]"), + blockType: Scratch.BlockType.COMMAND, + arguments: { + ID: { + type: Scratch.ArgumentType.STRING, + defaultValue: Scratch.translate("chat1"), + }, + }, + }, + { + opcode: "clearAllChats", + blockIconURI: BLOCK_ICON_URI, + text: Scratch.translate("Clear all chats"), + blockType: Scratch.BlockType.COMMAND, + }, + { + opcode: "createNewAIPreset", + blockIconURI: BLOCK_ICON_URI, + text: Scratch.translate("Create a new AI preset with id [ID]"), + blockType: Scratch.BlockType.COMMAND, + arguments: { + ID: { + type: Scratch.ArgumentType.STRING, + defaultValue: Scratch.translate("preset1"), + }, + }, + }, + { + opcode: "deletePreset", + blockIconURI: BLOCK_ICON_URI, + text: Scratch.translate("Delete AI preset with id [ID]"), + blockType: Scratch.BlockType.COMMAND, + arguments: { + ID: { + type: Scratch.ArgumentType.STRING, + defaultValue: Scratch.translate("preset1"), + }, + }, + }, + { + opcode: "clearAllPresets", + blockIconURI: BLOCK_ICON_URI, + text: Scratch.translate("Clear all AI presets"), + blockType: Scratch.BlockType.COMMAND, + }, + { + opcode: "setAiPresetProp", + blockIconURI: BLOCK_ICON_URI, + text: Scratch.translate( + "Set the [PROP] of AI preset [ID] to [VALUE]" + ), + blockType: Scratch.BlockType.COMMAND, + arguments: { + ID: { + type: Scratch.ArgumentType.STRING, + defaultValue: Scratch.translate("preset1"), + }, + PROP: { + type: Scratch.ArgumentType.STRING, + menu: "AI_PRESET_PROP", + defaultValue: "apikey", + }, + VALUE: { + type: Scratch.ArgumentType.STRING, + defaultValue: "sk-xxxx", + }, + }, + }, + { + opcode: "assignAiPresetToChat", + blockIconURI: BLOCK_ICON_URI, + text: Scratch.translate( + "Use AI preset [PRESET_ID] for chat [CHAT_ID]" + ), + blockType: Scratch.BlockType.COMMAND, + arguments: { + PRESET_ID: { + type: Scratch.ArgumentType.STRING, + defaultValue: Scratch.translate("preset1"), + }, + CHAT_ID: { + type: Scratch.ArgumentType.STRING, + defaultValue: Scratch.translate("chat1"), + }, + }, + }, + { + opcode: "setRequestFormatOfPreset", + blockIconURI: BLOCK_ICON_URI, + text: Scratch.translate( + "Set request format for preset with id[ID] to [FORMAT]" + ), + blockType: Scratch.BlockType.COMMAND, + arguments: { + ID: { + type: Scratch.ArgumentType.STRING, + defaultValue: Scratch.translate("preset1"), + }, + FORMAT: { + type: Scratch.ArgumentType.STRING, + menu: "REQUEST_FORMATS", + defaultValue: "openai", + }, + }, + }, + { + opcode: "sendMessage", + blockIconURI: BLOCK_ICON_URI, + text: Scratch.translate( + "Send message [MESSAGE] to chat with id[ID]" + ), + blockType: Scratch.BlockType.COMMAND, + arguments: { + MESSAGE: { + type: Scratch.ArgumentType.STRING, + defaultValue: Scratch.translate("Hello"), + }, + ID: { + type: Scratch.ArgumentType.STRING, + defaultValue: Scratch.translate("chat1"), + }, + }, + }, + { + opcode: "stopMessage", + blockIconURI: BLOCK_ICON_URI, + text: Scratch.translate("Stop message from chat [ID]"), + blockType: Scratch.BlockType.COMMAND, + arguments: { + ID: { + type: Scratch.ArgumentType.STRING, + defaultValue: Scratch.translate("chat1"), + }, + }, + }, + { + opcode: "response", + blockIconURI: BLOCK_ICON_URI, + text: Scratch.translate("Response from chat [ID]"), + blockType: Scratch.BlockType.REPORTER, + arguments: { + ID: { + type: Scratch.ArgumentType.STRING, + defaultValue: Scratch.translate("chat1"), + }, + }, + }, + { + opcode: "status", + blockIconURI: BLOCK_ICON_URI, + text: Scratch.translate("Status of chat with id[ID]"), + blockType: Scratch.BlockType.REPORTER, + arguments: { + ID: { + type: Scratch.ArgumentType.STRING, + defaultValue: Scratch.translate("chat1"), + }, + }, + }, + { + opcode: "isIdle", + blockIconURI: BLOCK_ICON_URI, + text: Scratch.translate("Is chat with id[ID] idle?"), + blockType: Scratch.BlockType.BOOLEAN, + arguments: { + ID: { + type: Scratch.ArgumentType.STRING, + defaultValue: Scratch.translate("chat1"), + }, + }, + }, + { + blockType: Scratch.BlockType.LABEL, + text: Scratch.translate("Common AI"), + }, + { + opcode: "anthropicUrl", + blockIconURI: BLOCK_ICON_URI, + text: Scratch.translate("URL of [AI](Anthropic)"), + blockType: Scratch.BlockType.REPORTER, + arguments: { + AI: { + type: Scratch.ArgumentType.STRING, + menu: "COMMON_AI_ANTHROPIC_URLS", + defaultValue: "https://api.deepseek.com/anthropic", + }, + }, + }, + { + opcode: "openaiUrl", + blockIconURI: BLOCK_ICON_URI, + text: Scratch.translate("URL of [AI](OpenAI)"), + blockType: Scratch.BlockType.REPORTER, + arguments: { + AI: { + type: Scratch.ArgumentType.STRING, + menu: "COMMON_AI_OPENAI_URLS", + defaultValue: "https://api.deepseek.com", + }, + }, + }, + + { + blockType: Scratch.BlockType.LABEL, + text: Scratch.translate("Data processing(JSON)"), + }, + { + opcode: "setDataOfMap", + blockIconURI: DATA_PROCESSING_URI, + text: Scratch.translate( + "Overwrite id[ID] in [MAP] with JSON [DATA]" + ), + blockType: Scratch.BlockType.COMMAND, + arguments: { + ID: { + type: Scratch.ArgumentType.STRING, + defaultValue: "chat1", + }, + MAP: { + type: Scratch.ArgumentType.STRING, + menu: "MAP", + defaultValue: "chats", + }, + DATA: { + type: Scratch.ArgumentType.STRING, + defaultValue: "{}", + }, + }, + }, + { + opcode: "setDataOfChatHistory", + blockIconURI: DATA_PROCESSING_URI, + text: Scratch.translate( + "Overwrite chat history of id[ID] with JSON [DATA]" + ), + blockType: Scratch.BlockType.COMMAND, + arguments: { + ID: { + type: Scratch.ArgumentType.STRING, + defaultValue: "chat1", + }, + DATA: { + type: Scratch.ArgumentType.STRING, + defaultValue: "{}", + }, + }, + }, + { + opcode: "clearChatHistory", + blockIconURI: DATA_PROCESSING_URI, + text: Scratch.translate("Clear chat history of id[ID]"), + blockType: Scratch.BlockType.COMMAND, + arguments: { + ID: { + type: Scratch.ArgumentType.STRING, + defaultValue: "chat1", + }, + }, + }, + { + opcode: "addMessageOfChatHistory", + blockIconURI: DATA_PROCESSING_URI, + text: Scratch.translate( + "Add a message to chat [ID] with role[ROLE] and content[CONTENT]" + ), + blockType: Scratch.BlockType.COMMAND, + arguments: { + ID: { + type: Scratch.ArgumentType.STRING, + defaultValue: "chat1", + }, + ROLE: { + type: Scratch.ArgumentType.STRING, + menu: "CHAT_ROLE", + defaultValue: "user", + }, + CONTENT: { + type: Scratch.ArgumentType.STRING, + defaultValue: Scratch.translate("How do you do?"), + }, + }, + }, + { + opcode: "deleteItemOfChatHistory", + blockIconURI: DATA_PROCESSING_URI, + text: Scratch.translate( + "Delete message at index [INDEX] from chat with id[ID]" + ), + blockType: Scratch.BlockType.COMMAND, + arguments: { + INDEX: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1, + }, + ID: { + type: Scratch.ArgumentType.STRING, + defaultValue: "chat1", + }, + }, + }, + { + opcode: "coverObject", + blockIconURI: DATA_PROCESSING_URI, + text: Scratch.translate("Cover [DIFF] to [BASE]"), + blockType: Scratch.BlockType.REPORTER, + arguments: { + DIFF: { + type: Scratch.ArgumentType.STRING, + defaultValue: '{"key1": 1}', + }, + BASE: { + type: Scratch.ArgumentType.STRING, + defaultValue: "{}", + }, + }, + }, + { + opcode: "setObjectProp", + blockIconURI: DATA_PROCESSING_URI, + text: Scratch.translate("Set [OBJECT]'s [KEY] to [VALUE]"), + blockType: Scratch.BlockType.COMMAND, + arguments: { + OBJECT: { + type: Scratch.ArgumentType.STRING, + defaultValue: "{}", + }, + KEY: { + type: Scratch.ArgumentType.STRING, + defaultValue: "key1", + }, + VALUE: { + type: Scratch.ArgumentType.STRING, + defaultValue: '"value1"', + }, + }, + }, + { + opcode: "allKeysOfMap", + blockIconURI: DATA_PROCESSING_URI, + text: Scratch.translate("All keys of [MAP]"), + blockType: Scratch.BlockType.REPORTER, + arguments: { + MAP: { + type: Scratch.ArgumentType.STRING, + menu: "MAP", + defaultValue: "chats", + }, + }, + }, + { + opcode: "dataOfMap", + blockIconURI: DATA_PROCESSING_URI, + text: Scratch.translate("Data of [ID] from [MAP]"), + blockType: Scratch.BlockType.REPORTER, + arguments: { + ID: { + type: Scratch.ArgumentType.STRING, + defaultValue: Scratch.translate("chat1"), + }, + MAP: { + type: Scratch.ArgumentType.STRING, + menu: "MAP", + defaultValue: "chats", + }, + }, + }, + { + opcode: "chatHistory", + blockIconURI: DATA_PROCESSING_URI, + text: Scratch.translate("Chat history from id[ID]"), + blockType: Scratch.BlockType.REPORTER, + arguments: { + ID: { + type: Scratch.ArgumentType.STRING, + defaultValue: Scratch.translate("chat1"), + }, + }, + }, + { + opcode: "propOfChatHistory", + blockIconURI: DATA_PROCESSING_URI, + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate( + "[PROP] of item [INDEX] of chat history id[ID]" + ), + arguments: { + PROP: { + type: Scratch.ArgumentType.STRING, + menu: "CHAT_HISTORY_PROP", + defaultValue: "content", + }, + INDEX: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1, + }, + ID: { + type: Scratch.ArgumentType.STRING, + defaultValue: Scratch.translate("chat1"), + }, + }, + }, + { + opcode: "lengthOfArray", + blockIconURI: DATA_PROCESSING_URI, + text: Scratch.translate("Length of [ARRAY]"), + blockType: Scratch.BlockType.REPORTER, + arguments: { + ARRAY: { + type: Scratch.ArgumentType.STRING, + defaultValue: "[]", + }, + }, + }, + { + opcode: "itemOfArray", + blockIconURI: DATA_PROCESSING_URI, + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("Item [INDEX] of array[ARRAY]"), + arguments: { + INDEX: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1, + }, + ARRAY: { + type: Scratch.ArgumentType.STRING, + defaultValue: "[]", + }, + }, + }, + { + opcode: "itemOfObject", + blockIconURI: DATA_PROCESSING_URI, + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("Item [KEY] of object[OBJECT]"), + arguments: { + KEY: { + type: Scratch.ArgumentType.STRING, + defaultValue: Scratch.translate("key1"), + }, + OBJECT: { + type: Scratch.ArgumentType.STRING, + defaultValue: "{}", + }, + }, + }, + ], + menus: { + MAP: { + acceptReporters: true, + items: [ + { + text: Scratch.translate("Chats"), + value: "chats", + }, + { + text: Scratch.translate("AI presets"), + value: "aipresets", + }, + ], + }, + AI_PRESET_PROP: { + acceptReporters: true, + items: [ + { + text: Scratch.translate("API Key"), + value: "apikey", + }, + { + text: Scratch.translate("Request URL"), + value: "requesturl", + }, + { + text: Scratch.translate("Model"), + value: "model", + }, + ], + }, + REQUEST_FORMATS: { + acceptReporters: false, + items: [ + { + text: "OpenAI", + value: "openai", + }, + { + text: "Anthropic", + value: "anthropic", + }, + ], + }, + COMMON_AI_OPENAI_URLS: { + acceptReporters: false, + items: [ + { + text: "OpenAI", + value: "https://api.openai.com/v1", + }, + { + text: "DeepSeek", + value: "https://api.deepseek.com", + }, + { + text: "Qwen", + value: "https://dashscope.aliyuncs.com/compatible-mode/v1", + }, + { + text: "Kimi", + value: "https://api.moonshot.cn/v1", + }, + { + text: "GLM", + value: "https://open.bigmodel.cn/api/paas/v4", + }, + { + text: "Baichuan", + value: "https://api.baichuan-ai.com/v1", + }, + { + text: "Doubao", + value: "https://ark.cn-beijing.volces.com/api/v3", + }, + { + text: "MiniMax", + value: "https://api.minimax.chat/v1", + }, + { + text: "Stepfun", + value: "https://api.stepfun.com/v1", + }, + { + text: "Hunyuan", + value: "https://api.hunyuan.cloud.tencent.com/v1", + }, + { + text: "Spark", + value: "https://spark-api-open.xf-yun.com/v1", + }, + { + text: "Gemini", + value: + "https://generativelanguage.googleapis.com/v1beta/openai", + }, + { + text: "Groq", + value: "https://api.groq.com/openai/v1", + }, + { + text: "Mistral", + value: "https://api.mistral.ai/v1", + }, + { + text: "Cohere", + value: "https://api.cohere.ai/compatibility/v1", + }, + { + text: "Grok", + value: "https://api.x.ai/v1", + }, + { + text: "OpenRouter", + value: "https://openrouter.ai/api/v1", + }, + { + text: "Together AI", + value: "https://api.together.xyz/v1", + }, + { + text: "Fireworks AI", + value: "https://api.fireworks.ai/inference/v1", + }, + { + text: "Perplexity", + value: "https://api.perplexity.ai", + }, + { + text: "Novita AI", + value: "https://api.novita.ai/v3/openai", + }, + { + text: "SiliconFlow", + value: "https://api.siliconflow.cn/v1", + }, + { + text: "Vercel AI Gateway", + value: "https://ai-gateway.vercel.sh/v1", + }, + ], + }, + + COMMON_AI_ANTHROPIC_URLS: { + acceptReporters: false, + items: [ + { + text: "DeepSeek", + value: "https://api.deepseek.com/anthropic", + }, + { + text: "Grok", + value: "https://api.x.ai", + }, + { + text: "GLM ", + value: "https://open.bigmodel.cn/api/anthropic", + }, + { + text: "Qwen", + value: "https://dashscope.aliyuncs.com/apps/anthropic", + }, + { + text: "Mimo", + value: "https://token-plan-cn.xiaomimimo.com", + }, + { + text: "Gemini", + value: + "https://generativelanguage.googleapis.com/v1beta/openai", + }, + { + text: "MiniMax", + value: "https://api.minimaxi.com/anthropic", + }, + { + text: "Seed Code", + value: "https://ark.cn-beijing.volces.com/api/compatible", + }, + { + text: "Kimi", + value: "https://api.moonshot.cn", + }, + { + text: "Claude", + value: "https://api.anthropic.com", + }, + { + text: "OpenRouter", + value: "https://openrouter.ai/api", + }, + { + text: "OfoxAI", + value: "https://api.ofox.io/anthropic", + }, + { + text: "Vercel", + value: "https://ai-gateway.vercel.sh", + }, + { + text: "Cloudflare AI", + value: + "https://gateway.ai.cloudflare.com/v1/YOUR_ACCOUNT_ID/YOUR_GATEWAY_ID/anthropic", + }, + ], + }, + + COMMON_MODELS: { + acceptReporters: true, + items: [], + }, + CHAT_HISTORY_PROP: { + acceptReporters: true, + items: [ + { + text: Scratch.translate("role"), + value: "role", + }, + { + text: Scratch.translate("content"), + value: "content", + }, + ], + }, + CHAT_ROLE: { + acceptReporters: false, + items: [ + { + text: Scratch.translate("user"), + value: "user", + }, + { + text: Scratch.translate("assistant"), + value: "assistant", + }, + ], + }, + }, + }; + } + /** + * @param {string} url + * @param {"anthropic"|"openai"} format + * @returns {string} + */ + _padUrl(url, format = "anthropic") { + if (typeof url !== "string") return ""; + var result = url; + while (result.endsWith("/")) { + result = result.slice(0, -1); + } + switch (format) { + case "anthropic": + if (result.endsWith("/v1")) { + result += "/messages"; + } else if (!result.endsWith("/v1/messages")) { + result += "/v1/messages"; + } + break; + case "openai": + // OpenAI-compatible APIs expect the path to end with /v1/chat/completions + if (!result.endsWith("/v1/chat/completions")) { + if (result.endsWith("/v1")) { + result += "/chat/completions"; + } else { + result += "/v1/chat/completions"; + } + } + break; + default: + break; + } + return result; + } + _sendAnthropic(chat, requesturl, headers, body) { + function fetchHandle(response) { + chat.messages.push({ + role: "assistant", + content: "", + }); + if (!response.ok) { + chat.messages[chat.messages.length - 1].content = + "Request failed:" + response.status; + chat.status = "idle"; + return; + } + var reader = response.body.getReader(); + var decoder = new TextDecoder(); + var buffer = ""; + function readHandle(result) { + if (result.done) { + chat.messages[chat.messages.length - 1].content = chat.response; + chat.status = "idle"; + chat.response = null; + return; + } + buffer += decoder.decode(result.value, { stream: true }); + var lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (!line.startsWith("data: ")) continue; + var jsonStr = line.slice(6); + + try { + var data = JSON.parse(jsonStr); + } catch (e) { + continue; + } + + switch (data.type) { + case "content_block_start": + break; + case "content_block_delta": + if (data.delta && data.delta.text) { + chat.response += data.delta.text; + chat.messages[chat.messages.length - 1].content = + chat.response; + } + break; + case "content_block_stop": + break; + case "message_delta": + break; + case "message_stop": + break; + case "error": + chat.messages[chat.messages.length - 1].content += "[error]"; + chat.status = "idle"; + chat.response = null; + break; + } + } + readStream(); + } + function readError(error) { + if (error.name !== "AbortError") { + chat.messages[chat.messages.length - 1].content += "[break]"; + } else { + chat.messages[chat.messages.length - 1].content = chat.response; + } + chat.status = "idle"; + chat.response = null; + } + function readStream() { + reader.read().then(readHandle).catch(readError); + } + readStream(); + } + function fetchError(error) { + if (error.name === "AbortError") { + chat.messages.push({ role: "assistant", content: chat.response }); + } else { + chat.messages.push({ + role: "assistant", + content: "Request failed: " + error.message, + }); + } + chat.status = "idle"; + chat.response = null; + } + + Scratch.fetch(requesturl, { + method: "POST", + headers: headers, + body: JSON.stringify(body), + signal: chat.controller.signal, + }) + .then(fetchHandle) + .catch(fetchError); + } + // Send a streaming request using the OpenAI-compatible chat completions format. + // The structure mirrors _sendAnthropic above: fetch -> read SSE stream -> accumulate deltas. + _sendOpenai(chat, requesturl, headers, body) { + function fetchHandle(response) { + chat.messages.push({ + role: "assistant", + content: "", + }); + if (!response.ok) { + chat.messages[chat.messages.length - 1].content = + "Request failed:" + response.status; + chat.status = "idle"; + return; + } + var reader = response.body.getReader(); + var decoder = new TextDecoder(); + var buffer = ""; + function readHandle(result) { + if (result.done) { + chat.messages[chat.messages.length - 1].content = chat.response; + chat.status = "idle"; + chat.response = null; + return; + } + buffer += decoder.decode(result.value, { stream: true }); + var lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + // Each SSE event starts with "data: " followed by JSON + if (!line.startsWith("data: ")) continue; + var jsonStr = line.slice(6); + + // "[DONE]" marker signals the end of the stream + if (jsonStr.trim() === "[DONE]") { + // Stream finished normally — final content is already in chat.response + continue; + } + + try { + var data = JSON.parse(jsonStr); + } catch (e) { + continue; + } + + // OpenAI-style SSE: data.choices[0].delta.content + if ( + data.choices && + data.choices[0] && + data.choices[0].delta && + typeof data.choices[0].delta.content === "string" + ) { + chat.response += data.choices[0].delta.content; + chat.messages[chat.messages.length - 1].content = chat.response; + } + } + readStream(); + } + function readError(error) { + if (error.name !== "AbortError") { + chat.messages[chat.messages.length - 1].content += "[break]"; + } else { + chat.messages[chat.messages.length - 1].content = chat.response; + } + chat.status = "idle"; + chat.response = null; + } + function readStream() { + reader.read().then(readHandle).catch(readError); + } + readStream(); + } + function fetchError(error) { + if (error.name === "AbortError") { + chat.messages.push({ role: "assistant", content: chat.response }); + } else { + chat.messages.push({ + role: "assistant", + content: "Request failed: " + error.message, + }); + } + chat.status = "idle"; + chat.response = null; + } + + Scratch.fetch(requesturl, { + method: "POST", + headers: headers, + body: JSON.stringify(body), + signal: chat.controller.signal, + }) + .then(fetchHandle) + .catch(fetchError); + } + isInternetConnected() { + return navigator.onLine || false; + } + deletePreset(args) { + var ID = Scratch.Cast.toString(args.ID); + if (!this.presets.has(ID)) return; + this.presets.delete(ID); + } + clearAllPresets() { + this.presets.clear(); + } + clearAllChats() { + this.chats.clear(); + } + deleteChat(args) { + var ID = Scratch.Cast.toString(args.ID); + if (!this.chats.has(ID)) return; + this.chats.delete(ID); + } + setRequestFormatOfPreset(args) { + var ID = Scratch.Cast.toString(args.ID); + var FORMAT = Scratch.Cast.toString(args.FORMAT); + if (!this.presets.has(ID)) return; + this.presets.get(ID).requestformat = FORMAT; + } + createNewChat(args) { + var ID = Scratch.Cast.toString(args.ID); + if (this.chats.has(ID)) return; + this.chats.set(ID, { + id: ID, + aiPresetId: "", + messages: [], + status: "idle", + controller: null, + response: null, + }); + } + status(args) { + const ID = Scratch.Cast.toString(args.ID); + if (!this.chats.has(ID)) return ""; + return this.chats.get(ID).status; + } + isIdle(args) { + const ID = Scratch.Cast.toString(args.ID); + if (!this.chats.has(ID)) return false; + return this.chats.get(ID).status === "idle"; + } + createNewAIPreset(args) { + var ID = Scratch.Cast.toString(args.ID); + if (this.presets.has(ID)) return; + this.presets.set(ID, { + id: ID, + requestformat: "openai", + requesturl: "", + model: "", + apikey: "", + }); + } + setAiPresetProp(args) { + var ID = Scratch.Cast.toString(args.ID); + var currentPreset = this.presets.get(ID); + if (!currentPreset) return; + var PROP = Scratch.Cast.toString(args.PROP); + var VALUE = Scratch.Cast.toString(args.VALUE); + switch (PROP) { + case "apikey": + currentPreset.apikey = VALUE; + break; + case "model": + currentPreset.model = VALUE; + break; + case "requesturl": + currentPreset.requesturl = VALUE; + break; + case "requestformat": + currentPreset.requestformat = VALUE; + break; + default: + return; + } + } + assignAiPresetToChat(args) { + var PRESET_ID = Scratch.Cast.toString(args.PRESET_ID); + var CHAT_ID = Scratch.Cast.toString(args.CHAT_ID); + var chat = this.chats.get(CHAT_ID); + if (!chat) return; + chat.aiPresetId = PRESET_ID; + } + + sendMessage(args) { + var MESSAGE = Scratch.Cast.toString(args.MESSAGE); + var ID = Scratch.Cast.toString(args.ID); + var chat = this.chats.get(ID); + if (!chat) return; + if (chat.status !== "idle") return; + var preset = this.presets.get(chat.aiPresetId); + if (!preset) { + chat.messages.push({ + role: "assistant", + content: "Error: No AI preset assigned", + }); + chat.status = "idle"; + return; + } + var format = preset.requestformat; + chat.status = "pending"; + var message = { + role: "user", + content: MESSAGE, + }; + chat.controller = new AbortController(); + chat.messages.push(message); + chat.response = ""; + + var requesturl = this._padUrl(preset.requesturl, format); + var headers; + var body; + + if (format === "anthropic") { + headers = { + "Content-Type": "application/json", + "x-api-key": preset.apikey, + "anthropic-version": "2023-06-01", + "anthropic-dangerous-direct-browser-access": "true", + }; + body = { + model: preset.model, + messages: chat.messages.map(function (msg) { + return { role: msg.role, content: msg.content }; + }), + stream: true, + max_tokens: 9070, + }; + this._sendAnthropic(chat, requesturl, headers, body); + } else if (format === "openai") { + // OpenAI-compatible chat completions format + headers = { + "Content-Type": "application/json", + Authorization: "Bearer " + preset.apikey, + }; + body = { + model: preset.model, + messages: chat.messages.map(function (msg) { + return { role: msg.role, content: msg.content }; + }), + stream: true, + }; + this._sendOpenai(chat, requesturl, headers, body); + } + } + stopMessage(args) { + var ID = Scratch.Cast.toString(args.ID); + var chat = this.chats.get(ID); + if (!chat) return; + if (chat.controller) chat.controller.abort(); + } + + response(args) { + var ID = Scratch.Cast.toString(args.ID); + var chat = this.chats.get(ID); + if (!chat) return ""; + return chat.response || ""; + } + anthropicUrl(args) { + return Scratch.Cast.toString(args.AI); + } + openaiUrl(args) { + return Scratch.Cast.toString(args.AI); + } + addMessageOfChatHistory(args) { + var ID = Scratch.Cast.toString(args.ID); + var ROLE = Scratch.Cast.toString(args.ROLE); + var CONTENT = Scratch.Cast.toString(args.CONTENT); + var chat = this.chats.get(ID); + if (!chat) return; + chat.messages.push({ role: ROLE, content: CONTENT }); + } + deleteItemOfChatHistory(args) { + var ID = Scratch.Cast.toString(args.ID); + var chat = this.chats.get(ID); + var INDEX = parseInt(Scratch.Cast.toNumber(args.INDEX)); + if (!chat) return; + // Convert 1-based index to 0-based + chat.messages.splice(INDEX - 1, 1); + } + clearChatHistory(args) { + var ID = Scratch.Cast.toString(args.ID); + var chat = this.chats.get(ID); + if (!chat) return; + chat.messages = []; + } + setDataOfChatHistory(args) { + var ID = Scratch.Cast.toString(args.ID); + var chat = this.chats.get(ID); + if (!chat) return; + var data; + try { + data = JSON.parse(Scratch.Cast.toString(args.DATA)); + if (!Array.isArray(data)) return; + } catch { + return; + } + chat.messages = data; + } + coverObject(args) { + var base; + var diff; + // In terms of performance, using a for loop directly is better than Object.assign. + try { + base = JSON.parse(Scratch.Cast.toString(args.BASE)); + diff = JSON.parse(Scratch.Cast.toString(args.DIFF)); + for (var i in diff) { + // In this situation, there's no need to worry about using Object.prototype.hasOwnProperty.call. + base[i] = diff[i]; + } + } catch { + return "{}"; + } + return JSON.stringify(base); + } + setObjectProp(args) { + var obj; + var prop; + var value; + try { + obj = JSON.parse(Scratch.Cast.toString(args.OBJECT)); + prop = Scratch.Cast.toString(args.KEY); + value = JSON.parse(Scratch.Cast.toString(args.VALUE)); + obj[prop] = value; + } catch { + return "{}"; + } + return JSON.stringify(obj); + } + allKeysOfMap(args) { + var MAP = Scratch.Cast.toString(args.MAP); + switch (MAP) { + case "chats": + return JSON.stringify(Array.from(this.chats.keys())); + case "aipresets": + return JSON.stringify(Array.from(this.presets.keys())); + default: + return "[]"; + } + } + dataOfMap(args) { + var ID = Scratch.Cast.toString(args.ID); + var MAP = Scratch.Cast.toString(args.MAP); + var currentMap; + switch (MAP) { + case "chats": + currentMap = this.chats.get(ID); + break; + case "aipresets": + currentMap = this.presets.get(ID); + break; + default: + return "{}"; + } + if (!currentMap) return "{}"; + return JSON.stringify(currentMap); + } + setDataOfMap(args) { + var ID = Scratch.Cast.toString(args.ID); + var MAP = Scratch.Cast.toString(args.MAP); + var DATA = Scratch.Cast.toString(args.DATA); + var currentMap; + var parsedData; + switch (MAP) { + case "chats": + currentMap = this.chats; + break; + case "aipresets": + currentMap = this.presets; + break; + default: + return; + } + try { + parsedData = JSON.parse(DATA); + } catch { + return; + } + if (!(typeof parsedData === "object")) return; + if (!Array.isArray(parsedData)) return; + currentMap.set(ID, parsedData); + } + chatHistory(args) { + var ID = Scratch.Cast.toString(args.ID); + var chat = this.chats.get(ID); + if (!chat) return "[]"; + return JSON.stringify(chat.messages); + } + propOfChatHistory(args) { + var ID = Scratch.Cast.toString(args.ID); + var INDEX = parseInt(Scratch.Cast.toNumber(args.INDEX)); + var PROP = Scratch.Cast.toString(args.PROP); + var chat = this.chats.get(ID); + if (!chat) return ""; + if (INDEX < 1 || INDEX > chat.messages.length) return ""; + var msg = chat.messages[INDEX - 1]; + return msg[PROP] || ""; + } + lengthOfArray(args) { + var ARRAY = Scratch.Cast.toString(args.ARRAY); + var parsedArray; + try { + parsedArray = JSON.parse(ARRAY); + } catch { + parsedArray = []; + } + return parsedArray.length; + } + itemOfArray(args) { + var INDEX = parseInt(Scratch.Cast.toNumber(args.INDEX)); + var ARRAY = Scratch.Cast.toString(args.ARRAY); + var parsedArray; + try { + parsedArray = JSON.parse(ARRAY); + } catch { + parsedArray = []; + } + var item = parsedArray[INDEX - 1]; + if (typeof item === "undefined") return ""; + return item; + } + itemOfObject(args) { + var KEY = Scratch.Cast.toString(args.KEY); + var OBJECT = Scratch.Cast.toString(args.OBJECT); + var parsedObject; + try { + parsedObject = JSON.parse(OBJECT); + } catch { + parsedObject = {}; + } + var item = parsedObject[KEY]; + if (typeof item === "undefined") return ""; + return item; + } + } + Scratch.extensions.register(new AI()); +})(Scratch); diff --git a/extensions/extensions.json b/extensions/extensions.json index 5f766f06d1..1119e6f1a1 100644 --- a/extensions/extensions.json +++ b/extensions/extensions.json @@ -25,6 +25,7 @@ "clipboard", "obviousAlexC/penPlus", "penplus", + "Xeltalliv/simple3D", "Lily/Skins", "obviousAlexC/SensingPlus", @@ -48,6 +49,7 @@ "mdwalters/notifications", "XeroName/Deltatime", "ar", + "KimosFrontender/ai", "encoding", "SharkPool/Tune-Shark-V3", "Lily/SoundExpanded", diff --git a/images/KimosFrontender/ai.svg b/images/KimosFrontender/ai.svg new file mode 100644 index 0000000000..4bdbd1f0f0 --- /dev/null +++ b/images/KimosFrontender/ai.svg @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/samples/Ai.sb3 b/samples/Ai.sb3 new file mode 100644 index 0000000000..2c7f92b936 Binary files /dev/null and b/samples/Ai.sb3 differ