瀏覽代碼

Oasis release 0.5.0

psy 2 周之前
父節點
當前提交
751b60c2ee

+ 12 - 0
docs/CHANGELOG.md

@@ -13,6 +13,18 @@ All notable changes to this project will be documented in this file.
 ### Security
 -->
 
+
+## v0.5.0 - 2025-09-20
+
+### Added
+
+ + Custom answer training (AI plugin).
+
+### Fixed
+
+ + Clean-SNH theme.
+ + AI learning (AI plugin).
+
 ## v0.4.9 - 2025-09-01
 
 ### Added

+ 3 - 7
src/AI/ai_service.mjs

@@ -54,20 +54,17 @@ app.post('/ai', async (req, res) => {
     } catch {}
 
     const config = getConfig?.() || {};
-    const userPrompt = config.ai?.prompt?.trim() || 'Provide an informative and precise response.';
+    const baseContext = 'Context: You are an AI assistant called "42" in Oasis, a distributed, encrypted and federated social network.';
+    const userPrompt = [baseContext, config.ai?.prompt?.trim() || 'Provide an informative and precise response.'].join('\n');
 
     const prompt = [
-      'Context: You are an AI assistant called "42" in Oasis, a distributed, encrypted and federated social network.',
       userContext ? `User Data:\n${userContext}` : '',
       `Query: "${userInput}"`,
       userPrompt
     ].filter(Boolean).join('\n\n');
     const answer = await session.prompt(prompt);
     res.json({ answer: String(answer || '').trim(), snippets });
-  } catch (err) {
-    lastError = err;
-    res.status(500).json({ error: 'Internal Server Error', details: String(err.message || err) });
-  }
+  } catch {}
 });
 
 app.post('/ai/train', async (req, res) => {
@@ -75,4 +72,3 @@ app.post('/ai/train', async (req, res) => {
 });
 
 app.listen(4001);
-

+ 78 - 98
src/AI/buildAIContext.js

@@ -1,124 +1,104 @@
-const pull = require('../server/node_modules/pull-stream');
-const gui = require('../client/gui.js');
-const { getConfig } = require('../configs/config-manager.js');
-const path = require('path');
-
-const logLimit = getConfig().ssbLogStream?.limit || 1000;
-const cooler = gui({ offline: false });
+const pull = require('../server/node_modules/pull-stream')
+const { getConfig } = require('../configs/config-manager.js')
+
+const logLimit = getConfig().ssbLogStream?.limit || 1000
+
+let cooler = null
+let ssb = null
+let opening = null
+
+function getCooler() {
+  let ssbPath = null
+  try { ssbPath = require.resolve('../server/SSB_server.js') } catch {}
+  if (ssbPath && require.cache[ssbPath]) {
+    if (!cooler) {
+      const gui = require('../client/gui.js')
+      cooler = gui({ offline: false })
+    }
+    return cooler
+  }
+  return null
+}
 
-const searchableTypes = [
-  'post', 'about', 'curriculum', 'tribe', 'transfer', 'feed',
-  'votes', 'vote', 'report', 'task', 'event', 'bookmark', 'document',
-  'image', 'audio', 'video', 'market', 'forum', 'job', 'project',
-  'contact', 'pub', 'pixelia', 'bankWallet', 'bankClaim', 'aiExchange'
-];
+async function openSsb() {
+  const c = getCooler()
+  if (!c) return null
+  if (ssb && ssb.closed === false) return ssb
+  if (!opening) opening = c.open().then(x => (ssb = x)).finally(() => { opening = null })
+  await opening
+  return ssb
+}
 
-const clip = (s, n) => String(s || '').slice(0, n);
-const squash = s => String(s || '').replace(/\s+/g, ' ').trim();
-const compact = s => squash(clip(s, 160));
+const clip = (s, n) => String(s || '').slice(0, n)
+const squash = s => String(s || '').replace(/\s+/g, ' ').trim()
+const compact = s => squash(clip(s, 160))
+const normalize = s => String(s || '').toLowerCase().replace(/\s+/g, ' ').replace(/[^\p{L}\p{N}\s]+/gu, '').trim()
 
 function fieldsForSnippet(type, c) {
-  switch (type) {
-    case 'aiExchange': return [c?.question, clip(squash(c?.answer || ''), 120)];
-    case 'post': return [c?.text, ...(c?.tags || [])];
-    case 'about': return [c?.about, c?.name, c?.description];
-    case 'curriculum': return [c?.name, c?.description, c?.location];
-    case 'tribe': return [c?.title, c?.description, ...(c?.tags || [])];
-    case 'transfer': return [c?.from, c?.to, String(c?.amount), c?.status];
-    case 'feed': return [c?.text, ...(c?.tags || [])];
-    case 'votes': return [c?.question, c?.status];
-    case 'vote': return [c?.vote?.link, String(c?.vote?.value)];
-    case 'report': return [c?.title, c?.severity, c?.status];
-    case 'task': return [c?.title, c?.status];
-    case 'event': return [c?.title, c?.date, c?.location];
-    case 'bookmark': return [c?.url, c?.description];
-    case 'document': return [c?.title, c?.description];
-    case 'image': return [c?.title, c?.description];
-    case 'audio': return [c?.title, c?.description];
-    case 'video': return [c?.title, c?.description];
-    case 'market': return [c?.title, String(c?.price), c?.status];
-    case 'forum': return [c?.title, c?.category, c?.text];
-    case 'job': return [c?.title, c?.job_type, String(c?.salary), c?.status];
-    case 'project': return [c?.title, c?.status, String(c?.progress)];
-    case 'contact': return [c?.contact];
-    case 'pub': return [c?.address?.key, c?.address?.host];
-    case 'pixelia': return [c?.author];
-    case 'bankWallet': return [c?.address];
-    case 'bankClaim': return [String(c?.amount), c?.epochId, c?.txid];
-    default: return [];
-  }
+  if (type === 'aiExchange') return [c?.question, clip(squash(c?.answer || ''), 120)]
+  return []
 }
 
 async function publishExchange({ q, a, ctx = [], tokens = {} }) {
-  const ssbClient = await cooler.open();
-
+  const s = await openSsb()
+  if (!s) return null
   const content = {
     type: 'aiExchange',
     question: clip(String(q || ''), 2000),
     answer: clip(String(a || ''), 5000),
-    ctx: ctx.slice(0, 12).map(s => clip(String(s || ''), 800)),
+    ctx: ctx.slice(0, 12).map(x => clip(String(x || ''), 800)),
     timestamp: Date.now()
-  };
-
+  }
   return new Promise((resolve, reject) => {
-    ssbClient.publish(content, (err, res) => err ? reject(err) : resolve(res));
-  });
+    s.publish(content, (err, res) => err ? reject(err) : resolve(res))
+  })
 }
 
 async function buildContext(maxItems = 100) {
-  const ssb = await cooler.open();
-  return new Promise((resolve, reject) => {
+  const s = await openSsb()
+  if (!s) return ''
+  return new Promise((resolve) => {
     pull(
-      ssb.createLogStream({ reverse: true, limit: logLimit }),
+      s.createLogStream({ reverse: true, limit: logLimit }),
       pull.collect((err, msgs) => {
-        if (err) return reject(err);
-
-        const tombstoned = new Set();
-        const latest = new Map();
-
+        if (err || !Array.isArray(msgs)) return resolve('')
+        const lines = []
         for (const { value } of msgs) {
-          const c = value?.content;
-          if (c?.type === 'tombstone' && c?.target) tombstoned.add(c.target);
+          const c = value && value.content || {}
+          if (c.type !== 'aiExchange') continue
+          const d = new Date(value.timestamp || 0).toISOString().slice(0, 10)
+          const q = compact(c.question)
+          const a = compact(c.answer)
+          lines.push(`[${d}] (AIExchange) Q: ${q} | A: ${a}`)
+          if (lines.length >= maxItems) break
         }
+        if (lines.length === 0) return resolve('')
+        resolve(`## AIEXCHANGE\n\n${lines.join('\n')}`)
+      })
+    )
+  })
+}
 
-        for (const { key, value } of msgs) {
-          const author = value?.author;
-          const content = value?.content || {};
-          const type = content?.type;
-          const ts = value?.timestamp || 0;
-
-          if (!searchableTypes.includes(type) || tombstoned.has(key)) continue;
-
-          const uniqueKey = type === 'about' ? content.about : key;
-          if (!latest.has(uniqueKey) || (latest.get(uniqueKey)?.value?.timestamp || 0) < ts) {
-            latest.set(uniqueKey, { key, value });
+async function getBestTrainedAnswer(question) {
+  const s = await openSsb()
+  if (!s) return null
+  const want = normalize(question)
+  return new Promise((resolve) => {
+    pull(
+      s.createLogStream({ reverse: true, limit: logLimit }),
+      pull.collect((err, msgs) => {
+        if (err || !Array.isArray(msgs)) return resolve(null)
+        for (const { value } of msgs) {
+          const c = value && value.content || {}
+          if (c.type !== 'aiExchange') continue
+          if (normalize(c.question) === want) {
+            return resolve({ answer: String(c.answer || '').trim(), ctx: Array.isArray(c.ctx) ? c.ctx : [] })
           }
         }
-
-        const grouped = {};
-        Array.from(latest.values())
-          .sort((a, b) => (b.value.timestamp || 0) - (a.value.timestamp || 0))
-          .slice(0, maxItems)
-          .forEach(({ value }) => {
-            const content = value.content;
-            const type = content.type;
-            const fields = fieldsForSnippet(type, content).filter(Boolean).map(compact).filter(Boolean).join(' | ');
-            if (!fields) return;
-            const date = new Date(value.timestamp || 0).toISOString().slice(0, 10);
-            grouped[type] = grouped[type] || [];
-            grouped[type].push(`[${date}] (${type}) ${fields}`);
-          });
-
-        const contextSections = Object.entries(grouped)
-          .map(([type, lines]) => `## ${type.toUpperCase()}\n\n${lines.slice(0, 20).join('\n')}`)
-          .join('\n\n');
-
-        const finalContext = contextSections ? contextSections : '';
-        resolve(finalContext);
+        resolve(null)
       })
-    );
-  });
+    )
+  })
 }
 
-module.exports = { fieldsForSnippet, buildContext, clip, publishExchange };
-
+module.exports = { fieldsForSnippet, buildContext, clip, publishExchange, getBestTrainedAnswer }

+ 58 - 48
src/backend/backend.js

@@ -46,7 +46,7 @@ const axiosMod = require('../server/node_modules/axios');
 const axios = axiosMod.default || axiosMod;
 const { spawn } = require('child_process');
 
-const { fieldsForSnippet, buildContext, clip, publishExchange } = require('../AI/buildAIContext.js');
+const { fieldsForSnippet, buildContext, clip, publishExchange, getBestTrainedAnswer } = require('../AI/buildAIContext.js');
 
 let aiStarted = false;
 function startAI() {
@@ -1588,9 +1588,9 @@ router
   .post('/ai', koaBody(), async (ctx) => {
     const { input } = ctx.request.body;
     if (!input) {
-        ctx.status = 400;
-        ctx.body = { error: 'No input provided' };
-        return;
+      ctx.status = 400;
+      ctx.body = { error: 'No input provided' };
+      return;
     }
     startAI();
     const i18nAll = require('../client/assets/translations/i18n');
@@ -1601,34 +1601,42 @@ router
     const historyPath = path.join(__dirname, '..', '..', 'src', 'configs', 'AI-history.json');
     let chatHistory = [];
     try {
-        const fileData = fs.readFileSync(historyPath, 'utf-8');
-        chatHistory = JSON.parse(fileData);
+      const fileData = fs.readFileSync(historyPath, 'utf-8');
+      chatHistory = JSON.parse(fileData);
     } catch {
-        chatHistory = [];
+      chatHistory = [];
     }
     const config = getConfig();
     const userPrompt = config.ai?.prompt?.trim() || 'Provide an informative and precise response.';
     try {
+      let aiResponse = '';
+      let snippets = [];
+      const trained = await getBestTrainedAnswer(input);
+      if (trained && trained.answer) {
+        aiResponse = trained.answer;
+        snippets = Array.isArray(trained.ctx) ? trained.ctx : [];
+      } else {
         const response = await axios.post('http://localhost:4001/ai', { input });
-        const aiResponse = response.data.answer;
-        const snippets = Array.isArray(response.data.snippets) ? response.data.snippets : [];
-        chatHistory.unshift({
-            prompt: userPrompt,
-            question: input,
-            answer: aiResponse,
-            timestamp: Date.now(),
-            trainStatus: 'pending',
-            snippets
-        });
+        aiResponse = response.data.answer;
+        snippets = Array.isArray(response.data.snippets) ? response.data.snippets : [];
+      }
+      chatHistory.unshift({
+        prompt: userPrompt,
+        question: input,
+        answer: aiResponse,
+        timestamp: Date.now(),
+        trainStatus: 'pending',
+        snippets
+      });
     } catch (e) {
-        chatHistory.unshift({
-            prompt: userPrompt,
-            question: input,
-            answer: translations.aiServerError || 'The AI could not answer. Please try again.',
-            timestamp: Date.now(),
-            trainStatus: 'rejected',
-            snippets: []
-        });
+      chatHistory.unshift({
+        prompt: userPrompt,
+        question: input,
+        answer: translations.aiServerError || 'The AI could not answer. Please try again.',
+        timestamp: Date.now(),
+        trainStatus: 'rejected',
+        snippets: []
+      });
     }
     chatHistory = chatHistory.slice(0, 20);
     fs.writeFileSync(historyPath, JSON.stringify(chatHistory, null, 2), 'utf-8');
@@ -1636,36 +1644,38 @@ router
   })
   .post('/ai/approve', koaBody(), async (ctx) => {
     const ts = String(ctx.request.body.ts || '');
+    const custom = String(ctx.request.body.custom || '').trim();
     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 (err) {
-        chatHistory = [];
+      const fileData = fs.readFileSync(historyPath, 'utf-8');
+      chatHistory = JSON.parse(fileData);
+    } catch {
+      chatHistory = [];
     }
     const item = chatHistory.find(e => String(e.timestamp) === ts);
     if (item) {
-        try {
-            const contentType = item?.type || 'aiExchange';
-            let snippets = fieldsForSnippet(contentType, item);
-            if (snippets.length === 0) {
-                const context = await buildContext();
-                snippets = [context];
-            } else {
-                snippets = snippets.map(snippet => clip(snippet, 200)); 
-            }
-            await publishExchange({
-                q: item.question,
-                a: item.answer,
-                ctx: snippets,
-                tokens: {}
-            });
-            item.trainStatus = 'approved';  
-        } catch (err) {
-            item.trainStatus = 'failed';
+      try {
+        if (custom) item.answer = custom;
+        item.type = 'aiExchange';
+        let snippets = fieldsForSnippet('aiExchange', item);
+        if (snippets.length === 0) {
+          const context = await buildContext();
+          snippets = [context];
+        } else {
+          snippets = snippets.map(snippet => clip(snippet, 200));
         }
-        fs.writeFileSync(historyPath, JSON.stringify(chatHistory, null, 2), 'utf-8');
+        await publishExchange({
+          q: item.question,
+          a: item.answer,
+          ctx: snippets,
+          tokens: {}
+        });
+        item.trainStatus = 'approved';
+      } catch {
+        item.trainStatus = 'failed';
+      }
+      fs.writeFileSync(historyPath, JSON.stringify(chatHistory, null, 2), 'utf-8');
     }
     const config = getConfig();
     const userPrompt = config.ai?.prompt?.trim() || '';

+ 0 - 2
src/client/assets/styles/style.css

@@ -1356,8 +1356,6 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 }
 
 .refeed-column {
-  width: 80px;
-  min-width: 80px;
   text-align: center;
   border-right: 1px solid #555;
   padding-right: 12px;

+ 10 - 0
src/client/assets/themes/Clear-SNH.css

@@ -41,6 +41,16 @@ a:hover {
   text-decoration: underline !important;
 }
 
+p {
+  color: black !important;
+  text-decoration: none !important;
+}
+
+.created-at, .about-time, .time {
+  font-size: 0.9rem;
+  color: black;
+}
+
 table {
   background-color: #FFFFFF !important;
   color: #2C2C2C !important;

+ 3 - 1
src/client/assets/translations/oasis_en.js

@@ -1563,9 +1563,11 @@ module.exports = {
     aiTrainPending: "Pending approval",
     aiTrainApproved: "Approved for training",
     aiTrainRejected: "Rejected for training",
-    aiSnippetsUsed: "Context lines used",
+    aiSnippetsUsed: "Snippets used",
     aiSnippetsLearned: "Snippets learned",
     statsAITraining: "AI training",
+    aiApproveCustomTrain: "Train using this custom answer",
+    aiCustomAnswerPlaceholder: "Write your custom answer…",
     statsAIExchanges: "Model Exchanges",
     //market
     marketMineSectionTitle: "Your Items",

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

@@ -1575,9 +1575,11 @@ module.exports = {
     aiTrainPending: "Pendiente de aprobación",
     aiTrainApproved: "Aprobado para entrenamiento",
     aiTrainRejected: "Rechazado para entrenamiento",
-    aiSnippetsUsed: "Líneas de contexto usadas",
+    aiSnippetsUsed: "Fragmentos usados",
     aiSnippetsLearned: "Fragmentos aprendidos",
     statsAITraining: "Entrenamiento de IA",
+    aiApproveCustomTrain: "Entrenar con esta respuesta personalizada",
+    aiCustomAnswerPlaceholder: "Escribe tu respuesta personalizada…",
     statsAIExchanges: "Intercambio de Modelos",
     //market
     marketMineSectionTitle: "Tus Artículos",

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

@@ -1576,9 +1576,11 @@ module.exports = {
     aiTrainPending: "Onartzearen zain",
     aiTrainApproved: "Entrenamendurako onartua",
     aiTrainRejected: "Entrenamendurako baztertua",
-    aiSnippetsUsed: "Erabilitako testuinguru lerroak",
+    aiSnippetsUsed: "Erabilitako zatiak",
     aiSnippetsLearned: "Ikasitako fragementuak",
     statsAITraining: "IA prestakuntza",
+    aiApproveCustomTrain: "Erantzun pertsonalizatu hau erabiliz trebatu",
+    aiCustomAnswerPlaceholder: "Idatzi zure erantzun pertsonalizatua…",
     statsAIExchanges: "Ereduen trukea",
     //market
     marketMineSectionTitle: "Zure Elementuak",

+ 3 - 1
src/client/assets/translations/oasis_fr.js

@@ -1575,9 +1575,11 @@ module.exports = {
     aiTrainPending: "En attente d’approbation",
     aiTrainApproved: "Approuvé pour l’entraînement",
     aiTrainRejected: "Rejeté pour l’entraînement",
-    aiSnippetsUsed: "Lignes de contexte utilisées",
+    aiSnippetsUsed: "Fragments utilisés",
     aiSnippetsLearned: "Fragments appris",
     statsAITraining: "Entraînement de l’IA",
+    aiApproveCustomTrain: "Entraîner avec cette réponse personnalisée",
+    aiCustomAnswerPlaceholder: "Écrivez votre réponse personnalisée…",
     statsAIExchanges: "Échanges de modèles",
     //market
     marketMineSectionTitle: "Vos articles",

+ 1 - 1
src/configs/oasis-config.json

@@ -54,4 +54,4 @@
   "ssbLogStream": {
     "limit": 2000
   }
-}
+}

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

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

+ 1 - 1
src/server/package.json

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

+ 128 - 121
src/views/AI_view.js

@@ -3,129 +3,136 @@ const { template, i18n } = require('./main_views');
 const { renderUrl } = require('../backend/renderUrl');
 
 exports.aiView = (history = [], userPrompt = '') => {
-    return template(
-        i18n.aiTitle,
-        section(
-            div({ class: "tags-header" },
-                h2(i18n.aiTitle),
-                p(i18n.aiDescription),
-                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(),
-                    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(),
-                ...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(...renderUrl(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}:`),
-                            ...String(entry.answer || '')
-                                .split('\n\n')
-                                .flatMap(paragraph =>
-                                    paragraph
-                                        .split('\n')
-                                        .map(line =>
-                                            p({ style: "margin-bottom: 1.2em;" }, ...renderUrl(line.trim()))
-                                        )
-                                )
-                        ),
-                        div({
-                            class: 'ai-train-bar',
-                            style: `
-                                display:flex;
-                                align-items:center;
-                                gap:12px;
-                                margin: 12px auto 8px auto;
-                                max-width: 800px;
-                                padding: 8px 0;
-                                border-top: 1px solid #2a2a2a;
-                            `
-                        },
-                            Array.isArray(entry.snippets) && entry.snippets.length
-                                ? span({ style: 'color:#9aa; font-size:0.95em;' }, `${i18n.aiSnippetsUsed}: ${entry.snippets.length}`)
-                                : null,
-                            h2(`${i18n.statsAITraining}:`),
-                            entry.trainStatus === 'approved'
-                                ? span({ style: 'color:#5ad25a; font-weight:600;' }, i18n.aiTrainApproved)
-                                : entry.trainStatus === 'rejected'
-                                    ? span({ style: 'color:#ff6b6b; font-weight:600;' }, i18n.aiTrainRejected)
-                                    : null,
-                            entry.trainStatus === 'approved' || entry.trainStatus === 'rejected'
-                                ? null
-                                : form({ method: 'POST', action: '/ai/approve', style: 'display:inline-block;' },
-                                    input({ type: 'hidden', name: 'ts', value: String(entry.timestamp) }),
-                                    button({ type: 'submit', class: 'approve-btn', style: 'background:#1e7e34;color:#fff;border:none;padding:0.45em 0.9em;border-radius:6px;cursor:pointer;' }, i18n.aiApproveTrain)
-                                ),
-                            entry.trainStatus === 'approved' || entry.trainStatus === 'rejected'
-                                ? null
-                                : form({ method: 'POST', action: '/ai/reject', style: 'display:inline-block;' },
-                                    input({ type: 'hidden', name: 'ts', value: String(entry.timestamp) }),
-                                    button({ type: 'submit', class: 'reject-btn', style: 'background:#a71d2a;color:#fff;border:none;padding:0.45em 0.9em;border-radius:6px;cursor:pointer;' }, i18n.aiRejectTrain)
-                                )
-                        )
+  return template(
+    i18n.aiTitle,
+    section(
+      div({ class: "tags-header" },
+        h2(i18n.aiTitle),
+        p(i18n.aiDescription),
+        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(),
+          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)
+          )
+        ),
+        br(),
+        ...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(...renderUrl(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}:`),
+              ...String(entry.answer || '')
+                .split('\n\n')
+                .flatMap(paragraph =>
+                  paragraph
+                    .split('\n')
+                    .map(line =>
+                      p({ style: "margin-bottom: 1.2em;" }, ...renderUrl(line.trim()))
                     )
                 )
+            ),
+            div({
+              class: 'ai-train-bar',
+              style: `
+                display:flex;
+                align-items:center;
+                gap:12px;
+                margin: 12px auto 8px auto;
+                max-width: 800px;
+                padding: 8px 0;
+                border-top: 1px solid #2a2a2a;
+                flex-wrap: wrap;
+              `
+            },
+              Array.isArray(entry.snippets) && entry.snippets.length
+                ? span({ style: 'color:#9aa; font-size:0.95em;' }, `${i18n.aiSnippetsUsed}: ${entry.snippets.length}`)
+                : null,
+              h2(`${i18n.statsAITraining}:`),
+              entry.trainStatus === 'approved'
+                ? span({ style: 'color:#5ad25a; font-weight:600;' }, i18n.aiTrainApproved)
+                : entry.trainStatus === 'rejected'
+                  ? span({ style: 'color:#ff6b6b; font-weight:600;' }, i18n.aiTrainRejected)
+                  : null,
+              entry.trainStatus === 'approved' || entry.trainStatus === 'rejected'
+                ? null
+                : div({ style: 'display:flex; flex-direction:column; gap:8px; width:100%;' },
+                    div({ style: 'display:flex; gap:8px; flex-wrap:wrap;' },
+                      form({ method: 'POST', action: '/ai/approve', style: 'display:inline-block;' },
+                        input({ type: 'hidden', name: 'ts', value: String(entry.timestamp) }),
+                        button({ type: 'submit', class: 'approve-btn', style: 'background:#1e7e34;color:#fff;border:none;padding:0.45em 0.9em;border-radius:6px;cursor:pointer;' }, i18n.aiApproveTrain)
+                      ),
+                      form({ method: 'POST', action: '/ai/reject', style: 'display:inline-block;' },
+                        input({ type: 'hidden', name: 'ts', value: String(entry.timestamp) }),
+                        button({ type: 'submit', class: 'reject-btn', style: 'background:#a71d2a;color:#fff;border:none;padding:0.45em 0.9em;border-radius:6px;cursor:pointer;' }, i18n.aiRejectTrain)
+                      )
+                    ),
+                    form({ method: 'POST', action: '/ai/approve', style: 'display:flex; flex-direction:column; gap:6px;' },
+                      input({ type: 'hidden', name: 'ts', value: String(entry.timestamp) }),
+                      textarea({ name: 'custom', rows: 3, placeholder: i18n.aiCustomAnswerPlaceholder, style: 'width:100%;' }),
+                      button({ type: 'submit', class: 'approve-custom-btn', style: 'align-self:flex-start;background:#0d6efd;color:#fff;border:none;padding:0.45em 0.9em;border-radius:6px;cursor:pointer;' }, i18n.aiApproveCustomTrain)
+                    )
+                  )
             )
+          )
         )
-    );
+      )
+    )
+  );
 };
-