Browse Source

Oasis release 0.3.9

psy 6 days ago
parent
commit
8c991e1c54

+ 36 - 18
src/AI/ai_service.mjs

@@ -1,9 +1,20 @@
 import path from 'path';
 import { fileURLToPath } from 'url';
-import express from '../server/node_modules/express/index.js'; 
-import cors from '../server/node_modules/cors/lib/index.js';   
+import express from '../server/node_modules/express/index.js';
+import cors from '../server/node_modules/cors/lib/index.js';
 import { getLlama, LlamaChatSession } from '../server/node_modules/node-llama-cpp/dist/index.js';
 
+let getConfig, buildAIContext;
+
+try {
+  getConfig = (await import('../configs/config-manager.js')).getConfig;
+} catch {}
+
+try {
+  const mod = await import('./buildAIContext.js');
+  buildAIContext = mod.default || mod.buildContext;
+} catch {}
+
 const app = express();
 app.use(cors());
 app.use(express.json());
@@ -25,24 +36,31 @@ async function initModel() {
 }
 
 app.post('/ai', async (req, res) => {
+  try {
     const userInput = req.body.input;
-
-    await initModel();  
-    const prompt = `
-      Context: You are an AI assistant in Oasis, a distributed, encrypted and federated social network created by old-school hackers.
-
-      Query: "${userInput}"
-
-      Provide an informative and precise response.
-    `;
-
-    const response = await session.prompt(prompt);
-    if (!response) {
-      res.status(500).json({ error: 'Failed to get response from model' });
-      return;
+    await initModel();
+    let userContext = '';
+    try {
+      userContext = await buildAIContext();
+    } catch {
+      userContext = '';
+    }
+    const config = getConfig?.() || {};
+    const userPrompt = config.ai?.prompt?.trim() || "Provide an informative and precise response.";
+    const promptParts = [
+      "Context: You are an AI assistant called \"42\" in Oasis, a distributed, encrypted and federated social network.",
+    ];
+    if (userContext?.trim()) {
+      promptParts.push(`User Data:\n${userContext}`);
     }
+    promptParts.push(`Query: "${userInput}"`);
+    promptParts.push(userPrompt);
+    const finalPrompt = promptParts.join('\n\n');
+    const response = await session.prompt(finalPrompt);
     res.json({ answer: response.trim() });
+  } catch (err) {
+    res.status(500).json({ error: 'Internal Server Error', details: err.message });
+  }
 });
 
-app.listen(4001, () => {
-});
+app.listen(4001);

File diff suppressed because it is too large
+ 107 - 0
src/AI/buildAIContext.js


+ 62 - 5
src/backend/backend.js

@@ -540,7 +540,17 @@ router
       return;
     }
     startAI();
