diff --git a/index.html b/index.html
index 0f8e302..98ae4c8 100644
--- a/index.html
+++ b/index.html
@@ -478,6 +478,8 @@
Voice Chat
+
+
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');