diff --git a/index.html b/index.html index 0f8e302..98ae4c8 100644 --- a/index.html +++ b/index.html @@ -478,6 +478,8 @@ + + diff --git a/js/chat/chat-core.js b/js/chat/chat-core.js index 0428e9f..53a14d0 100644 --- a/js/chat/chat-core.js +++ b/js/chat/chat-core.js @@ -421,17 +421,60 @@ document.addEventListener("DOMContentLoaded", () => { }); async function handleToolJson(raw, { imageUrls, audioUrls }) { - try { - const obj = JSON.parse(raw); + const obj = (window.repairJson || (() => ({ text: raw })))(raw); + let handled = false; + + if (obj.tool) { const fn = toolbox.get(obj.tool); - if (!fn) return { handled: false, text: raw }; - const res = await fn(obj); - if (res?.imageUrl) imageUrls.push(res.imageUrl); - if (res?.audioUrl) audioUrls.push(res.audioUrl); - return { handled: true, text: res?.text || '' }; - } catch { - return { handled: false, text: raw }; + if (fn) { + try { + const res = await fn(obj); + if (res?.imageUrl) imageUrls.push(res.imageUrl); + if (res?.audioUrl) audioUrls.push(res.audioUrl); + handled = true; + return { handled: true, text: res?.text || '' }; + } catch (e) { + console.warn('tool execution failed', e); + } + } + } + + const imgPrompts = obj.image ? [obj.image] : Array.isArray(obj.images) ? obj.images : []; + for (const prompt of imgPrompts) { + if (!(window.polliLib && window.polliClient) || !prompt) continue; + try { + const url = window.polliLib.mcp.generateImageUrl(window.polliClient, { prompt }); + imageUrls.push(url); + handled = true; + } catch (e) { + console.warn('polliLib generateImageUrl failed', e); + } } + + const audioText = obj.audio || obj.tts; + if (audioText && window.polliLib && window.polliClient) { + try { + const blob = await window.polliLib.tts(audioText, { model: 'openai-audio' }, window.polliClient); + const url = URL.createObjectURL(blob); + audioUrls.push(url); + handled = true; + } catch (e) { + console.warn('polliLib tts failed', e); + } + } + + const command = obj.ui || obj.command; + if (command) { + if (validateUICommand(command)) { + try { executeCommand(command); } catch (e) { console.warn('executeCommand failed', e); } + handled = true; + } else { + console.warn('invalid ui command', command); + } + } + + const text = typeof obj.text === 'string' ? obj.text : raw; + return { handled, text }; } function handleVoiceCommand(text) { diff --git a/js/chat/json-repair.js b/js/chat/json-repair.js new file mode 100644 index 0000000..a371820 --- /dev/null +++ b/js/chat/json-repair.js @@ -0,0 +1,27 @@ +export function repairJson(raw) { + if (typeof raw !== 'string') return { text: '' }; + let text = raw.trim(); + if (!text) return { text: '' }; + const attempts = []; + attempts.push(text); + // Attempt: replace single quotes with double quotes + attempts.push(text.replace(/'/g, '"')); + // Attempt: quote unquoted keys, replace single quotes, remove trailing commas + attempts.push( + text + .replace(/([,{]\s*)([A-Za-z0-9_]+)\s*:/g, '$1"$2":') + .replace(/'/g, '"') + .replace(/,\s*([}\]])/g, '$1') + ); + for (const str of attempts) { + try { + return JSON.parse(str); + } catch {} + } + // Fallback: treat as plain text + return { text }; +} + +if (typeof window !== 'undefined') { + window.repairJson = repairJson; +} diff --git a/js/chat/markdown-sanitizer.js b/js/chat/markdown-sanitizer.js new file mode 100644 index 0000000..b323e5d --- /dev/null +++ b/js/chat/markdown-sanitizer.js @@ -0,0 +1,16 @@ +export const defaultBlockedFenceTypes = ['image', 'audio', 'ui']; + +export function sanitizeMarkdown(content, blocked = defaultBlockedFenceTypes) { + if (!content) return ''; + const pattern = /```(\w+)\n[\s\S]*?```/g; + return content.replace(pattern, (match, type) => { + return blocked.includes(type.toLowerCase()) ? '' : match; + }); +} + +if (typeof window !== 'undefined') { + window.sanitizeMarkdown = sanitizeMarkdown; + if (!window.blockedFenceTypes) { + window.blockedFenceTypes = [...defaultBlockedFenceTypes]; + } +} diff --git a/package.json b/package.json index 7b2ec02..349f012 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "test": "node tests/pollilib-smoke.mjs && node tests/markdown-sanitization.mjs && node tests/ai-response.mjs && node tests/json-tools.mjs" + "test": "node tests/pollilib-smoke.mjs && node tests/markdown-sanitization.mjs && node tests/ai-response.mjs && node tests/json-tools.mjs && node tests/json-repair.mjs" }, "devDependencies": { "marked": "^11.2.0" diff --git a/prompts/ai-instruct.md b/prompts/ai-instruct.md index b4aaf5a..e103d1c 100644 --- a/prompts/ai-instruct.md +++ b/prompts/ai-instruct.md @@ -119,22 +119,30 @@ tell me a joke in a calm tone ## JSON Tools -- As an alternative to fenced blocks, respond with a JSON object to invoke tools. -- The object must include a `tool` field: - - `image` with a `prompt` string to generate an image. - - `tts` with a `text` string for text-to-speech. - - `ui` with a `command` object that follows `docs/ui-command.schema.json`. -- Example: +- As an alternative to fenced blocks, respond with a JSON object. +- The object may include: + - `tool` to invoke a tool (`image`, `tts`, or `ui`). + - `text` for plain responses. + - `image` or `images` with prompt strings to generate images. + - `audio` with text for text-to-speech. + - `command`/`ui` objects that follow `docs/ui-command.schema.json`. +- Examples: ```json {"tool":"image","prompt":"a glowing neon cityscape at night with flying cars"} ``` ```json -{"tool":"ui","command":{"action":"openScreensaver"}} +{"text":"Hello there"} +``` + +```json +{"images":["a tiny house"],"text":"Here you go"} ``` +- Always return valid JSON (double quotes, no trailing commas). - Do not include extra commentary outside the JSON object. +- If you must send plain text, wrap it as `{ "text": "..." }`. --- diff --git a/tests/json-repair.mjs b/tests/json-repair.mjs new file mode 100644 index 0000000..6f6b17f --- /dev/null +++ b/tests/json-repair.mjs @@ -0,0 +1,11 @@ +import { strict as assert } from 'node:assert'; +import { repairJson } from '../js/chat/json-repair.js'; + +const fixed = repairJson("{tool:'image', prompt:'apple',}"); +assert.equal(fixed.tool, 'image'); +assert.equal(fixed.prompt, 'apple'); + +const plain = repairJson('just some text'); +assert.deepEqual(plain, { text: 'just some text' }); + +console.log('json-repair test passed'); diff --git a/tests/pollilib-smoke.mjs b/tests/pollilib-smoke.mjs index 216bc54..aa2b53a 100644 --- a/tests/pollilib-smoke.mjs +++ b/tests/pollilib-smoke.mjs @@ -26,7 +26,7 @@ async function step(name, fn) { const started = Date.now(); try { const info = await fn(); - push(name, true, info ?? `ok in ${Date.now()-started}ms`); + push(name, true, info ?? `ok in ${Date.now() - started}ms`); } catch (err) { push(name, false, `${err?.message || err}`); } @@ -43,46 +43,113 @@ function summary() { return { ok, fail, text: lines.join('\n') }; } -// Tests -await step('textModels returns JSON', async () => { - const models = await textModels(client); - const type = typeof models; - if (!(type === 'object' && models)) throw new Error('models is not object'); - // Record a few keys for debugging - const keys = Array.isArray(models) ? models.slice(0, 3) : Object.keys(models).slice(0, 3); - return `keys: ${JSON.stringify(keys)}`; -}); - -await step('text(prompt) returns string', async () => { - const out = await textGet('Say ok', { model: 'openai-mini' }, client); - if (typeof out !== 'string' || !out.length) throw new Error('empty text output'); - return `len=${out.length}`; -}); - -await step('chat basic response', async () => { - const messages = [ - { role: 'system', content: 'You are concise.' }, - { role: 'user', content: 'Reply with the word: ok' } - ]; - const data = await chat({ messages, /* model omitted to use server default */ }, client); - const content = data?.choices?.[0]?.message?.content; - if (!content || typeof content !== 'string') throw new Error('missing choices[0].message.content'); - return `len=${content.length}`; -}); +// Detect network availability for Pollinations APIs +let networkOk = true; +try { + const resp = await fetch('https://image.pollinations.ai/ping', { method: 'HEAD' }); + if (!resp.ok) throw new Error(`status ${resp.status}`); +} catch (err) { + networkOk = false; + push('pollinations network check', true, `skipped network tests: ${err?.message || err}`); +} -await step('search convenience returns text', async () => { - const out = await search('2+2=?', 'searchgpt', client); - if (typeof out !== 'string' || !out.length) throw new Error('empty search output'); - return `len=${out.length}`; -}); +if (networkOk) { + await step('textModels returns JSON', async () => { + const models = await textModels(client); + const type = typeof models; + if (!(type === 'object' && models)) throw new Error('models is not object'); + const keys = Array.isArray(models) ? models.slice(0, 3) : Object.keys(models).slice(0, 3); + return `keys: ${JSON.stringify(keys)}`; + }); + + await step('text(prompt) returns string', async () => { + const out = await textGet('Say ok', { model: 'openai-mini' }, client); + if (typeof out !== 'string' || !out.length) throw new Error('empty text output'); + return `len=${out.length}`; + }); + + await step('chat basic response', async () => { + const messages = [ + { role: 'system', content: 'You are concise.' }, + { role: 'user', content: 'Reply with the word: ok' } + ]; + const data = await chat({ messages, /* model omitted to use server default */ }, client); + const content = data?.choices?.[0]?.message?.content; + if (!content || typeof content !== 'string') throw new Error('missing choices[0].message.content'); + return `len=${content.length}`; + }); + + await step('search convenience returns text', async () => { + const out = await search('2+2=?', 'searchgpt', client); + if (typeof out !== 'string' || !out.length) throw new Error('empty search output'); + return `len=${out.length}`; + }); + + await step('imageModels returns JSON', async () => { + const models = await imageModels(client); + const type = typeof models; + if (!(type === 'object' && models)) throw new Error('image models is not object'); + const keys = Array.isArray(models) ? models.slice(0, 3) : Object.keys(models).slice(0, 3); + return `keys: ${JSON.stringify(keys)}`; + }); + + await step('image fetch small blob', async () => { + const blob = await image('tiny test pixel art red square', { width: 32, height: 32, private: true, nologo: true, safe: true }, client); + if (!blob || typeof blob.size !== 'number' || blob.size <= 0) throw new Error('empty image blob'); + return `blob size=${blob.size}`; + }); + + async function blobToBase64(b) { + const ab = await b.arrayBuffer(); + const bytes = new Uint8Array(ab); + let bin = ''; + const chunk = 0x8000; + for (let i = 0; i < bytes.length; i += chunk) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk)); + return Buffer.from(bin, 'binary').toString('base64'); + } -await step('imageModels returns JSON', async () => { - const models = await imageModels(client); - const type = typeof models; - if (!(type === 'object' && models)) throw new Error('image models is not object'); - const keys = Array.isArray(models) ? models.slice(0, 3) : Object.keys(models).slice(0, 3); - return `keys: ${JSON.stringify(keys)}`; -}); + await step('mcp generateImageBase64 returns base64', async () => { + const b64 = await mcp.generateImageBase64(client, { prompt: 'tiny blue square icon', width: 16, height: 16, private: true, nologo: true, safe: true }); + if (typeof b64 !== 'string' || b64.length < 20) throw new Error('short base64'); + return `len=${b64.length}`; + }); + + await step('vision with data URL', async () => { + const blob = await image('tiny green square icon', { width: 16, height: 16, private: true, nologo: true, safe: true }, client); + const b64 = await blobToBase64(blob); + const dataUrl = `data:image/png;base64,${b64}`; + const resp = await vision({ imageUrl: dataUrl, question: 'One word color name only.' }, client); + const msg = resp?.choices?.[0]?.message?.content; + if (!msg || typeof msg !== 'string') throw new Error('vision no content'); + return `len=${msg.length}`; + }); + + await step('audio.tts returns audio blob', async () => { + const blob = await tts('ok', { voice: 'alloy', model: 'openai-audio' }, client); + if (!blob || typeof blob.size !== 'number' || blob.size <= 0) throw new Error('empty tts blob'); + return `blob size=${blob.size}`; + }); + + await step('mcp list helpers return arrays/objects', async () => { + const ims = await mcp.listImageModels(client); + const tms = await mcp.listTextModels(client); + const voices = await mcp.listAudioVoices(client); + if (typeof ims !== 'object' || !ims) throw new Error('listImageModels not object'); + if (typeof tms !== 'object' || !tms) throw new Error('listTextModels not object'); + if (!Array.isArray(voices)) throw new Error('listAudioVoices not array'); + return `voices: ${voices.length}`; + }); + + await step('pipeline end-to-end', async () => { + const p = new pipeline.Pipeline() + .step(new pipeline.TextGetStep({ prompt: 'Say ok', outKey: 't', params: { model: 'openai-mini' } })) + .step(new pipeline.ImageStep({ prompt: 'tiny emoji like red dot', outKey: 'img', params: { width: 16, height: 16, private: true, nologo: true, safe: true } })) + .step(new pipeline.TtsStep({ text: 'ok', outKey: 'snd', params: { model: 'openai-audio' } })); + const ctx = await p.execute({ client }); + if (!ctx.get('t') || !ctx.get('img')?.blob || !ctx.get('snd')?.blob) throw new Error('pipeline missing outputs'); + return 'ok'; + }); +} await step('mcp generateImageUrl builds URL', async () => { const url = mcp.generateImageUrl(client, { prompt: 'simple red square icon', width: 32, height: 32, private: true, nologo: true }); @@ -90,53 +157,6 @@ await step('mcp generateImageUrl builds URL', async () => { return url.slice(0, 80) + '…'; }); -await step('image fetch small blob', async () => { - const blob = await image('tiny test pixel art red square', { width: 32, height: 32, private: true, nologo: true, safe: true }, client); - if (!blob || typeof blob.size !== 'number' || blob.size <= 0) throw new Error('empty image blob'); - return `blob size=${blob.size}`; -}); - -async function blobToBase64(b) { - const ab = await b.arrayBuffer(); - const bytes = new Uint8Array(ab); - let bin = ''; - const chunk = 0x8000; - for (let i = 0; i < bytes.length; i += chunk) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk)); - return Buffer.from(bin, 'binary').toString('base64'); -} - -await step('mcp generateImageBase64 returns base64', async () => { - const b64 = await mcp.generateImageBase64(client, { prompt: 'tiny blue square icon', width: 16, height: 16, private: true, nologo: true, safe: true }); - if (typeof b64 !== 'string' || b64.length < 20) throw new Error('short base64'); - return `len=${b64.length}`; -}); - -await step('vision with data URL', async () => { - const blob = await image('tiny green square icon', { width: 16, height: 16, private: true, nologo: true, safe: true }, client); - const b64 = await blobToBase64(blob); - const dataUrl = `data:image/png;base64,${b64}`; - const resp = await vision({ imageUrl: dataUrl, question: 'One word color name only.' }, client); - const msg = resp?.choices?.[0]?.message?.content; - if (!msg || typeof msg !== 'string') throw new Error('vision no content'); - return `len=${msg.length}`; -}); - -await step('audio.tts returns audio blob', async () => { - const blob = await tts('ok', { voice: 'alloy', model: 'openai-audio' }, client); - if (!blob || typeof blob.size !== 'number' || blob.size <= 0) throw new Error('empty tts blob'); - return `blob size=${blob.size}`; -}); - -await step('mcp list helpers return arrays/objects', async () => { - const ims = await mcp.listImageModels(client); - const tms = await mcp.listTextModels(client); - const voices = await mcp.listAudioVoices(client); - if (typeof ims !== 'object' || !ims) throw new Error('listImageModels not object'); - if (typeof tms !== 'object' || !tms) throw new Error('listTextModels not object'); - if (!Array.isArray(voices)) throw new Error('listAudioVoices not array'); - return `voices: ${voices.length}`; -}); - await step('tools.functionTool and ToolBox shape', async () => { const def = tools.functionTool('echo', 'Echo back input', { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] }); if (def?.type !== 'function' || !def.function?.name) throw new Error('bad function tool shape'); @@ -146,16 +166,6 @@ await step('tools.functionTool and ToolBox shape', async () => { return 'ok'; }); -await step('pipeline end-to-end', async () => { - const p = new pipeline.Pipeline() - .step(new pipeline.TextGetStep({ prompt: 'Say ok', outKey: 't', params: { model: 'openai-mini' } })) - .step(new pipeline.ImageStep({ prompt: 'tiny emoji like red dot', outKey: 'img', params: { width: 16, height: 16, private: true, nologo: true, safe: true } })) - .step(new pipeline.TtsStep({ text: 'ok', outKey: 'snd', params: { model: 'openai-audio' } })); - const ctx = await p.execute({ client }); - if (!ctx.get('t') || !ctx.get('img')?.blob || !ctx.get('snd')?.blob) throw new Error('pipeline missing outputs'); - return 'ok'; -}); - await step('index.html contains critical tags', async () => { const p = path.join(process.cwd(), 'index.html'); const html = await fs.readFile(p, 'utf8');