-    ctx.body = aiView();
+    const historyPath = path.join(__dirname, '..', '..', 'src', 'configs', 'AI-history.json');
+    let chatHistory = [];
+    try {
+      const fileData = fs.readFileSync(historyPath, 'utf-8');
+      chatHistory = JSON.parse(fileData);
+    } catch (e) {
+      chatHistory = [];
+    }
+    const config = getConfig();
+    const userPrompt = config.ai?.prompt?.trim() || '';
+    ctx.body = aiView(chatHistory, userPrompt);
   })
    // pixelArt
   .get('/pixelia', async (ctx) => {
@@ -991,13 +1001,16 @@ router
   })
   .get("/settings", async (ctx) => {
     const theme = ctx.cookies.get("theme") || "Dark-SNH";
-    const getMeta = async ({ theme }) => {
+    const config = getConfig();
+    const aiPrompt = config.ai?.prompt || "";
+    const getMeta = async ({ theme, aiPrompt }) => {
       return settingsView({
         theme,
         version: version.toString(),
+        aiPrompt
       });
     };
-    ctx.body = await getMeta({ theme });
+    ctx.body = await getMeta({ theme, aiPrompt });
   })
   .get("/peers", async (ctx) => {
     const theme = ctx.cookies.get("theme") || config.theme;
@@ -1290,11 +1303,41 @@ router
     const axios = require('../server/node_modules/axios').default;
     const { input } = ctx.request.body;
     if (!input) {
-      return ctx.status = 400, ctx.body = { error: 'No input provided' };
+      ctx.status = 400;
+      ctx.body = { error: 'No input provided' };
+      return;
     }
+    const config = getConfig();
+    const userPrompt = config.ai?.prompt?.trim() || "Provide an informative and precise response.";
     const response = await axios.post('http://localhost:4001/ai', { input });
     const aiResponse = response.data.answer;
-    ctx.body = aiView(aiResponse, input);
+    const historyPath = path.join(__dirname, '..', '..', 'src', 'configs', 'AI-history.json');
+    let chatHistory = [];
+    try {
+      const fileData = fs.readFileSync(historyPath, 'utf-8');
+      chatHistory = JSON.parse(fileData);
+    } catch (e) {
+      chatHistory = [];
+    }
+    chatHistory.unshift({
+      prompt: userPrompt,
+      question: input,
+      answer: aiResponse,
+      timestamp: Date.now()
+    });
+    chatHistory = chatHistory.slice(0, 20);
+    fs.writeFileSync(historyPath, JSON.stringify(chatHistory, null, 2), 'utf-8');
+    ctx.body = aiView(chatHistory, userPrompt);
+  })
+  .post('/ai/clear', async (ctx) => {
+    const fs = require('fs');
+    const path = require('path');
+    const { getConfig } = require('../configs/config-manager.js');
+    const historyPath = path.join(__dirname, '..', '..', 'src', 'configs', 'AI-history.json');
+    fs.writeFileSync(historyPath, '[]', 'utf-8');
+    const config = getConfig();
+    const userPrompt = config.ai?.prompt?.trim() || '';
+    ctx.body = aiView([], userPrompt);
   })
   .post('/pixelia/paint', koaBody(), async (ctx) => {
     const { x, y, color } = ctx.request.body;
@@ -2205,6 +2248,20 @@ router
     saveConfig(currentConfig);
     ctx.redirect(`/modules`);
   })
+  .post("/settings/ai", koaBody(), async (ctx) => {
+    const aiPrompt = String(ctx.request.body.ai_prompt || "").trim();
+    if (aiPrompt.length > 128) {
+      ctx.status = 400;
+      ctx.body = "Prompt too long. Must be 128 characters or fewer.";
+      return;
+    }
+    const currentConfig = getConfig();
+    currentConfig.ai = currentConfig.ai || {};
+    currentConfig.ai.prompt = aiPrompt;
+    saveConfig(currentConfig);
+    const referer = new URL(ctx.request.header.referer);
+    ctx.redirect("/settings");
+  })
   .post('/transfers/create',
     koaBody(),
     async ctx => {

+ 31 - 0
src/backend/renderUrl.js

@@ -0,0 +1,31 @@
+const { a } = require("../server/node_modules/hyperaxe");
+
+function renderUrl(text) {
+  if (typeof text !== 'string') return [text];
+  const urlRegex = /\b(?:https?:\/\/|www\.)[^\s]+/g;
+  const emailRegex = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z]{2,}\b/gi;
+  const result = [];
+  let cursor = 0;
+  const matches = [...(text.matchAll(urlRegex)), ...(text.matchAll(emailRegex))]
+    .sort((a, b) => a.index - b.index);
+  for (const match of matches) {
+    const url = match[0];
+    const index = match.index;
+    if (cursor < index) {
+      result.push(text.slice(cursor, index));
+    }
+    if (url.startsWith('http') || url.startsWith('www.')) {
+      const href = url.startsWith('http') ? url : `https://${url}`;
+      result.push(a({ href, target: '_blank', rel: 'noopener noreferrer' }, url));
+    } else if (url.includes('@')) {
+      result.push(a({ href: `mailto:${url}` }, url));
+    }
+    cursor = index + url.length;
+  }
+  if (cursor < text.length) {
+    result.push(text.slice(cursor));
+  }
+  return result;
+}
+
+module.exports = { renderUrl };

+ 7 - 2
src/client/assets/translations/oasis_en.js

@@ -1301,9 +1301,14 @@ module.exports = {
     aiTitle: "AI",
     aiDescription: "A Collective Artificial Intelligence (CAI) called '42' that learns from your network.",
     aiInputPlaceholder: "What's up?",
-    aiUserQuestion: "Your Question",
-    aiResponseTitle: "AI Reply",
+    aiUserQuestion: "Question",
+    aiResponseTitle: "Reply",
     aiSubmitButton: "Send!",
+    aiSettingsDescription: "Set your prompt  (max 128 characters) for the AI model.",
+    aiPrompt: "Provide an informative and precise response.",
+    aiConfiguration: "Set prompt",
+    aiPromptUsed: "Prompt",
+    aiClearHistory: "Clear chat history",
     //market
     marketMineSectionTitle: "Your Items",
     marketCreateSectionTitle: "Create a New Item",

+ 6 - 1
src/client/assets/translations/oasis_es.js

@@ -1300,9 +1300,14 @@ module.exports = {
     aiTitle: "IA",
     aiDescription: "Una Inteligencia Artificial Colectiva (IAC) llamada '42' que aprende de tu red.",
     aiInputPlaceholder: "Qué quieres saber?",
-    aiUserQuestion: "Tu pregunta",
+    aiUserQuestion: "Pregunta",
     aiResponseTitle: "Respuesta",
     aiSubmitButton: "Enviar!",
+    aiSettingsDescription: "Configura tu prompt (máx 128 caracteres) para el modelo de IA.",
+    aiPrompt: "Provide an informative and precise response.",
+    aiConfiguration: "Configurar prompt",
+    aiPromptUsed: "Prompt",
+    aiClearHistory: "Borrar historial de chat",
     //market
     marketMineSectionTitle: "Tus Artículos",
     marketCreateSectionTitle: "Crear un Nuevo Artículo",

+ 6 - 1
src/client/assets/translations/oasis_eu.js

@@ -1301,9 +1301,14 @@ module.exports = {
     aiTitle: "IA",
     aiDescription: "Zure saretik ikasten duen '42' izeneko Adimen Artifizial Kolektibo (AIA) bat.",
     aiInputPlaceholder: "Zer jakin nahi duzu?",
-    aiUserQuestion: "Zure galdera",
+    aiUserQuestion: "Galdera",
     aiResponseTitle: "Erantzuna",
     aiSubmitButton: "Bidali!",
+    aiSettingsDescription: "Konfiguratu zure IA ereduaren prompt-a (gehienez 128 karaktere).",
+    aiPrompt: "Eman erantzun informatibo eta zehatza.",
+    aiConfiguration: "Konfiguratu prompt-a",
+    aiPromptUsed: "Prompt-a",
+    aiClearHistory: "Txataren historia garbitu",
     //market
     marketMineSectionTitle: "Zure Elementuak",
     marketCreateSectionTitle: "Sortu Elementu Berria",

+ 1 - 0
src/configs/AI-history.json

@@ -0,0 +1 @@
+[]

+ 3 - 0
src/configs/config-manager.js

@@ -44,6 +44,9 @@ if (!fs.existsSync(configFilePath)) {
       "user": "ecoinrpc",
       "pass": "",
       "fee": "1"
+    },
+    "ai": {
+      "prompt": "Provide an informative and precise response."
     }
   };
   fs.writeFileSync(configFilePath, JSON.stringify(defaultConfig, null, 2));

+ 3 - 0
src/configs/oasis-config.json

@@ -38,5 +38,8 @@
     "user": "ecoinrpc",
     "pass": "",
     "fee": "1"
+  },
+  "ai": {
+    "prompt": "Provide an informative and precise response."
   }
 }

+ 1 - 1
src/server/package-lock.json

@@ -1,6 +1,6 @@
 {
   "name": "@krakenslab/oasis",
-  "version": "0.3.8",
+  "version": "0.3.9",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {

+ 1 - 1
src/server/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@krakenslab/oasis",
-  "version": "0.3.8",
+  "version": "0.3.9",
   "description": "Oasis Social Networking Project Utopia",
   "repository": {
     "type": "git",

+ 80 - 13
src/views/AI_view.js

@@ -1,29 +1,96 @@
 const { div, h2, p, section, button, form, textarea, br, span } = require("../server/node_modules/hyperaxe");
 const { template, i18n } = require('./main_views');
 
-exports.aiView = (response = '', userQuestion = '') => {
+exports.aiView = (history = [], userPrompt = '') => {
   return template(
     i18n.aiTitle,
     section(
       div({ class: "tags-header" },
         h2(i18n.aiTitle),
         p(i18n.aiDescription),
-        form({ method: 'POST', action: '/ai' },
-          textarea({ name: 'input', placeholder: i18n.aiInputPlaceholder, required: true }),
+        userPrompt ? div({ class: 'user-prompt', style: 'margin-bottom: 2em; font-size: 0.95em; color: #888;' },
+          `${i18n.aiPromptUsed || 'System Prompt'}: `,
+          span({ style: 'font-style: italic;' }, `"${userPrompt}"`)
+        ) : null,
+        form({ method: 'POST', action: '/ai', style: "margin-bottom: 0;" },
+          textarea({ name: 'input', rows: 4, placeholder: i18n.aiInputPlaceholder, required: true }),
           br(),
-          button({ type: 'submit' }, i18n.aiSubmitButton)
+          div({ style: "display: flex; gap: 1.5em; justify-content: flex-end; align-items: center; margin-top: 0.7em;" },
+            button({ type: 'submit' }, i18n.aiSubmitButton)
+          )
+        ),
+        div({ style: "display: flex; justify-content: flex-end; margin-bottom: 2em;" },
+          form({ method: 'POST', action: '/ai/clear', style: "display: inline;" },
+            button({
+              type: 'submit',
+              style: `
+                background: #b80c09;
+                color: #fff;
+                border: none;
+                padding: 0.4em 1.2em;
+                border-radius: 6px;
+                cursor: pointer;
+                font-size: 1em;
+                margin-left: 1em;
+              `
+            }, i18n.aiClearHistory || 'Clear chat history')
+          )
         ),
         br(),
-        userQuestion ? div({ class: 'user-question' },
-          h2(`${i18n.aiUserQuestion}:`),
-          userQuestion
-        ) : null,
-
-        response ? div({ class: 'ai-response' },
-          h2(`${i18n.aiResponseTitle}:`),
-          response
-        ) : null
+        ...history.map(entry =>
+          div({
+            class: 'chat-entry',
+            style: `
+              margin-bottom: 2em;
+              position: relative;
+              background: #191919;
+              border-radius: 10px;
+              box-shadow: 0 0 8px #0004;
+              padding-top: 1.8em;
+            `
+          },
+            entry.timestamp ? span({
+              style: `
+                position: absolute;
+                top: 0.5em;
+                right: 1.3em;
+                font-size: 0.92em;
+                color: #888;
+              `
+            }, new Date(entry.timestamp).toLocaleString()) : null,
+            br(),br(),
+            div({ class: 'user-question', style: 'margin-bottom: 0.75em;' },
+              h2(`${i18n.aiUserQuestion}:`),
+              p(entry.question)
+            ),
+            div({
+              class: 'ai-response',
+              style: `
+                max-width: 800px;
+                margin: auto;
+                background: #111;
+                padding: 1.25em;
+                border-radius: 6px;
+                font-family: sans-serif;
+                line-height: 1.6;
+                color: #ffcc00;
+              `
+            },
+              h2(`${i18n.aiResponseTitle}:`),
+              ...entry.answer
+                .split('\n\n')
+                .flatMap(paragraph =>
+                  paragraph
+                    .split('\n')
+                    .map(line =>
+                      p({ style: "margin-bottom: 1.2em;" }, line.trim())
+                )
+              )
+            )
+          )
+        )
       )
     )
   );
 };
+

+ 3 - 2
src/views/inhabitants_view.js

@@ -1,5 +1,6 @@
 const { div, h2, p, section, button, form, img, a, textarea, input, br } = require("../server/node_modules/hyperaxe");
 const { template, i18n } = require('./main_views');
+const { renderUrl } = require('../backend/renderUrl');
 
 function resolvePhoto(photoField, size = 256) {
   if (typeof photoField === 'string' && photoField.startsWith('/image/')) {
@@ -31,7 +32,7 @@ const renderInhabitantCard = (user, filter) => {
   }),
     div({ class: 'inhabitant-details' },
       h2(user.name),
-      user.description ? p(user.description) : null,
+        user.description ? p(...renderUrl(user.description)) : null,
       filter === 'MATCHSKILLS' && user.commonSkills?.length
         ? p(`${i18n.commonSkills}: ${user.commonSkills.join(', ')}`) : null,
       filter === 'SUGGESTED' && user.mutualCount
@@ -186,7 +187,7 @@ exports.inhabitantsProfileView = ({ about = {}, cv = {}, feed = [] }) => {
         div({ class: 'inhabitant-details' },
           h2(name),
           p(a({ class: 'user-link', href: `/author/${encodeURIComponent(id)}` }, id)),
-          description ? p(description) : null,
+          description ? p(...renderUrl(description)) : null,
           location ? p(`${i18n.locationLabel}: ${location}`) : null,
           languages.length ? p(`${i18n.languagesLabel}: ${languages.join(', ')}`) : null,
           skills.length ? p(`${i18n.skillsLabel}: ${skills.join(', ')}`) : null,

+ 20 - 1
src/views/settings_view.js

@@ -18,7 +18,7 @@ const getThemeConfig = () => {
   }
 };
 
-const settingsView = ({ version }) => {
+const settingsView = ({ version, aiPrompt }) => {
   const currentThemeConfig = getThemeConfig();
   const theme = currentThemeConfig.themes?.current || "Dark-SNH";
   const currentConfig = getConfig();
@@ -113,6 +113,25 @@ const settingsView = ({ version }) => {
         )
       )
     ),
+    section(
+      div({ class: "tags-header" },
+        h2(i18n.aiTitle),
+        p(i18n.aiSettingsDescription),
+        form(
+          { action: "/settings/ai", method: "POST" },
+          input({
+            type: "text",
+            id: "ai_prompt",
+            name: "ai_prompt",
+            placeholder: aiPrompt,
+            value: aiPrompt,
+            maxlength: "128",
+            required: true
+          }), br(),
+          button({ type: "submit" }, i18n.aiConfiguration)
+        )
+      )
+    ),
     section(
       div({ class: "tags-header" },
         h2(i18n.indexes),

+ 3 - 2
src/views/tribes_view.js

@@ -1,6 +1,7 @@
 const { div, h2, p, section, button, form, a, input, img, label, select, option, br, textarea, h1 } = require("../server/node_modules/hyperaxe");
 const { template, i18n } = require('./main_views');
 const { config } = require('../server/SSB_server.js');
+const { renderUrl } = require('../backend/renderUrl');
 
 const userId = config.keys.id;
 
@@ -267,7 +268,7 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}) => {
       p(`${i18n.tribeModeLabel}: ${t.inviteMode.toUpperCase()}`),
       p(`${i18n.tribeLARPLabel}: ${t.isLARP ? i18n.tribeYes : i18n.tribeNo}`),
       img({ src: imageSrc }),
-      p(t.description),
+      t.description ? p(...renderUrl(t.description)) : null,
       p(`${i18n.tribeLocationLabel}: ${t.location}`),
       h2(`${i18n.tribeMembersCount}: ${t.members.length}`),
       t.tags && t.tags.filter(Boolean).length ? div(t.tags.filter(Boolean).map(tag =>
@@ -379,7 +380,7 @@ exports.tribeView = async (tribe, userId, query) => {
     p(`${i18n.tribeModeLabel}: ${tribe.inviteMode.toUpperCase()}`),
     p(`${i18n.tribeLARPLabel}: ${tribe.isLARP ? i18n.tribeYes : i18n.tribeNo}`),
     img({ src: imageSrc, alt: tribe.title }),
-    p(tribe.description),
+    tribe.description ? p(...renderUrl(tribe.description)) : null,
     p(`${i18n.tribeLocationLabel}: ${tribe.location}`),
     h2(`${i18n.tribeMembersCount}: ${tribe.members.length}`),
     tribe.tags && tribe.tags.filter(Boolean).length ? div(tribe.tags.filter(Boolean).map(tag =>