From facf0ffa7d13d4bbcadc9ba054f9163e94a5928a Mon Sep 17 00:00:00 2001 From: VoiceBox Dev Date: Sun, 12 Jul 2026 14:31:42 +0530 Subject: [PATCH 01/37] feat: add UI preview mockup for Agent Chat and Provider Settings --- .../freeclaw/voice-box/docs/ui-preview.html | 673 ++++++++++++++++++ .../freeclaw/voice-box/public/ui-preview.html | 673 ++++++++++++++++++ 2 files changed, 1346 insertions(+) create mode 100644 freeclaw/freeclaw/voice-box/docs/ui-preview.html create mode 100644 freeclaw/freeclaw/voice-box/public/ui-preview.html diff --git a/freeclaw/freeclaw/voice-box/docs/ui-preview.html b/freeclaw/freeclaw/voice-box/docs/ui-preview.html new file mode 100644 index 0000000..d514551 --- /dev/null +++ b/freeclaw/freeclaw/voice-box/docs/ui-preview.html @@ -0,0 +1,673 @@ + + + + + + Voice Box Admin — UI Preview + + + +
+ + + + +
+ +
+
+
+

+ 🤖 Admin Agent +

+

Natural language admin assistant — approves every action before executing

+
+
+ +
+
+ +
+
AGENT
+
+ Hi! I'm your admin assistant. I can help you manage posts, users, polls, and analytics.

+ Try commands like:
+ • "Show me all spam posts"
+ • "Hide post about homework"
+ • "How many posts this week?"
+ • "Create a poll about library hours"

+ What would you like to do? +
+
+ + +
+
YOU
+
Hide all spam posts
+
+ + +
+
AGENT
+
+ I found 3 spam posts. Here's what I'll do: +
+
+ + +
+
+
+
+ 🔇 Hide Post +
post_abc123 · reported 2h ago
+
+ SPAM +
+

"Buy cheap followers for your school account!!!"

+
+ hidden: false + + hidden: true +
+
📋 Reason: Promotional spam with external link — violates community guidelines
+
+ + +
+
+
+ + +
+
+
+
+ 🔇 Hide Post +
post_def456 · reported 5h ago
+
+ SPAM +
+

"FREE CRYPTO AIRDROP! Claim your tokens now 🚀"

+
+ hidden: false + + hidden: true +
+
📋 Reason: Cryptocurrency scam — phishing attempt targeting students
+
+ + +
+
+
+ + +
+
+
+
+ 🔇 Hide Post +
post_ghi789 · reported 1d ago
+
+ SPAM +
+

"Click here to win a free iPhone! Limited time offer!"

+
+ hidden: false + + hidden: true +
+
📋 Reason: Phishing attempt — external link to suspicious domain
+
+ + +
+
+
+ + +
+ +
+ + + +
+ + +
+ + +
+
+
+ + +
+

Settings

+

Manage API providers, admin password, and system configuration

+ + +
+
+ + API Providers + 7 providers · 2 active +
+ + +
+
1
+
+
OpenAI
+
gpt-4o
+
+
sk-...3f8a
+
+ + Active +
+
+ + +
+
+ + +
+
2
+
+
Anthropic
+
claude-sonnet-4-6
+
+
sk-ant-...b2c1
+
+ + Active +
+
+ + +
+
+ + +
+
3
+
+
Google Gemini
+
gemini-2.5-pro
+
+
Not configured
+
+ + Off +
+
+ + +
+
+ + +
+
4
+
+
NVIDIA NIM
+
meta/llama-3.1-70b-instruct
+
+
nvapi-...9d2e
+
+ + Active +
+
+ + +
+
+ + +
+
5
+
+
Mistral
+
mistral-large-latest
+
+
Not configured
+
+ + Off +
+
+ + +
+
+ + +
+
6
+
+
DeepSeek
+
deepseek-chat
+
+
Not configured
+
+ + Off +
+
+ + +
+
+ + +
+
7
+
+
Groq
+
llama-3.3-70b-versatile
+
+
Not configured
+
+ + Off +
+
+ + +
+
+ +
+ + +
+ +

+ ℹ️ Providers are tried in priority order (1→7). If one fails (timeout, bad key, rate limit), the next is used automatically. + Environment variables (NVIDIA_API_KEY, ANTHROPIC_API_KEY) are used as fallback if no DB keys are configured. +

+
+ + +
+
+ + Change Admin Password +
+ + + +

Passwords are hashed with SHA-256 in your browser before transmission.

+
+ + +
+
+ + Security Posture +
+
    +
  • Sessions expire automatically after 60 minutes.
  • +
  • Anonymous IDs are the only identifiers — no personal data in database.
  • +
  • All user input sanitized server-side; profanity masked automatically.
  • +
  • Rate limits: 3 posts/min, 5 comments/30s, 2 polls/2min per anonymous ID.
  • +
  • AI moderation uses configured provider with heuristic fallback.
  • +
+
+
+
+
+ + + + + + + diff --git a/freeclaw/freeclaw/voice-box/public/ui-preview.html b/freeclaw/freeclaw/voice-box/public/ui-preview.html new file mode 100644 index 0000000..d514551 --- /dev/null +++ b/freeclaw/freeclaw/voice-box/public/ui-preview.html @@ -0,0 +1,673 @@ + + + + + + Voice Box Admin — UI Preview + + + +
+ + + + +
+ +
+
+
+

+ 🤖 Admin Agent +

+

Natural language admin assistant — approves every action before executing

+
+
+ +
+
+ +
+
AGENT
+
+ Hi! I'm your admin assistant. I can help you manage posts, users, polls, and analytics.

+ Try commands like:
+ • "Show me all spam posts"
+ • "Hide post about homework"
+ • "How many posts this week?"
+ • "Create a poll about library hours"

+ What would you like to do? +
+
+ + +
+
YOU
+
Hide all spam posts
+
+ + +
+
AGENT
+
+ I found 3 spam posts. Here's what I'll do: +
+
+ + +
+
+
+
+ 🔇 Hide Post +
post_abc123 · reported 2h ago
+
+ SPAM +
+

"Buy cheap followers for your school account!!!"

+
+ hidden: false + + hidden: true +
+
📋 Reason: Promotional spam with external link — violates community guidelines
+
+ + +
+
+
+ + +
+
+
+
+ 🔇 Hide Post +
post_def456 · reported 5h ago
+
+ SPAM +
+

"FREE CRYPTO AIRDROP! Claim your tokens now 🚀"

+
+ hidden: false + + hidden: true +
+
📋 Reason: Cryptocurrency scam — phishing attempt targeting students
+
+ + +
+
+
+ + +
+
+
+
+ 🔇 Hide Post +
post_ghi789 · reported 1d ago
+
+ SPAM +
+

"Click here to win a free iPhone! Limited time offer!"

+
+ hidden: false + + hidden: true +
+
📋 Reason: Phishing attempt — external link to suspicious domain
+
+ + +
+
+
+ + +
+ +
+ + + +
+ + +
+ + +
+
+
+ + +
+

Settings

+

Manage API providers, admin password, and system configuration

+ + +
+
+ + API Providers + 7 providers · 2 active +
+ + +
+
1
+
+
OpenAI
+
gpt-4o
+
+
sk-...3f8a
+
+ + Active +
+
+ + +
+
+ + +
+
2
+
+
Anthropic
+
claude-sonnet-4-6
+
+
sk-ant-...b2c1
+
+ + Active +
+
+ + +
+
+ + +
+
3
+
+
Google Gemini
+
gemini-2.5-pro
+
+
Not configured
+
+ + Off +
+
+ + +
+
+ + +
+
4
+
+
NVIDIA NIM
+
meta/llama-3.1-70b-instruct
+
+
nvapi-...9d2e
+
+ + Active +
+
+ + +
+
+ + +
+
5
+
+
Mistral
+
mistral-large-latest
+
+
Not configured
+
+ + Off +
+
+ + +
+
+ + +
+
6
+
+
DeepSeek
+
deepseek-chat
+
+
Not configured
+
+ + Off +
+
+ + +
+
+ + +
+
7
+
+
Groq
+
llama-3.3-70b-versatile
+
+
Not configured
+
+ + Off +
+
+ + +
+
+ +
+ + +
+ +

+ ℹ️ Providers are tried in priority order (1→7). If one fails (timeout, bad key, rate limit), the next is used automatically. + Environment variables (NVIDIA_API_KEY, ANTHROPIC_API_KEY) are used as fallback if no DB keys are configured. +

+
+ + +
+
+ + Change Admin Password +
+ + + +

Passwords are hashed with SHA-256 in your browser before transmission.

+
+ + +
+
+ + Security Posture +
+
    +
  • Sessions expire automatically after 60 minutes.
  • +
  • Anonymous IDs are the only identifiers — no personal data in database.
  • +
  • All user input sanitized server-side; profanity masked automatically.
  • +
  • Rate limits: 3 posts/min, 5 comments/30s, 2 polls/2min per anonymous ID.
  • +
  • AI moderation uses configured provider with heuristic fallback.
  • +
+
+
+
+
+ + + + + + + From 70a160395dd8f3f8d717ce3618d180fa1fdc652e Mon Sep 17 00:00:00 2001 From: VoiceBox Dev Date: Sun, 12 Jul 2026 15:05:39 +0530 Subject: [PATCH 02/37] feat: add multi-provider API key management and agent chat Backend: - api/_providers.js: 7-provider registry (OpenAI, Anthropic, Gemini, NVIDIA, Mistral, DeepSeek, Groq) with failover chain, test/test_all/reorder endpoints, exports callLLMChain() - api/_agent-chat.js: Natural language admin agent with LLM tool calling, 11 action types, conversation history, execute/approve workflow - api/index.js: Added providers and agent-chat route entries - agent_conversations table with RLS - api_providers setting seeded in DB (7 providers, all disabled by default) Frontend: - ProviderSettings.tsx: Admin UI for managing providers (toggle, key input, model selector, test button, priority) - AgentChat.tsx: Chat interface with action cards, execute/cancel buttons, quick actions, session history - AdminSettings.tsx: Integrated ProviderSettings below password change - AgentPanel.tsx: Added Chat/Suggestions sub-tabs, AgentChat integrated --- freeclaw/freeclaw/voice-box/api/_admin.js | 133 ++++ .../freeclaw/voice-box/api/_agent-chat.js | 303 +++++++++ freeclaw/freeclaw/voice-box/api/_agent.js | 178 ++++++ freeclaw/freeclaw/voice-box/api/_ai.js | 246 ++++++++ .../freeclaw/voice-box/api/_announcement.js | 35 ++ freeclaw/freeclaw/voice-box/api/_assist.js | 129 ++++ freeclaw/freeclaw/voice-box/api/_auth.js | 74 +++ freeclaw/freeclaw/voice-box/api/_chat.js | 84 +++ freeclaw/freeclaw/voice-box/api/_comments.js | 90 +++ freeclaw/freeclaw/voice-box/api/_db-client.js | 18 + freeclaw/freeclaw/voice-box/api/_db-wake.js | 15 + freeclaw/freeclaw/voice-box/api/_me.js | 28 + freeclaw/freeclaw/voice-box/api/_polls.js | 125 ++++ freeclaw/freeclaw/voice-box/api/_posts.js | 177 ++++++ freeclaw/freeclaw/voice-box/api/_providers.js | 281 +++++++++ freeclaw/freeclaw/voice-box/api/_reactions.js | 78 +++ freeclaw/freeclaw/voice-box/api/_reports.js | 48 ++ freeclaw/freeclaw/voice-box/api/_upload.js | 32 + freeclaw/freeclaw/voice-box/api/_users.js | 38 ++ freeclaw/freeclaw/voice-box/api/index.js | 62 ++ ...6-07-12-api-providers-agent-chat-design.md | 574 ++++++++++++++++++ .../voice-box/src/pages/admin/AgentChat.tsx | 269 ++++++++ .../src/pages/admin/ProviderSettings.tsx | 191 ++++++ 23 files changed, 3208 insertions(+) create mode 100644 freeclaw/freeclaw/voice-box/api/_admin.js create mode 100644 freeclaw/freeclaw/voice-box/api/_agent-chat.js create mode 100644 freeclaw/freeclaw/voice-box/api/_agent.js create mode 100644 freeclaw/freeclaw/voice-box/api/_ai.js create mode 100644 freeclaw/freeclaw/voice-box/api/_announcement.js create mode 100644 freeclaw/freeclaw/voice-box/api/_assist.js create mode 100644 freeclaw/freeclaw/voice-box/api/_auth.js create mode 100644 freeclaw/freeclaw/voice-box/api/_chat.js create mode 100644 freeclaw/freeclaw/voice-box/api/_comments.js create mode 100644 freeclaw/freeclaw/voice-box/api/_db-client.js create mode 100644 freeclaw/freeclaw/voice-box/api/_db-wake.js create mode 100644 freeclaw/freeclaw/voice-box/api/_me.js create mode 100644 freeclaw/freeclaw/voice-box/api/_polls.js create mode 100644 freeclaw/freeclaw/voice-box/api/_posts.js create mode 100644 freeclaw/freeclaw/voice-box/api/_providers.js create mode 100644 freeclaw/freeclaw/voice-box/api/_reactions.js create mode 100644 freeclaw/freeclaw/voice-box/api/_reports.js create mode 100644 freeclaw/freeclaw/voice-box/api/_upload.js create mode 100644 freeclaw/freeclaw/voice-box/api/_users.js create mode 100644 freeclaw/freeclaw/voice-box/api/index.js create mode 100644 freeclaw/freeclaw/voice-box/docs/2026-07-12-api-providers-agent-chat-design.md create mode 100644 freeclaw/freeclaw/voice-box/src/pages/admin/AgentChat.tsx create mode 100644 freeclaw/freeclaw/voice-box/src/pages/admin/ProviderSettings.tsx diff --git a/freeclaw/freeclaw/voice-box/api/_admin.js b/freeclaw/freeclaw/voice-box/api/_admin.js new file mode 100644 index 0000000..058ac8f --- /dev/null +++ b/freeclaw/freeclaw/voice-box/api/_admin.js @@ -0,0 +1,133 @@ +// Admin auth (hashed password + session timeout), user management, logs, settings +import supabase from './_db-client.js'; +import { cors, isAdmin, auditLog, clean } from './_auth.js'; +import crypto from 'crypto'; + +const SESSION_MS = 60 * 60 * 1000; // 60 minute session timeout + +async function getSetting(key) { + const { data } = await supabase.from('settings').select('value').eq('key', key).maybeSingle(); + return data?.value ?? null; +} +async function setSetting(key, value) { + const { data } = await supabase.from('settings').select('key').eq('key', key).maybeSingle(); + if (data) await supabase.from('settings').update({ value }).eq('key', key); + else await supabase.from('settings').insert({ key, value }); +} + +export default async function handler(req, res) { + cors(res); + if (req.method === 'OPTIONS') return res.status(204).end(); + + try { + const b = req.body || {}; + const action = req.method === 'GET' ? req.query.action : b.action; + + // ---------- AUTH ---------- + if (action === 'login') { + const stored = await getSetting('admin_password'); + const hash = clean(b.password_hash, 128); + if (!stored?.hash || stored.hash !== hash) { + await auditLog('system', 'failed_login', 'Bad password attempt'); + return res.status(401).json({ error: 'Incorrect password.' }); + } + const token = crypto.randomBytes(24).toString('hex'); + const sessions = (await getSetting('admin_sessions')) || { tokens: [] }; + const now = Date.now(); + sessions.tokens = [...sessions.tokens.filter((t) => t.exp > now), { t: token, exp: now + SESSION_MS }].slice(-10); + await setSetting('admin_sessions', sessions); + await auditLog('admin', 'login', 'Admin signed in'); + return res.status(200).json({ token, expires_at: now + SESSION_MS }); + } + + if (action === 'verify') { + return res.status(200).json({ valid: await isAdmin(req) }); + } + + if (action === 'logout') { + const token = req.headers['x-admin-token']; + const sessions = (await getSetting('admin_sessions')) || { tokens: [] }; + sessions.tokens = sessions.tokens.filter((t) => t.t !== token); + await setSetting('admin_sessions', sessions); + return res.status(200).json({ ok: true }); + } + + // ---------- everything below requires admin ---------- + if (!(await isAdmin(req))) return res.status(403).json({ error: 'Admin only' }); + + if (action === 'change_password') { + const newHash = clean(b.new_hash, 128); + if (!newHash || newHash.length < 32) return res.status(400).json({ error: 'Invalid hash' }); + await setSetting('admin_password', { hash: newHash }); + await auditLog('admin', 'change_password', 'Admin password updated'); + return res.status(200).json({ ok: true }); + } + + if (action === 'logs') { + const { data } = await supabase.from('activity_logs').select('*').order('created_at', { ascending: false }).limit(300); + return res.status(200).json(data || []); + } + + if (action === 'log') { + await auditLog('admin', clean(b.log_action, 60), b.detail); + return res.status(200).json({ ok: true }); + } + + // ---------- USER MANAGEMENT ---------- + if (action === 'users') { + const { data: users } = await supabase.from('users_meta').select('*').order('created_at', { ascending: false }).limit(500); + // attach activity counts + const [{ data: posts }, { data: comments }, { data: reactions }] = await Promise.all([ + supabase.from('posts').select('author_id'), + supabase.from('comments').select('author_id'), + supabase.from('reactions').select('author_id'), + ]); + const count = (rows, id) => (rows || []).filter((r) => r.author_id === id).length; + return res.status(200).json((users || []).map((u) => ({ + ...u, + post_count: count(posts, u.anon_id), + comment_count: count(comments, u.anon_id), + reaction_count: count(reactions, u.anon_id), + }))); + } + + if (action === 'user_detail') { + const id = clean(b.anon_id, 40).toLowerCase(); + const [{ data: meta }, { data: posts }, { data: comments }, { data: reactions }, { data: reports }] = await Promise.all([ + supabase.from('users_meta').select('*').eq('anon_id', id).maybeSingle(), + supabase.from('posts').select('*').eq('author_id', id).order('created_at', { ascending: false }), + supabase.from('comments').select('*').eq('author_id', id).order('created_at', { ascending: false }), + supabase.from('reactions').select('*').eq('author_id', id), + supabase.from('reports').select('*').eq('author_id', id), + ]); + return res.status(200).json({ meta, posts: posts || [], comments: comments || [], reactions: reactions || [], reports: reports || [] }); + } + + if (action === 'update_user') { + const id = clean(b.anon_id, 40).toLowerCase(); + const { data: existing } = await supabase.from('users_meta').select('anon_id,warnings,strikes').eq('anon_id', id).maybeSingle(); + if (!existing) await supabase.from('users_meta').insert({ anon_id: id, warnings: [] }); + const patch = {}; + if (b.warn) { + patch.warnings = [...(existing?.warnings || []), { text: clean(b.warn, 300), at: new Date().toISOString() }]; + patch.strikes = (existing?.strikes || 0) + 1; + } + if (b.suspend_days !== undefined) { + patch.suspended_until = b.suspend_days === 0 ? null : new Date(Date.now() + b.suspend_days * 86400000).toISOString(); + } + if (typeof b.banned === 'boolean') patch.banned = b.banned; + if (b.notes !== undefined) patch.notes = clean(b.notes, 2000); + if (typeof b.spam_score === 'number') patch.spam_score = b.spam_score; + if (typeof b.strikes === 'number') patch.strikes = b.strikes; + const { data, error } = await supabase.from('users_meta').update(patch).eq('anon_id', id).select().single(); + if (error) throw error; + await auditLog('admin', 'update_user', `${id}: ${Object.keys(patch).join(', ')}`); + return res.status(200).json(data); + } + + return res.status(400).json({ error: 'Unknown action' }); + } catch (err) { + console.error('admin API error:', err); + return res.status(500).json({ error: err.message }); + } +} diff --git a/freeclaw/freeclaw/voice-box/api/_agent-chat.js b/freeclaw/freeclaw/voice-box/api/_agent-chat.js new file mode 100644 index 0000000..29b7aac --- /dev/null +++ b/freeclaw/freeclaw/voice-box/api/_agent-chat.js @@ -0,0 +1,303 @@ +// Admin Agent Chat — natural language interface for managing Voice Box. +// Uses LLM function calling to plan actions; admin approves before execution. +// All actions are audited. Destructive actions require explicit confirmation. +import supabase from './_db-client.js'; +import { cors, isAdmin, auditLog, clean } from './_auth.js'; +import { callLLMChain } from './_providers.js'; + +const TOOL_DEFS = [ + { name: 'get_posts', description: 'Retrieve posts with optional filters', parameters: { type: 'object', properties: { status: { type: 'string' }, category: { type: 'string' }, limit: { type: 'integer', default: 20 } } } }, + { name: 'update_post', description: "Update a post's status, priority, or content", parameters: { type: 'object', properties: { post_id: { type: 'string' }, status: { type: 'string' }, priority: { type: 'string' }, admin_reply: { type: 'string' }, hidden: { type: 'boolean' } }, required: ['post_id'] } }, + { name: 'delete_post', description: 'Soft-delete a post', parameters: { type: 'object', properties: { post_id: { type: 'string' }, reason: { type: 'string' } }, required: ['post_id', 'reason'] } }, + { name: 'warn_user', description: 'Issue a warning to an anonymous user', parameters: { type: 'object', properties: { anon_id: { type: 'string' }, reason: { type: 'string' } }, required: ['anon_id', 'reason'] } }, + { name: 'ban_user', description: 'Ban an anonymous user (prevents posting)', parameters: { type: 'object', properties: { anon_id: { type: 'string' }, reason: { type: 'string' } }, required: ['anon_id', 'reason'] } }, + { name: 'get_user_posts', description: 'Get all posts from a specific anonymous user', parameters: { type: 'object', properties: { anon_id: { type: 'string' } }, required: ['anon_id'] } }, + { name: 'create_poll', description: 'Create a new poll', parameters: { type: 'object', properties: { title: { type: 'string' }, options: { type: 'array', items: { type: 'string' } }, ptype: { type: 'string', enum: ['yesno', 'choice', 'rating'] } }, required: ['title'] } }, + { name: 'close_poll', description: 'Close a poll to new votes', parameters: { type: 'object', properties: { poll_id: { type: 'integer' } }, required: ['poll_id'] } }, + { name: 'get_analytics', description: 'Get platform analytics', parameters: { type: 'object', properties: { period: { type: 'string', enum: ['day', 'week', 'month', 'all'] } } } }, + { name: 'get_activity_logs', description: 'Retrieve recent activity logs', parameters: { type: 'object', properties: { limit: { type: 'integer', default: 50 } } } }, + { name: 'set_announcement', description: 'Set or clear a site-wide announcement', parameters: { type: 'object', properties: { text: { type: 'string' }, enabled: { type: 'boolean' } } } }, +]; + +const SYSTEM_PROMPT = `You are the Voice Box admin agent. You help school administrators manage their anonymous feedback platform. + +CAPABILITIES: +- View, edit, hide, delete posts +- Warn or ban anonymous users +- Create and manage polls +- Generate analytics reports +- View activity logs +- Post announcements + +RULES: +1. ALWAYS describe what you want to do before suggesting actions +2. NEVER auto-execute destructive actions (delete, ban) +3. Group related actions together when possible +4. Explain WHY you're recommending each action +5. Be concise — administrators are busy + +When you want to perform actions, respond with a JSON block like: +\`\`\`json +{ + "reply": "Brief explanation of what you found and recommend", + "actions": [ + { "tool": "tool_name", "args": { ... }, "reason": "Why this action" } + ] +} +\`\`\` + +If no actions are needed, just reply normally.`; + +// ─── Execute a single tool call against the database ────────────── +async function executeTool(toolName, args) { + switch (toolName) { + case 'get_posts': { + let q = supabase.from('posts').select('*').order('created_at', { ascending: false }).limit(args.limit || 20); + if (args.status) q = q.eq('status', args.status); + if (args.category) q = q.eq('category', args.category); + const { data } = await q; + return data || []; + } + case 'update_post': { + const patch = {}; + if (args.status) { patch.status = args.status; patch.updated_at = new Date().toISOString(); } + if (args.priority) patch.priority = args.priority; + if (args.admin_reply !== undefined) patch.admin_reply = clean(args.admin_reply, 1000); + if (typeof args.hidden === 'boolean') patch.hidden = args.hidden; + const { data, error } = await supabase.from('posts').update(patch).eq('id', args.post_id).select().single(); + if (error) throw error; + return data; + } + case 'delete_post': { + const { data, error } = await supabase.from('posts').update({ deleted: true, deleted_reason: clean(args.reason, 500), updated_at: new Date().toISOString() }).eq('id', args.post_id).select().single(); + if (error) throw error; + return data; + } + case 'warn_user': { + const { data: existing } = await supabase.from('users_meta').select('warnings,strikes').eq('anon_id', args.anon_id.toLowerCase()).maybeSingle(); + const warnings = [...(existing?.warnings || []), { text: clean(args.reason, 300), at: new Date().toISOString() }]; + const { error } = await supabase.from('users_meta').update({ warnings, strikes: (existing?.strikes || 0) + 1 }).eq('anon_id', args.anon_id.toLowerCase()); + if (error) throw error; + return { warned: true, anon_id: args.anon_id, total_warnings: warnings.length }; + } + case 'ban_user': { + const { error } = await supabase.from('users_meta').update({ banned: true, ban_reason: clean(args.reason, 500) }).eq('anon_id', args.anon_id.toLowerCase()); + if (error) throw error; + return { banned: true, anon_id: args.anon_id }; + } + case 'get_user_posts': { + const { data } = await supabase.from('posts').select('*').eq('author_id', args.anon_id.toLowerCase()).order('created_at', { ascending: false }); + return data || []; + } + case 'create_poll': { + const { data, error } = await supabase.from('polls').insert({ + title: clean(args.title, 200), + options: args.options || ['Yes', 'No'], + ptype: args.ptype || 'yesno', + author_id: 'ADMIN', + }).select().single(); + if (error) throw error; + return data; + } + case 'close_poll': { + const { error } = await supabase.from('polls').update({ archived: true }).eq('id', args.poll_id); + if (error) throw error; + return { closed: true, poll_id: args.poll_id }; + } + case 'get_analytics': { + const [{ data: posts }, { data: users }, { data: comments }, { data: reactions }, { data: polls }] = await Promise.all([ + supabase.from('posts').select('id,category,status,created_at'), + supabase.from('users_meta').select('anon_id,created_at'), + supabase.from('comments').select('id,created_at'), + supabase.from('reactions').select('id,kind'), + supabase.from('polls').select('id,title,total_votes'), + ]); + const cats = {}; + (posts || []).forEach((p) => { cats[p.category] = (cats[p.category] || 0) + 1; }); + const statuses = {}; + (posts || []).forEach((p) => { statuses[p.status] = (statuses[p.status] || 0) + 1; }); + return { + posts: (posts || []).length, + users: (users || []).length, + comments: (comments || []).length, + reactions: (reactions || []).length, + polls: (polls || []).length, + categories: cats, + statuses, + }; + } + case 'get_activity_logs': { + const { data } = await supabase.from('activity_logs').select('*').order('created_at', { ascending: false }).limit(args.limit || 50); + return data || []; + } + case 'set_announcement': { + const value = { text: clean(args.text, 500), enabled: !!args.enabled, updated_at: new Date().toISOString() }; + const { data: existing } = await supabase.from('settings').select('key').eq('key', 'announcement').maybeSingle(); + if (existing) await supabase.from('settings').update({ value }).eq('key', 'announcement'); + else await supabase.from('settings').insert({ key: 'announcement', value }); + return { ok: true }; + } + default: + throw new Error(`Unknown tool: ${toolName}`); + } +} + +// ─── Parse LLM response for actions ────────────────────────────── +function parseAgentResponse(text) { + // Try to extract JSON block from response + const jsonMatch = text.match(/```json\s*([\s\S]*?)```/) || text.match(/\{[\s\S]*"actions"[\s\S]*\}/); + if (jsonMatch) { + try { + const json = JSON.parse(jsonMatch[1] || jsonMatch[0]); + if (json.actions && Array.isArray(json.actions)) { + return { reply: json.reply || text, actions: json.actions }; + } + } catch { /* fall through */ } + } + return { reply: text, actions: [] }; +} + +// ─── HTTP Handler ──────────────────────────────────────────────── +export default async function handler(req, res) { + cors(res); + if (req.method === 'OPTIONS') return res.status(204).end(); + + try { + if (!(await isAdmin(req))) return res.status(403).json({ error: 'Admin only' }); + + const b = req.body || {}; + const action = req.method === 'GET' ? req.query.action : b.action; + + // chat — send message, get response with proposed actions + if (action === 'chat') { + const { message, session_id } = b; + if (!message) return res.status(400).json({ error: 'Message required' }); + const sid = clean(session_id, 60) || `s_${Date.now()}`; + + // Load conversation history + const { data: history } = await supabase.from('agent_conversations') + .select('role,content') + .eq('session_id', sid) + .order('created_at', { ascending: true }) + .limit(40); + + // Get fresh analytics context for the LLM + const [{ count: postCount }, { count: userCount }] = await Promise.all([ + supabase.from('posts').select('*', { count: 'exact', head: true }), + supabase.from('users_meta').select('*', { count: 'exact', head: true }), + ]); + + const contextMsg = `[Current platform state: ${postCount || 0} posts, ${userCount || 0} registered users. Current time: ${new Date().toISOString()}]`; + + const messages = [ + ...(history || []).map((h) => ({ role: h.role, content: h.content })), + { role: 'user', content: message }, + ]; + + // Call LLM with tool definitions + const systemWithTools = SYSTEM_PROMPT + `\n\nAvailable tools:\n${JSON.stringify(TOOL_DEFS, null, 2)}\n\n${contextMsg}`; + + const llmResult = await callLLMChain(systemWithTools, '', messages); + + if (!llmResult) { + return res.status(200).json({ + reply: "I couldn't connect to an AI provider. Please configure an API key in Settings → Provider Settings.", + actions: [], + session_id: sid, + provider: 'none', + }); + } + + const parsed = parseAgentResponse(llmResult.text); + + // Save user message + await supabase.from('agent_conversations').insert({ + session_id: sid, role: 'user', content: message, + }); + // Save assistant response + await supabase.from('agent_conversations').insert({ + session_id: sid, role: 'assistant', content: parsed.reply, + actions: parsed.actions, + }); + + await auditLog('admin', 'agent_chat', `Message: "${message.slice(0, 80)}" → ${parsed.actions.length} action(s) proposed`); + + return res.status(200).json({ + reply: parsed.reply, + actions: parsed.actions.map((a, i) => ({ + id: `act_${Date.now()}_${i}`, + tool: a.tool, + args: a.args, + reason: a.reason || '', + destructive: ['delete_post', 'ban_user'].includes(a.tool), + })), + requires_approval: parsed.actions.some((a) => ['delete_post', 'ban_user'].includes(a.tool)), + session_id: sid, + provider: llmResult.provider, + model: llmResult.model, + }); + } + + // execute — run approved actions + if (action === 'execute') { + const { actions: actionList, session_id } = b; + if (!Array.isArray(actionList) || !actionList.length) return res.status(400).json({ error: 'No actions to execute' }); + + const results = []; + for (const act of actionList) { + try { + const result = await executeTool(act.tool, act.args || {}); + results.push({ id: act.id, success: true, result }); + await auditLog('admin', `agent_execute_${act.tool}`, `Executed ${act.tool}(${JSON.stringify(act.args).slice(0, 120)}) → OK`); + } catch (e) { + results.push({ id: act.id, success: false, error: e.message }); + await auditLog('admin', `agent_execute_${act.tool}_FAIL`, `Failed ${act.tool}: ${e.message}`); + } + } + + // Save execution result in conversation + if (session_id) { + const summary = results.map((r) => `${r.id}: ${r.success ? 'OK' : r.error}`).join('; '); + await supabase.from('agent_conversations').insert({ + session_id: clean(session_id, 60), + role: 'system', + content: `Actions executed: ${summary}`, + }); + } + + return res.status(200).json({ results }); + } + + // history — get conversation history + if (action === 'history') { + const sid = clean(b.session_id || req.query.session_id || '', 60); + if (!sid) return res.status(400).json({ error: 'session_id required' }); + const { data } = await supabase.from('agent_conversations') + .select('*') + .eq('session_id', sid) + .order('created_at', { ascending: true }) + .limit(100); + return res.status(200).json(data || []); + } + + // sessions — list recent sessions + if (action === 'sessions') { + const { data } = await supabase.rpc ? { data: null } : { data: null }; + // Workaround: get unique session_ids from recent conversations + const { data: rows } = await supabase.from('agent_conversations') + .select('session_id, created_at') + .order('created_at', { ascending: false }) + .limit(200); + const sessions = {}; + (rows || []).forEach((r) => { + if (!sessions[r.session_id]) sessions[r.session_id] = { session_id: r.session_id, last_message: r.created_at }; + }); + return res.status(200).json(Object.values(sessions).slice(0, 20)); + } + + return res.status(400).json({ error: 'Unknown action' }); + } catch (err) { + console.error('agent-chat API error:', err); + return res.status(500).json({ error: err.message }); + } +} diff --git a/freeclaw/freeclaw/voice-box/api/_agent.js b/freeclaw/freeclaw/voice-box/api/_agent.js new file mode 100644 index 0000000..c0005e8 --- /dev/null +++ b/freeclaw/freeclaw/voice-box/api/_agent.js @@ -0,0 +1,178 @@ +// Approval-only AI Agent. +// The agent can DRAFT suggestions (status changes, replies, escalations, merges) +// but can NEVER act on the database itself. Every suggestion requires explicit +// admin approval; approving applies the change and writes a permanent audit log. +// Suggestions expire after 48 hours automatically. +import supabase from './_db-client.js'; +import { cors, isAdmin, auditLog, clean } from './_auth.js'; + +const EXPIRY_MS = 48 * 60 * 60 * 1000; + +/** Heuristic suggestion generator (deterministic; AI text optional upstream) */ +function generateSuggestions(posts) { + const out = []; + const now = Date.now(); + const urgentWords = /\b(urgent|danger|unsafe|injur|threat|bully|harass|emergency|fire|leak|assault)\b/i; + + for (const p of posts) { + if (p.deleted || p.hidden || p.type !== 'problem') continue; + const support = p.reactions?.support || 0; + const comments = p.comment_count || 0; + const ageDays = (now - +new Date(p.created_at)) / 86400000; + + // 1. Escalation: safety language or safety category still unverified + if (p.status === 'reported' && (urgentWords.test(p.title + ' ' + p.description) || ['Bullying', 'Security', 'Medical'].includes(p.category)) && p.priority !== 'critical') { + out.push({ + kind: 'escalation', target_id: p.id, critical: true, + title: `Escalate “${p.title}” to critical priority`, + content: { field: 'priority', from: p.priority, to: 'critical' }, + confidence: 0.8, + reasoning: `“${p.title}” is in a safety-sensitive category (${p.category}) or contains urgency language, but is still priority “${p.priority}” and unverified after ${ageDays.toFixed(1)} day(s). Recommend escalating to critical.`, + }); + } + + // 2. Status change: high engagement but still 'reported' + if (p.status === 'reported' && (support >= 3 || comments >= 3) && ageDays > 0.5) { + out.push({ + kind: 'status_change', target_id: p.id, critical: false, + title: `Mark “${p.title}” as Verified`, + content: { field: 'status', from: p.status, to: 'verified' }, + confidence: 0.72, + reasoning: `“${p.title}” has ${support} supports and ${comments} comments but hasn't been triaged in ${ageDays.toFixed(1)} day(s). Recommend marking as Verified to show the community it was seen.`, + }); + } + + // 3. Reply draft: solved without an official reply + if (p.status === 'solved' && !p.admin_reply) { + out.push({ + kind: 'reply', target_id: p.id, critical: false, + title: `Post an official reply on “${p.title}”`, + content: { field: 'admin_reply', from: '', to: `This issue has been resolved. Thank you for reporting “${p.title}” — please let us know if it happens again.` }, + confidence: 0.75, + reasoning: `“${p.title}” was marked solved but has no official reply. A short public reply closes the loop and builds trust.`, + }); + } + } + + // 4. Merge suggestions: strong word overlap in same category + const words = (t) => new Set(String(t).toLowerCase().split(/\W+/).filter((w) => w.length > 4)); + const open = posts.filter((p) => !p.deleted && !p.hidden && !p.merged_into && p.type === 'problem'); + for (let i = 0; i < open.length; i++) { + for (let j = i + 1; j < open.length; j++) { + if (open[i].category !== open[j].category) continue; + const wi = words(open[i].title + ' ' + open[i].description); + const wj = words(open[j].title + ' ' + open[j].description); + const overlap = [...wi].filter((w) => wj.has(w)).length; + if (overlap >= 4) { + const [keep, dup] = (open[i].reactions?.support || 0) >= (open[j].reactions?.support || 0) ? [open[i], open[j]] : [open[j], open[i]]; + out.push({ + kind: 'merge', target_id: dup.id, critical: false, + title: `Merge “${dup.title}” into “${keep.title}”`, + content: { field: 'merged_into', from: '', to: keep.id, keep_title: keep.title }, + confidence: 0.65, + reasoning: `“${dup.title}” appears to duplicate “${keep.title}” (${overlap} shared key words, same category). Merging combines their support.`, + }); + break; + } + } + } + return out.slice(0, 10); +} + +export default async function handler(req, res) { + cors(res); + if (req.method === 'OPTIONS') return res.status(204).end(); + + try { + if (!(await isAdmin(req))) return res.status(403).json({ error: 'Admin only' }); + + if (req.method === 'GET') { + // auto-expire old suggestions (48h) + const cutoff = new Date(Date.now() - EXPIRY_MS).toISOString(); + await supabase.from('agent_suggestions').update({ status: 'expired' }).eq('status', 'pending').lt('created_at', cutoff); + const { data, error } = await supabase.from('agent_suggestions').select('*').order('created_at', { ascending: false }).limit(100); + if (error) throw error; + return res.status(200).json(data); + } + + const b = req.body || {}; + + if (req.method === 'POST' && b.action === 'generate') { + const { data: posts } = await supabase.from('posts').select('*'); + // enrich with counts + const ids = (posts || []).map((p) => p.id); + const [{ data: reactions }, { data: comments }] = await Promise.all([ + supabase.from('reactions').select('target_id,kind').in('target_id', ids.length ? ids : ['_']), + supabase.from('comments').select('post_id').in('post_id', ids.length ? ids : ['_']).eq('deleted', false), + ]); + const rMap = {}; const cMap = {}; + (reactions || []).forEach((r) => { rMap[r.target_id] = rMap[r.target_id] || {}; rMap[r.target_id][r.kind] = (rMap[r.target_id][r.kind] || 0) + 1; }); + (comments || []).forEach((c) => { cMap[c.post_id] = (cMap[c.post_id] || 0) + 1; }); + const enriched = (posts || []).map((p) => ({ ...p, reactions: rMap[p.id] || {}, comment_count: cMap[p.id] || 0 })); + + const suggestions = generateSuggestions(enriched); + // skip ones already pending for the same target+kind + const { data: existing } = await supabase.from('agent_suggestions').select('target_id,kind').eq('status', 'pending'); + const dupe = new Set((existing || []).map((e) => `${e.kind}:${e.target_id}`)); + const fresh = suggestions.filter((s) => !dupe.has(`${s.kind}:${s.target_id}`)); + if (fresh.length) { + const { error } = await supabase.from('agent_suggestions').insert(fresh.map((s) => ({ + kind: s.kind, target_id: s.target_id, target_type: 'post', title: s.title, + content: s.content, confidence: s.confidence, + reasoning: s.reasoning, critical: s.critical, status: 'pending', + }))); + if (error) throw error; + } + await auditLog('ai-agent', 'generate_suggestions', `${fresh.length} new suggestion(s) drafted (read-only; awaiting admin approval)`); + return res.status(200).json({ created: fresh.length }); + } + + if (req.method === 'PUT') { + const { data: sug } = await supabase.from('agent_suggestions').select('*').eq('id', b.id).maybeSingle(); + if (!sug) return res.status(404).json({ error: 'Suggestion not found' }); + if (sug.status !== 'pending') return res.status(400).json({ error: 'Suggestion already resolved' }); + + if (b.action === 'dismiss') { + await supabase.from('agent_suggestions').update({ status: 'dismissed', resolved_at: new Date().toISOString(), outcome: 'Dismissed by admin — no action was taken.' }).eq('id', b.id); + await auditLog('admin', 'agent_dismiss', `Dismissed AI suggestion #${b.id} (${sug.kind}): ${String(sug.title || sug.reasoning).slice(0, 120)}`); + return res.status(200).json({ ok: true }); + } + + if (b.action === 'approve') { + // Critical suggestions require the confirmed flag (second-step confirmation) + if (sug.critical && b.confirmed !== true) { + return res.status(400).json({ error: 'This is a critical/safety suggestion — second-step confirmation required.' }); + } + const p = sug.content || {}; + const patch = {}; + // Support both my schema and legacy suggestion kinds + const targetStatus = p.to || p.status; + if (sug.kind === 'status_change' || sug.kind === 'solved_confirm') { + patch.status = targetStatus; + const map = { reported: 5, verified: 20, in_progress: 50, waiting: 70, solved: 100, archived: 100 }; + patch.progress = map[targetStatus] ?? 20; + const { data: post } = await supabase.from('posts').select('status_history').eq('id', sug.target_id).maybeSingle(); + patch.status_history = [...(post?.status_history || []), { status: targetStatus, at: new Date().toISOString(), note: p.status_note || 'Applied from AI suggestion (admin approved)' }]; + } + if (sug.kind === 'escalation') patch.priority = p.to || 'critical'; + if (sug.kind === 'reply') patch.admin_reply = clean(b.edited_text, 1000) || p.to || p.reply; + if (sug.kind === 'merge') { patch.merged_into = p.to || p.merge_into; patch.hidden = true; } + patch.updated_at = new Date().toISOString(); + + const { error } = await supabase.from('posts').update(patch).eq('id', sug.target_id); + if (error) throw error; + const outcome = `Approved by admin — applied ${sug.kind} on ${sug.target_id} (${p.from || '—'} → ${String(p.to || targetStatus).slice(0, 60)})`; + await supabase.from('agent_suggestions').update({ status: 'approved', resolved_at: new Date().toISOString(), outcome }).eq('id', b.id); + await auditLog('admin', 'agent_approve', `Approved AI suggestion #${b.id} (${sug.kind}) on ${sug.target_id}: ${p.from || '—'} → ${String(p.to || targetStatus).slice(0, 80)}`); + return res.status(200).json({ ok: true }); + } + + return res.status(400).json({ error: 'Unknown action' }); + } + + return res.status(405).json({ error: 'Method not allowed' }); + } catch (err) { + console.error('agent API error:', err); + return res.status(500).json({ error: err.message }); + } +} diff --git a/freeclaw/freeclaw/voice-box/api/_ai.js b/freeclaw/freeclaw/voice-box/api/_ai.js new file mode 100644 index 0000000..14ec526 --- /dev/null +++ b/freeclaw/freeclaw/voice-box/api/_ai.js @@ -0,0 +1,246 @@ +// AI Analysis — NVIDIA NIM (default) → Anthropic Claude (fallback) → built-in heuristics. +// All keys live server-side in environment variables. Structured JSON responses only. +import { cors, isAdmin } from './_auth.js'; + +const NIM_MODEL = process.env.NVIDIA_MODEL || 'meta/llama-3.1-70b-instruct'; +const CLAUDE_MODEL = process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-6'; + +function parseJson(text) { + try { + return JSON.parse(String(text || '').replace(/^```json?\s*/i, '').replace(/```\s*$/, '')); + } catch { return null; } +} + +/** NVIDIA NIM (OpenAI-compatible endpoint) */ +async function callNim(system, user) { + const key = process.env.NVIDIA_API_KEY; + if (!key) return null; + try { + const resp = await fetch('https://integrate.api.nvidia.com/v1/chat/completions', { + method: 'POST', + headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: NIM_MODEL, max_tokens: 2048, temperature: 0.2, + messages: [ + { role: 'system', content: system + '\nRespond with STRICT valid JSON only. No markdown, no prose.' }, + { role: 'user', content: user }, + ], + }), + }); + if (!resp.ok) { console.error('NIM error', resp.status, await resp.text()); return null; } + const data = await resp.json(); + return parseJson(data?.choices?.[0]?.message?.content); + } catch (e) { console.error('NIM fetch failed', e); return null; } +} + +/** Anthropic Claude fallback */ +async function callClaude(system, user) { + const key = process.env.ANTHROPIC_API_KEY; + if (!key) return null; + try { + const resp = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' }, + body: JSON.stringify({ + model: CLAUDE_MODEL, max_tokens: 2048, + system: system + '\nRespond with STRICT valid JSON only. No markdown, no prose.', + messages: [{ role: 'user', content: user }], + }), + }); + if (!resp.ok) { console.error('Claude error', resp.status, await resp.text()); return null; } + const data = await resp.json(); + return parseJson(data?.content?.[0]?.text); + } catch (e) { console.error('Claude fetch failed', e); return null; } +} + +/** Try NIM first, then Claude. Returns { engine, result } or null. */ +async function callLLM(system, user) { + const nim = await callNim(system, user); + if (nim) return { engine: `nvidia:${NIM_MODEL}`, result: nim }; + const claude = await callClaude(system, user); + if (claude) return { engine: `anthropic:${CLAUDE_MODEL}`, result: claude }; + return null; +} + +// ---------- Deterministic heuristic fallback (no API key needed) ---------- +function heuristicAnalysis(posts) { + const urgentWords = /\b(urgent|danger|unsafe|injur|threat|bully|harass|broken|emergency|health|fire|leak|assault)\b/i; + const scored = posts.map((p) => { + const support = p.reactions?.support || 0; + const disagree = p.reactions?.disagree || 0; + const comments = p.comment_count || 0; + const ageDays = Math.max(0.2, (Date.now() - new Date(p.created_at).getTime()) / 86400000); + const severity = { low: 1, medium: 2, high: 3, critical: 4 }[p.priority] || 2; + const textUrgency = urgentWords.test(p.title + ' ' + p.description) ? 2 : 0; + const growth = (support + comments) / ageDays; + const score = support * 3 + comments * 2 - disagree + severity * 3 + textUrgency * 4 + growth * 2; + return { + id: p.id, title: p.title, category: p.category, + urgency_score: Math.min(100, Math.round(score * 2.2)), + rank_score: Math.round(score * 10) / 10, + support_ratio: support + disagree > 0 ? Math.round((support / (support + disagree)) * 100) : 100, + flags: [ + ...(textUrgency ? ['urgency-keywords'] : []), + ...(p.category === 'Bullying' || p.category === 'Security' || p.category === 'Medical' ? ['safety-risk'] : []), + ...(disagree > support && disagree > 3 ? ['contested'] : []), + ], + recommended_action: severity >= 3 || textUrgency + ? 'Verify immediately and escalate to staff' + : comments > 4 ? 'High engagement — respond publicly' : 'Review within normal queue', + confidence: 0.62, + }; + }).sort((a, b) => b.rank_score - a.rank_score); + + const clusters = []; + const used = new Set(); + const words = (t) => new Set(t.toLowerCase().split(/\W+/).filter((w) => w.length > 4)); + for (let i = 0; i < posts.length; i++) { + if (used.has(posts[i].id)) continue; + const group = [posts[i].id]; + const wi = words(posts[i].title + ' ' + posts[i].description); + for (let j = i + 1; j < posts.length; j++) { + if (used.has(posts[j].id)) continue; + const wj = words(posts[j].title + ' ' + posts[j].description); + const overlap = [...wi].filter((w) => wj.has(w)).length; + if ((posts[i].category === posts[j].category && overlap >= 3) || overlap >= 5) { + group.push(posts[j].id); used.add(posts[j].id); + } + } + if (group.length > 1) clusters.push({ topic: posts[i].title, post_ids: group, count: group.length }); + } + + const catCount = {}; + posts.forEach((p) => { catCount[p.category] = (catCount[p.category] || 0) + 1; }); + const topCats = Object.entries(catCount).sort((a, b) => b[1] - a[1]).slice(0, 3); + + return { + engine: 'heuristic-fallback', + generated_at: new Date().toISOString(), + summary: `Analyzed ${posts.length} items. Top categories: ${topCats.map(([c, n]) => `${c} (${n})`).join(', ') || 'none'}. ${clusters.length} duplicate cluster(s) detected. ${scored.filter((s) => s.urgency_score > 70).length} item(s) flagged high urgency.`, + ranked_issues: scored.slice(0, 15), + duplicate_clusters: clusters, + safety_alerts: scored.filter((s) => s.flags.includes('safety-risk')).map((s) => ({ id: s.id, title: s.title, reason: 'Category indicates potential safety concern' })), + weekly_insights: { + total: posts.length, + high_urgency: scored.filter((s) => s.urgency_score > 70).length, + trending_category: topCats[0]?.[0] || 'N/A', + recommendation: 'Prioritize the top 3 ranked issues and publish status updates to maintain community trust.', + }, + }; +} + +function heuristicModeration(text) { + const bad = /\b(kill|hurt|attack|weapon|drugs|suicide)\b/i.test(text); + const bully = /\b(loser|stupid|ugly|hate you|worthless|idiot)\b/i.test(text); + const spam = /(http[s]?:\/\/|www\.|buy now|free money|click here)/i.test(text) || /(.)\1{6,}/.test(text); + return { + engine: 'heuristic-fallback', + abuse: bad, bullying: bully, spam, + safety_risk: bad, + action: bad ? 'escalate' : bully || spam ? 'review' : 'allow', + confidence: 0.55, + }; +} + +export default async function handler(req, res) { + cors(res); + if (req.method === 'OPTIONS') return res.status(204).end(); + if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' }); + + try { + const { task, posts, text, poll } = req.body || {}; + + if (task === 'moderate') { + const ai = await callLLM( + 'You are a school-content moderator. Analyze the text for abuse, bullying, spam, and safety risks.', + `Text: """${String(text || '').slice(0, 1500)}"""\nReturn JSON: {"abuse":bool,"bullying":bool,"spam":bool,"safety_risk":bool,"action":"allow|review|escalate","reason":string,"confidence":0-1}` + ); + return res.status(200).json(ai ? { engine: ai.engine, ...ai.result } : heuristicModeration(String(text || ''))); + } + + if (task === 'analyze') { + if (!(await isAdmin(req))) return res.status(403).json({ error: 'Admin only' }); + const items = (posts || []).slice(0, 60).map((p) => ({ + id: p.id, title: p.title, description: (p.description || '').slice(0, 240), + category: p.category, priority: p.priority, status: p.status, + reactions: p.reactions, comments: p.comment_count, created_at: p.created_at, + })); + const ai = await callLLM( + 'You are an analyst for an anonymous school feedback platform. Cluster duplicates, detect urgency, rank issues using votes, support ratio, severity, recurrence, comment volume and growth rate. Detect abuse/spam/bullying/safety risks.', + `Feedback items JSON:\n${JSON.stringify(items)}\n\nReturn JSON with keys: summary (string), ranked_issues (array of {id,title,category,urgency_score:0-100,rank_score,support_ratio,flags:[],recommended_action,confidence:0-1}), duplicate_clusters (array of {topic,post_ids,count}), safety_alerts (array of {id,title,reason}), weekly_insights ({total,high_urgency,trending_category,recommendation}).` + ); + if (ai) return res.status(200).json({ engine: ai.engine, generated_at: new Date().toISOString(), ...ai.result }); + return res.status(200).json(heuristicAnalysis(posts || [])); + } + + if (task === 'categorize') { + // AI category suggestion while typing — instant heuristic + optional Claude refinement + const input = String(text || '').slice(0, 600).toLowerCase(); + const KEYWORDS = { + Academics: ['exam', 'homework', 'class', 'lesson', 'grade', 'test', 'study', 'curriculum', 'syllabus', 'timetable'], + Facilities: ['ac', 'air condition', 'chair', 'desk', 'window', 'door', 'roof', 'classroom', 'building', 'fan', 'light', 'broken', 'repair'], + Food: ['canteen', 'food', 'lunch', 'meal', 'cafeteria', 'snack', 'menu', 'hungry', 'queue'], + Bullying: ['bully', 'harass', 'threat', 'intimidat', 'mock', 'teas', 'corner', 'afraid', 'scared'], + Teachers: ['teacher', 'staff', 'professor', 'lecture', 'unfair', 'favorit', 'shout'], + Events: ['event', 'club', 'fest', 'competition', 'trip', 'excursion', 'celebration'], + Transport: ['bus', 'transport', 'route', 'pickup', 'driver', 'late bus'], + Sports: ['sport', 'gym', 'football', 'basketball', 'pe ', 'playground', 'field', 'court'], + Technology: ['wifi', 'internet', 'computer', 'laptop', 'projector', 'network', 'password', 'printer'], + Library: ['library', 'book', 'reading', 'study space', 'quiet'], + Hostel: ['hostel', 'dorm', 'room', 'warden', 'bed'], + Security: ['security', 'theft', 'stolen', 'guard', 'gate', 'stranger', 'unsafe', 'cctv'], + Cleanliness: ['clean', 'dirty', 'trash', 'soap', 'toilet', 'bathroom', 'hygien', 'smell', 'garbage'], + Medical: ['nurse', 'sick', 'injur', 'first aid', 'medic', 'health', 'infirmary'], + }; + let best = 'Other'; let bestScore = 0; + for (const [cat, words] of Object.entries(KEYWORDS)) { + const score = words.reduce((a, w) => a + (input.includes(w) ? 1 : 0), 0); + if (score > bestScore) { best = cat; bestScore = score; } + } + // Try Claude for higher accuracy when a key is configured + const ai = bestScore > 0 ? null : await callClaude( + 'Classify school feedback into exactly one category.', + `Text: """${input}"""\nCategories: Academics, Facilities, Food, Bullying, Teachers, Events, Transport, Sports, Technology, Library, Hostel, Security, Cleanliness, Medical, Other.\nReturn JSON: {"category": string, "confidence": 0-1}` + ); + const category = ai?.category && Object.keys(KEYWORDS).concat('Other').includes(ai.category) ? ai.category : best; + return res.status(200).json({ + engine: ai ? MODEL : 'heuristic-keywords', + category, + confidence: ai?.confidence ?? Math.min(0.95, 0.4 + bestScore * 0.18), + }); + } + + if (task === 'summarize') { + const ai = await callLLM( + 'Summarize this school feedback item in 1-2 neutral sentences for administrators.', + `Item: ${JSON.stringify({ title: req.body.title, description: req.body.description })}\nReturn JSON: {"summary": string}` + ); + if (ai?.result?.summary) return res.status(200).json({ engine: ai.engine, summary: ai.result.summary }); + const d = String(req.body.description || ''); + return res.status(200).json({ engine: 'heuristic-fallback', summary: `${req.body.title}. ${d.slice(0, 140)}${d.length > 140 ? '…' : ''}` }); + } + + if (task === 'poll_insight') { + const ai = await callLLM( + 'You analyze school poll results and give one short, neutral insight for students and staff.', + `Poll: ${JSON.stringify(poll)}\nReturn JSON: {"insight": string}` + ); + if (ai?.result?.insight) return res.status(200).json({ engine: ai.engine, insight: ai.result.insight }); + // heuristic insight + const counts = poll?.vote_counts || {}; + const total = poll?.total_votes || 0; + const top = Object.entries(counts).sort((a, b) => b[1] - a[1])[0]; + const pct = total && top ? Math.round((top[1] / total) * 100) : 0; + const opt = poll?.options?.[Number(top?.[0])] || 'the leading option'; + return res.status(200).json({ + engine: 'heuristic-fallback', + insight: total === 0 ? 'No votes yet — share the poll to gather opinions.' : `“${opt}” leads with ${pct}% of ${total} vote${total !== 1 ? 's' : ''}${pct >= 70 ? ' — a strong consensus.' : pct >= 50 ? ' — a clear majority.' : ' — opinions are split.'}`, + }); + } + + return res.status(400).json({ error: 'Unknown task' }); + } catch (err) { + console.error('ai API error:', err); + return res.status(500).json({ error: err.message }); + } +} diff --git a/freeclaw/freeclaw/voice-box/api/_announcement.js b/freeclaw/freeclaw/voice-box/api/_announcement.js new file mode 100644 index 0000000..0221e47 --- /dev/null +++ b/freeclaw/freeclaw/voice-box/api/_announcement.js @@ -0,0 +1,35 @@ +// Public announcement banner — set by admin, visible to everyone +import supabase from './_db-client.js'; +import { cors, isAdmin, auditLog, clean } from './_auth.js'; + +export default async function handler(req, res) { + cors(res); + if (req.method === 'OPTIONS') return res.status(204).end(); + + try { + if (req.method === 'GET') { + const { data } = await supabase.from('settings').select('value').eq('key', 'announcement').maybeSingle(); + return res.status(200).json(data?.value || null); + } + + if (req.method === 'POST') { + if (!(await isAdmin(req))) return res.status(403).json({ error: 'Admin only' }); + const b = req.body || {}; + const value = b.clear ? null : { + text: clean(b.text, 300), + kind: ['info', 'success', 'warning'].includes(b.kind) ? b.kind : 'info', + at: new Date().toISOString(), + }; + const { data: existing } = await supabase.from('settings').select('key').eq('key', 'announcement').maybeSingle(); + if (existing) await supabase.from('settings').update({ value }).eq('key', 'announcement'); + else await supabase.from('settings').insert({ key: 'announcement', value }); + await auditLog('admin', b.clear ? 'clear_announcement' : 'set_announcement', b.text || ''); + return res.status(200).json({ ok: true, value }); + } + + return res.status(405).json({ error: 'Method not allowed' }); + } catch (err) { + console.error('announcement API error:', err); + return res.status(500).json({ error: err.message }); + } +} diff --git a/freeclaw/freeclaw/voice-box/api/_assist.js b/freeclaw/freeclaw/voice-box/api/_assist.js new file mode 100644 index 0000000..de24294 --- /dev/null +++ b/freeclaw/freeclaw/voice-box/api/_assist.js @@ -0,0 +1,129 @@ +// Real-time AI writing assistance: category detection, tag suggestions, +// title improvement, and contextual chat replies. Uses Claude when +// ANTHROPIC_API_KEY is set; otherwise a fast keyword engine (always works). +import { cors, isAdmin } from './_auth.js'; + +const MODEL = process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-6'; + +async function callClaude(system, user, maxTokens = 400) { + const key = process.env.ANTHROPIC_API_KEY; + if (!key) return null; + try { + const resp = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' }, + body: JSON.stringify({ + model: MODEL, max_tokens: maxTokens, + system: system + '\nRespond with STRICT valid JSON only.', + messages: [{ role: 'user', content: user }], + }), + }); + if (!resp.ok) return null; + const data = await resp.json(); + return JSON.parse((data?.content?.[0]?.text || '').replace(/^```json?\s*/i, '').replace(/```\s*$/, '')); + } catch { return null; } +} + +// ---------- Fast keyword engine (no API key needed, <1ms) ---------- +const CATEGORY_KEYWORDS = { + Academics: ['exam', 'homework', 'assignment', 'syllabus', 'grade', 'marks', 'test', 'study', 'lesson', 'class', 'subject', 'teacher explains', 'curriculum'], + Facilities: ['ac', 'air conditioning', 'fan', 'light', 'chair', 'desk', 'bench', 'door', 'window', 'roof', 'leak', 'broken', 'repair', 'building', 'wall', 'paint', 'projector', 'toilet', 'washroom', 'bathroom', 'water cooler', 'fountain'], + Food: ['canteen', 'food', 'lunch', 'meal', 'menu', 'snack', 'cafeteria', 'hygiene food', 'taste', 'queue lunch', 'expensive food'], + Bullying: ['bully', 'bullying', 'harass', 'threat', 'teasing', 'intimidat', 'ragging', 'mock', 'exclude', 'unsafe', 'scared'], + Teachers: ['teacher', 'staff', 'professor', 'faculty', 'rude', 'favoritism', 'late to class', 'absent teacher', 'substitute'], + Events: ['event', 'fest', 'club', 'competition', 'annual day', 'sports day', 'celebration', 'function', 'trip', 'excursion'], + Transport: ['bus', 'transport', 'route', 'driver', 'pickup', 'drop', 'late bus', 'crowded bus', 'parking', 'cycle stand'], + Sports: ['sports', 'gym', 'ground', 'football', 'cricket', 'basketball', 'equipment', 'coach', 'pe ', 'physical education', 'playground'], + Technology: ['wifi', 'wi-fi', 'internet', 'computer', 'laptop', 'smartboard', 'network', 'lab computer', 'software', 'website', 'portal'], + Library: ['library', 'book', 'reading', 'librarian', 'study space', 'quiet room'], + Hostel: ['hostel', 'dorm', 'warden', 'room mate', 'mess', 'curfew', 'laundry'], + Security: ['security', 'guard', 'gate', 'cctv', 'theft', 'stolen', 'stranger', 'id card', 'safety gate'], + Cleanliness: ['clean', 'dirty', 'garbage', 'trash', 'dust', 'smell', 'sweep', 'hygiene', 'soap', 'sanitiz', 'washroom smell'], + Medical: ['medical', 'nurse', 'first aid', 'sick', 'injury', 'health', 'medicine', 'infirmary', 'doctor'], +}; + +function keywordAssist(text) { + const t = text.toLowerCase(); + const scores = {}; + for (const [cat, kws] of Object.entries(CATEGORY_KEYWORDS)) { + for (const kw of kws) if (t.includes(kw)) scores[cat] = (scores[cat] || 0) + (kw.length > 6 ? 2 : 1); + } + const ranked = Object.entries(scores).sort((a, b) => b[1] - a[1]); + const category = ranked[0]?.[0] || null; + const confidence = ranked[0] ? Math.min(0.95, 0.4 + ranked[0][1] * 0.12) : 0; + + // tags: pick matched keywords as tags + const tags = []; + if (category) { + for (const kw of CATEGORY_KEYWORDS[category]) { + if (t.includes(kw) && tags.length < 3) tags.push(kw.trim().replace(/\s+/g, '-')); + } + } + + // priority heuristics + const urgent = /\b(urgent|danger|unsafe|injur|emergency|fire|threat|bully|harass|leak|broken glass)\b/i.test(text); + const high = /\b(broken|not working|every ?day|weeks?|month|always|still)\b/i.test(text); + const priority = urgent ? 'critical' : high ? 'high' : 'medium'; + + return { engine: 'keyword', category, confidence, tags, priority }; +} + +const CHAT_TEMPLATES = [ + { match: /\b(thank|thanks|great|solved|fixed|works now)\b/i, reply: "You're welcome! Glad it's sorted. Reach out anytime — this stays anonymous. 🙌" }, + { match: /\b(when|how long|eta|update|status|progress)\b/i, reply: 'Let me check the latest status with the team and get back to you here shortly. Your report is being tracked.' }, + { match: /\b(urgent|danger|unsafe|scared|threat|bully|harass)\b/i, reply: 'Thank you for telling us — safety reports get top priority. This is being escalated to staff right now. If anyone is in immediate danger, please also alert the nearest teacher.' }, + { match: /\b(who|see|anonymous|identity|know me|trace)\b/i, reply: "We can only see your random anonymous ID — never your name, email, or device. It's completely safe to talk here." }, + { match: /\b(how|where|submit|report|post)\b/i, reply: 'You can submit anonymously from the "Submit" page — describe the problem, pick a category, and publish. No name needed.' }, + { match: /\?$/, reply: "Good question — I'm checking with the responsible staff member and will reply here as soon as I know more." }, +]; + +function keywordChatReply(lastUserMessage) { + for (const t of CHAT_TEMPLATES) if (t.match.test(lastUserMessage)) return t.reply; + return 'Thanks for the message — could you share a bit more detail (where, when, how often)? That helps us act faster.'; +} + +export default async function handler(req, res) { + cors(res); + if (req.method === 'OPTIONS') return res.status(204).end(); + if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' }); + + try { + const { task, text, messages } = req.body || {}; + + // ---- Real-time submission assist (public, fast) ---- + if (task === 'suggest') { + const input = String(text || '').slice(0, 800); + if (input.trim().length < 8) return res.status(200).json({ engine: 'none', category: null, tags: [], priority: null }); + const fallback = keywordAssist(input); + // Only call the LLM for longer text to keep latency low + if (input.length > 60) { + const ai = await callClaude( + 'You classify school feedback. Categories: Academics, Facilities, Food, Bullying, Teachers, Events, Transport, Sports, Technology, Library, Hostel, Security, Cleanliness, Medical, Other.', + `Text: """${input}"""\nReturn JSON: {"category":string,"confidence":0-1,"tags":[max 3 short kebab-case strings],"priority":"low|medium|high|critical","improved_title":string(max 80 chars, clear and specific)}`, + 300 + ); + if (ai?.category) return res.status(200).json({ engine: MODEL, ...ai }); + } + return res.status(200).json(fallback); + } + + // ---- AI chat reply (admin side) ---- + if (task === 'chat_reply') { + if (!(await isAdmin(req))) return res.status(403).json({ error: 'Admin only' }); + const history = (messages || []).slice(-8).map((m) => `${m.sender === 'admin' ? 'Admin' : 'Student'}: ${String(m.body || '').slice(0, 300)}`).join('\n'); + const lastUser = [...(messages || [])].reverse().find((m) => m.sender === 'user'); + const ai = await callClaude( + 'You are a kind, professional school admin replying to an anonymous student in a support chat. Keep replies short (1-3 sentences), warm, and actionable. Never ask for personal details.', + `Conversation:\n${history}\n\nReturn JSON: {"reply": string}`, + 250 + ); + if (ai?.reply) return res.status(200).json({ engine: MODEL, reply: ai.reply }); + return res.status(200).json({ engine: 'keyword', reply: keywordChatReply(lastUser?.body || '') }); + } + + return res.status(400).json({ error: 'Unknown task' }); + } catch (err) { + console.error('assist API error:', err); + return res.status(500).json({ error: err.message }); + } +} diff --git a/freeclaw/freeclaw/voice-box/api/_auth.js b/freeclaw/freeclaw/voice-box/api/_auth.js new file mode 100644 index 0000000..91e1723 --- /dev/null +++ b/freeclaw/freeclaw/voice-box/api/_auth.js @@ -0,0 +1,74 @@ +// Shared helpers for Voice Box API routes (underscore prefix = not exposed as a route) +import supabase from './_db-client.js'; + +export function cors(res) { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Admin-Token'); +} + +/** Verify admin session token from x-admin-token header */ +export async function isAdmin(req) { + const token = req.headers['x-admin-token']; + if (!token) return false; + const { data } = await supabase.from('settings').select('value').eq('key', 'admin_sessions').maybeSingle(); + const tokens = data?.value?.tokens || []; + const now = Date.now(); + return tokens.some((s) => s.t === token && s.exp > now); +} + +/** Check whether an anonymous user is allowed to write (not banned / suspended) */ +export async function checkUser(authorId) { + if (!authorId || typeof authorId !== 'string' || authorId.length > 40) { + return { ok: false, error: 'Missing or invalid anonymous ID.' }; + } + // Case-insensitive: IDs are stored lowercase; normalize incoming values + const id = authorId.toLowerCase(); + const { data } = await supabase.from('users_meta').select('*').eq('anon_id', id).maybeSingle(); + if (data?.banned) return { ok: false, error: 'This anonymous ID has been permanently banned.' }; + if (data?.suspended_until && new Date(data.suspended_until) > new Date()) { + return { ok: false, error: `This anonymous ID is suspended until ${new Date(data.suspended_until).toLocaleDateString()}.` }; + } + return { ok: true, meta: data }; +} + +/** Ensure a users_meta row exists for an anonymous id */ +export async function ensureUser(authorId) { + try { + const id = String(authorId).toLowerCase(); + const { data } = await supabase.from('users_meta').select('anon_id').eq('anon_id', id).maybeSingle(); + if (!data) await supabase.from('users_meta').insert({ anon_id: id, warnings: [], last_seen: new Date().toISOString() }); + } catch { /* non-fatal */ } +} + +/** Append to the audit / activity log */ +export async function auditLog(actor, action, detail) { + try { + await supabase.from('activity_logs').insert({ actor, action, detail: String(detail || '').slice(0, 500) }); + } catch { /* non-fatal */ } +} + +/** Basic server-side text sanitation: strip control chars + trim + cap length */ +export function clean(str, max = 2000) { + if (typeof str !== 'string') return ''; + return str.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '').trim().slice(0, max); +} + +const PROFANITY = ['fuck', 'shit', 'bitch', 'asshole', 'bastard', 'cunt', 'dickhead', 'slut', 'whore', 'nigger', 'faggot', 'retard']; + +/** Very small profanity mask */ +export function maskProfanity(text) { + let out = text; + for (const w of PROFANITY) { + out = out.replace(new RegExp(`\\b${w}\\b`, 'gi'), (m) => m[0] + '*'.repeat(m.length - 1)); + } + return out; +} + +/** Simple rate limit: max `limit` rows by author in table within `seconds` */ +export async function rateLimited(table, authorId, seconds, limit) { + const since = new Date(Date.now() - seconds * 1000).toISOString(); + const { count } = await supabase.from(table).select('*', { count: 'exact', head: true }) + .eq('author_id', authorId).gte('created_at', since); + return (count || 0) >= limit; +} diff --git a/freeclaw/freeclaw/voice-box/api/_chat.js b/freeclaw/freeclaw/voice-box/api/_chat.js new file mode 100644 index 0000000..196833b --- /dev/null +++ b/freeclaw/freeclaw/voice-box/api/_chat.js @@ -0,0 +1,84 @@ +// Anonymous direct messaging between admin and anonymous users +import supabase from './_db-client.js'; +import { cors, isAdmin, checkUser, clean, maskProfanity } from './_auth.js'; + +export default async function handler(req, res) { + cors(res); + if (req.method === 'OPTIONS') return res.status(204).end(); + + try { + if (req.method === 'GET') { + const { thread_id, threads } = req.query; + if (threads === '1') { + if (!(await isAdmin(req))) return res.status(403).json({ error: 'Admin only' }); + const [{ data: t }, { data: msgs }] = await Promise.all([ + supabase.from('chat_threads').select('*').order('updated_at', { ascending: false }), + supabase.from('chat_messages').select('thread_id,sender,read,body,created_at').order('created_at', { ascending: false }).limit(1000), + ]); + const enriched = (t || []).map((th) => { + const mine = (msgs || []).filter((m) => m.thread_id === th.thread_id); + return { + ...th, + last_message: mine[0]?.body || '', + last_at: mine[0]?.created_at || th.updated_at, + unread: mine.filter((m) => m.sender === 'user' && !m.read).length, + }; + }); + return res.status(200).json(enriched); + } + if (!thread_id) return res.status(400).json({ error: 'Missing thread_id' }); + const [{ data: msgs, error }, { data: thread }] = await Promise.all([ + supabase.from('chat_messages').select('*').eq('thread_id', thread_id).order('created_at', { ascending: true }).limit(500), + supabase.from('chat_threads').select('*').eq('thread_id', thread_id).maybeSingle(), + ]); + if (error) throw error; + return res.status(200).json({ messages: msgs || [], thread: thread || null }); + } + + if (req.method === 'POST') { + const b = req.body || {}; + const thread_id = clean(b.thread_id, 40); + if (!thread_id) return res.status(400).json({ error: 'Missing thread_id' }); + const fromAdmin = b.sender === 'admin' && (await isAdmin(req)); + if (!fromAdmin) { + const gate = await checkUser(thread_id); + if (!gate.ok) return res.status(403).json({ error: gate.error }); + } + const body = maskProfanity(clean(b.body, 1000)); + if (!body && !b.attachment_url) return res.status(400).json({ error: 'Empty message' }); + // Ensure thread exists / bump + const { data: existing } = await supabase.from('chat_threads').select('thread_id').eq('thread_id', thread_id).maybeSingle(); + const now = new Date().toISOString(); + if (existing) await supabase.from('chat_threads').update({ updated_at: now, status: 'open' }).eq('thread_id', thread_id); + else await supabase.from('chat_threads').insert({ thread_id, status: 'open', updated_at: now }); + const { data, error } = await supabase.from('chat_messages').insert({ + thread_id, sender: fromAdmin ? 'admin' : 'user', body, + attachment_url: clean(b.attachment_url, 500) || null, + }).select().single(); + if (error) throw error; + return res.status(201).json(data); + } + + if (req.method === 'PUT') { + const b = req.body || {}; + const admin = await isAdmin(req); + if (b.action === 'mark_read') { + // admin marks user messages read; user marks admin messages read + const senderToMark = admin && b.as === 'admin' ? 'user' : 'admin'; + await supabase.from('chat_messages').update({ read: true }).eq('thread_id', b.thread_id).eq('sender', senderToMark); + return res.status(200).json({ ok: true }); + } + if (b.action === 'set_status') { + if (!admin) return res.status(403).json({ error: 'Admin only' }); + await supabase.from('chat_threads').update({ status: b.status === 'closed' ? 'closed' : 'open', updated_at: new Date().toISOString() }).eq('thread_id', b.thread_id); + return res.status(200).json({ ok: true }); + } + return res.status(400).json({ error: 'Unknown action' }); + } + + return res.status(405).json({ error: 'Method not allowed' }); + } catch (err) { + console.error('chat API error:', err); + return res.status(500).json({ error: err.message }); + } +} diff --git a/freeclaw/freeclaw/voice-box/api/_comments.js b/freeclaw/freeclaw/voice-box/api/_comments.js new file mode 100644 index 0000000..bd64193 --- /dev/null +++ b/freeclaw/freeclaw/voice-box/api/_comments.js @@ -0,0 +1,90 @@ +// Anonymous comments with nested replies +import supabase from './_db-client.js'; +import { cors, isAdmin, checkUser, ensureUser, auditLog, clean, maskProfanity, rateLimited } from './_auth.js'; + +export default async function handler(req, res) { + cors(res); + if (req.method === 'OPTIONS') return res.status(204).end(); + + try { + if (req.method === 'GET') { + const { post_id, all, author, viewer } = req.query; + const admin = all === '1' ? await isAdmin(req) : false; + let q = supabase.from('comments').select('*').order('created_at', { ascending: true }); + if (post_id) q = q.eq('post_id', post_id); + if (author) q = q.eq('author_id', clean(author, 40)); + if (!admin) q = q.eq('hidden', false); + if (!post_id && !author) q = q.limit(500).order('created_at', { ascending: false }); + const { data, error } = await q; + if (error) throw error; + // Mask author IDs (bearer-token semantics) except for admin/owner + const v = clean(viewer, 40); + const masked = (data || []).map((c) => { + const is_mine = !!v && c.author_id === v; + return { ...c, is_mine, author_id: admin || is_mine || author || c.author_id === 'ADMIN' ? c.author_id : c.author_id.slice(0, 9) + '…' }; + }); + return res.status(200).json(masked); + } + + if (req.method === 'POST') { + const b = req.body || {}; + const author_id = clean(b.author_id, 40); + const is_admin_msg = b.is_admin === true && (await isAdmin(req)); + if (!is_admin_msg) { + const gate = await checkUser(author_id); + if (!gate.ok) return res.status(403).json({ error: gate.error }); + if (await rateLimited('comments', author_id, 30, 5)) { + return res.status(429).json({ error: 'Too many comments — please wait a moment.' }); + } + } + const body = maskProfanity(clean(b.body, 500)); + if (body.length < 2) return res.status(400).json({ error: 'Comment is too short.' }); + // Respect locked posts + const { data: post } = await supabase.from('posts').select('locked').eq('id', b.post_id).maybeSingle(); + if (post?.locked && !is_admin_msg) return res.status(403).json({ error: 'Comments are locked on this post.' }); + const row = { + id: `cmt_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`, + post_id: clean(b.post_id, 60), + parent_id: b.parent_id ? clean(b.parent_id, 60) : null, + author_id: is_admin_msg ? 'ADMIN' : author_id, + body, is_admin: !!is_admin_msg, + }; + const { data, error } = await supabase.from('comments').insert(row).select().single(); + if (error) throw error; + if (!is_admin_msg) await ensureUser(author_id); + // Activity resets the auto-deletion countdown on solved/archived posts + await supabase.from('posts').update({ updated_at: new Date().toISOString() }).eq('id', row.post_id); + return res.status(201).json(data); + } + + if (req.method === 'PUT') { + const b = req.body || {}; + const { data: cmt } = await supabase.from('comments').select('*').eq('id', b.id).maybeSingle(); + if (!cmt) return res.status(404).json({ error: 'Comment not found' }); + const admin = await isAdmin(req); + const isOwner = b.author_id && b.author_id === cmt.author_id; + if (!isOwner && !admin) return res.status(403).json({ error: 'Not authorized' }); + const patch = {}; + if (b.body !== undefined) { patch.body = maskProfanity(clean(b.body, 500)); patch.edited = true; } + if (typeof b.deleted === 'boolean') patch.deleted = b.deleted; + if (admin && typeof b.hidden === 'boolean') patch.hidden = b.hidden; + const { data, error } = await supabase.from('comments').update(patch).eq('id', b.id).select().single(); + if (error) throw error; + if (admin && !isOwner) await auditLog('admin', 'moderate_comment', b.id); + return res.status(200).json(data); + } + + if (req.method === 'DELETE') { + if (!(await isAdmin(req))) return res.status(403).json({ error: 'Admin only' }); + const { error } = await supabase.from('comments').delete().eq('id', req.body?.id); + if (error) throw error; + await auditLog('admin', 'hard_delete_comment', req.body?.id); + return res.status(200).json({ ok: true }); + } + + return res.status(405).json({ error: 'Method not allowed' }); + } catch (err) { + console.error('comments API error:', err); + return res.status(500).json({ error: err.message }); + } +} diff --git a/freeclaw/freeclaw/voice-box/api/_db-client.js b/freeclaw/freeclaw/voice-box/api/_db-client.js new file mode 100644 index 0000000..c131ed0 --- /dev/null +++ b/freeclaw/freeclaw/voice-box/api/_db-client.js @@ -0,0 +1,18 @@ +import { createClient } from '@supabase/supabase-js'; +import { triggerRestore } from './_db-wake.js'; + +const supabase = createClient( + process.env.VITE_SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL, + process.env.VITE_SUPABASE_ANON_KEY || process.env.SUPABASE_SERVICE_ROLE_KEY, + { + global: { + fetch: async (url, options) => { + const res = await fetch(url, options); + if (!res.ok && res.status >= 500) triggerRestore(); + return res; + }, + }, + } +); + +export default supabase; diff --git a/freeclaw/freeclaw/voice-box/api/_db-wake.js b/freeclaw/freeclaw/voice-box/api/_db-wake.js new file mode 100644 index 0000000..b65d6a0 --- /dev/null +++ b/freeclaw/freeclaw/voice-box/api/_db-wake.js @@ -0,0 +1,15 @@ +const PROJECT_REF = process.env.FULLSTACK_PROJECT_REF || ''; +const RESTORE_URL = process.env.FULLSTACK_RESTORE_API_URL || ''; + +let _restoreTriggered = false; + +export function triggerRestore() { + if (_restoreTriggered || !PROJECT_REF || !RESTORE_URL) return; + _restoreTriggered = true; + fetch(RESTORE_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ project_ref: PROJECT_REF }), + }).catch(() => {}); + setTimeout(() => { _restoreTriggered = false; }, 60000); +} diff --git a/freeclaw/freeclaw/voice-box/api/_me.js b/freeclaw/freeclaw/voice-box/api/_me.js new file mode 100644 index 0000000..f4a2a0b --- /dev/null +++ b/freeclaw/freeclaw/voice-box/api/_me.js @@ -0,0 +1,28 @@ +// Account status check for the caller's own anonymous ID (no personal data involved) +import supabase from './_db-client.js'; +import { cors, clean } from './_auth.js'; + +export default async function handler(req, res) { + cors(res); + if (req.method === 'OPTIONS') return res.status(204).end(); + if (req.method !== 'GET') return res.status(405).json({ error: 'Method not allowed' }); + + try { + const anonId = clean(req.query.anon_id, 40); + if (!anonId) return res.status(400).json({ error: 'Missing anon_id' }); + const { data } = await supabase.from('users_meta') + .select('banned,suspended_until,strikes,warnings') + .eq('anon_id', anonId).maybeSingle(); + const suspended = data?.suspended_until && new Date(data.suspended_until) > new Date(); + return res.status(200).json({ + banned: !!data?.banned, + suspended: !!suspended, + suspended_until: suspended ? data.suspended_until : null, + strikes: data?.strikes || 0, + warnings: data?.warnings || [], + }); + } catch (err) { + console.error('me API error:', err); + return res.status(500).json({ error: err.message }); + } +} diff --git a/freeclaw/freeclaw/voice-box/api/_polls.js b/freeclaw/freeclaw/voice-box/api/_polls.js new file mode 100644 index 0000000..754ade3 --- /dev/null +++ b/freeclaw/freeclaw/voice-box/api/_polls.js @@ -0,0 +1,125 @@ +// Poll system: standalone + complaint-linked, with live results +import supabase from './_db-client.js'; +import { cors, isAdmin, checkUser, auditLog, clean, maskProfanity, rateLimited } from './_auth.js'; + +async function attachResults(polls) { + const ids = polls.map((p) => p.id); + if (!ids.length) return polls; + const { data: votes } = await supabase.from('poll_votes').select('poll_id,choices').in('poll_id', ids); + const map = {}; + (votes || []).forEach((v) => { + map[v.poll_id] = map[v.poll_id] || { total: 0, counts: {} }; + map[v.poll_id].total += 1; + (v.choices || []).forEach((c) => { map[v.poll_id].counts[c] = (map[v.poll_id].counts[c] || 0) + 1; }); + }); + return polls.map((p) => ({ ...p, total_votes: map[p.id]?.total || 0, vote_counts: map[p.id]?.counts || {} })); +} + +export default async function handler(req, res) { + cors(res); + if (req.method === 'OPTIONS') return res.status(204).end(); + + try { + if (req.method === 'GET') { + const { id, post_id, voter } = req.query; + if (voter) { + const { data } = await supabase.from('poll_votes').select('poll_id,choices').eq('author_id', voter); + return res.status(200).json(data || []); + } + let q = supabase.from('polls').select('*').order('created_at', { ascending: false }).limit(200); + if (id) q = q.eq('id', id); + if (post_id) q = q.eq('post_id', post_id); + const { data, error } = await q; + if (error) throw error; + const results = await attachResults(data || []); + // Mask author IDs — they are bearer tokens for poll deletion + const v = clean(req.query.viewer, 40); + const masked = results.map((p) => { + const is_mine = !!v && p.author_id === v; + return { ...p, is_mine, author_id: is_mine || p.author_id === 'ADMIN' ? p.author_id : (p.author_id || '').slice(0, 9) + '…' }; + }); + return res.status(200).json(masked); + } + + if (req.method === 'POST') { + const b = req.body || {}; + const author_id = clean(b.author_id, 40); + + if (b.action === 'vote') { + const gate = await checkUser(author_id); + if (!gate.ok) return res.status(403).json({ error: gate.error }); + const { data: poll } = await supabase.from('polls').select('*').eq('id', b.poll_id).maybeSingle(); + if (!poll) return res.status(404).json({ error: 'Poll not found' }); + if (poll.archived) return res.status(400).json({ error: 'Poll is archived.' }); + if (poll.expires_at && new Date(poll.expires_at) < new Date()) return res.status(400).json({ error: 'Poll has ended.' }); + const choices = (Array.isArray(b.choices) ? b.choices : []).map(Number).filter((n) => Number.isInteger(n) && n >= 0 && n < (poll.options || []).length); + if (!choices.length) return res.status(400).json({ error: 'Select at least one option.' }); + if (poll.ptype !== 'multi' && choices.length > 1) return res.status(400).json({ error: 'Only one choice allowed.' }); + const { data: existing } = await supabase.from('poll_votes').select('id').eq('poll_id', poll.id).eq('author_id', author_id).maybeSingle(); + if (existing) { + await supabase.from('poll_votes').update({ choices }).eq('id', existing.id); + } else { + await supabase.from('poll_votes').insert({ poll_id: poll.id, author_id, choices }); + } + const [withResults] = await attachResults([poll]); + return res.status(200).json(withResults); + } + + // Create poll + const admin = await isAdmin(req); + if (!admin) { + const gate = await checkUser(author_id); + if (!gate.ok) return res.status(403).json({ error: gate.error }); + if (await rateLimited('polls', author_id, 120, 2)) return res.status(429).json({ error: 'Please wait before creating another poll.' }); + } + const title = maskProfanity(clean(b.title, 140)); + if (title.length < 5) return res.status(400).json({ error: 'Question must be at least 5 characters.' }); + const ptype = ['yesno', 'single', 'multi'].includes(b.ptype) ? b.ptype : 'yesno'; + let options = ptype === 'yesno' ? ['Yes', 'No'] : (Array.isArray(b.options) ? b.options.map((o) => clean(o, 60)).filter(Boolean) : []); + if (ptype !== 'yesno' && (options.length < 2 || options.length > 10)) { + return res.status(400).json({ error: 'Provide 2–10 options.' }); + } + const row = { + id: `poll_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`, + title, ptype, options, + post_id: b.post_id ? clean(b.post_id, 60) : null, + author_id: admin && !author_id ? 'ADMIN' : author_id, + expires_at: b.expires_at || null, + }; + const { data, error } = await supabase.from('polls').insert(row).select().single(); + if (error) throw error; + return res.status(201).json(data); + } + + if (req.method === 'PUT') { + const b = req.body || {}; + const admin = await isAdmin(req); + const { data: poll } = await supabase.from('polls').select('*').eq('id', b.id).maybeSingle(); + if (!poll) return res.status(404).json({ error: 'Poll not found' }); + const isOwner = b.author_id && b.author_id === poll.author_id; + if (!admin && !isOwner) return res.status(403).json({ error: 'Not authorized' }); + const patch = {}; + if (typeof b.archived === 'boolean') patch.archived = b.archived; + if (typeof b.deleted === 'boolean') patch.deleted = b.deleted; + if (admin && b.expires_at !== undefined) patch.expires_at = b.expires_at; + const { data, error } = await supabase.from('polls').update(patch).eq('id', b.id).select().single(); + if (error) throw error; + if (admin) await auditLog('admin', 'update_poll', b.id); + return res.status(200).json(data); + } + + if (req.method === 'DELETE') { + if (!(await isAdmin(req))) return res.status(403).json({ error: 'Admin only' }); + await supabase.from('poll_votes').delete().eq('poll_id', req.body?.id); + const { error } = await supabase.from('polls').delete().eq('id', req.body?.id); + if (error) throw error; + await auditLog('admin', 'delete_poll', req.body?.id); + return res.status(200).json({ ok: true }); + } + + return res.status(405).json({ error: 'Method not allowed' }); + } catch (err) { + console.error('polls API error:', err); + return res.status(500).json({ error: err.message }); + } +} diff --git a/freeclaw/freeclaw/voice-box/api/_posts.js b/freeclaw/freeclaw/voice-box/api/_posts.js new file mode 100644 index 0000000..83afd4f --- /dev/null +++ b/freeclaw/freeclaw/voice-box/api/_posts.js @@ -0,0 +1,177 @@ +// Problems + Suggestions API +import supabase from './_db-client.js'; +import { cors, isAdmin, checkUser, ensureUser, auditLog, clean, maskProfanity, rateLimited } from './_auth.js'; + +const CATEGORIES = ['Academics','Facilities','Food','Bullying','Teachers','Events','Transport','Sports','Technology','Library','Hostel','Security','Cleanliness','Medical','Other']; +const STATUSES = ['reported','verified','in_progress','waiting','solved','archived']; + +// Co-sign threshold: posts with this many supports are auto-flagged "ready for decision" +const READY_THRESHOLD = 10; +// Solved/archived posts are permanently deleted after 5 days of NO activity. +// Any reaction or comment bumps updated_at and resets the countdown. +const PURGE_MS = 5 * 24 * 60 * 60 * 1000; + +/** Lazy sweep: permanently remove solved/archived posts inactive for 5+ days */ +async function purgeExpired() { + try { + const cutoff = new Date(Date.now() - PURGE_MS).toISOString(); + const { data: expired } = await supabase.from('posts').select('id') + .in('status', ['solved', 'archived']).lt('updated_at', cutoff).limit(20); + if (expired?.length) { + const ids = expired.map((p) => p.id); + await Promise.all([ + supabase.from('posts').delete().in('id', ids), + supabase.from('comments').delete().in('post_id', ids), + supabase.from('reactions').delete().in('target_id', ids), + ]); + } + } catch { /* sweep is best-effort */ } +} + +async function attachCounts(posts) { + const ids = posts.map((p) => p.id); + if (!ids.length) return posts; + const [{ data: reactions }, { data: comments }, { data: polls }] = await Promise.all([ + supabase.from('reactions').select('target_id,kind').in('target_id', ids), + supabase.from('comments').select('post_id').in('post_id', ids).eq('deleted', false).eq('hidden', false), + supabase.from('polls').select('id,post_id').in('post_id', ids), + ]); + const rMap = {}; const cMap = {}; const pMap = {}; + (reactions || []).forEach((r) => { rMap[r.target_id] = rMap[r.target_id] || {}; rMap[r.target_id][r.kind] = (rMap[r.target_id][r.kind] || 0) + 1; }); + (comments || []).forEach((c) => { cMap[c.post_id] = (cMap[c.post_id] || 0) + 1; }); + (polls || []).forEach((p) => { pMap[p.post_id] = p.id; }); + return posts.map((p) => { + const reactions = rMap[p.id] || {}; + const isClosed = ['solved', 'archived'].includes(p.status); + return { + ...p, reactions, comment_count: cMap[p.id] || 0, linked_poll: pMap[p.id] || null, + // Co-sign threshold auto-flag + ready_for_decision: !isClosed && (reactions.support || 0) >= READY_THRESHOLD, + ready_threshold: READY_THRESHOLD, + // Countdown metadata for solved/archived posts (5-day auto-delete) + purge_at: isClosed ? new Date(+new Date(p.updated_at || p.created_at) + PURGE_MS).toISOString() : null, + }; + }); +} + +export default async function handler(req, res) { + cors(res); + if (req.method === 'OPTIONS') return res.status(204).end(); + + try { + if (req.method === 'GET') { + await purgeExpired(); // lazy cleanup on every read + const { id, ids, type, all, viewer, author } = req.query; + const admin = all === '1' ? await isAdmin(req) : false; + let q = supabase.from('posts').select('*').order('created_at', { ascending: false }); + if (id) q = q.eq('id', id); + else if (ids) q = q.in('id', String(ids).split(',').slice(0, 100)); + else if (author) q = q.eq('author_id', clean(author, 40)).eq('deleted', false).limit(200); + else { + if (type) q = q.eq('type', type); + if (!admin) q = q.eq('hidden', false).eq('deleted', false); + q = q.limit(300); + } + const { data, error } = await q; + if (error) throw error; + const out = await attachCounts(data || []); + // SECURITY: never expose full anonymous IDs publicly — they act as bearer + // tokens for edit/delete. Mask for everyone except admin and the owner. + const v = clean(viewer, 40); + const masked = out.map((p) => { + const is_mine = !!v && p.author_id === v; + return { ...p, is_mine, author_id: admin || is_mine || author ? p.author_id : p.author_id.slice(0, 9) + '…' }; + }); + return res.status(200).json(masked); + } + + if (req.method === 'POST') { + const b = req.body || {}; + const author_id = clean(b.author_id, 40); + const gate = await checkUser(author_id); + if (!gate.ok) return res.status(403).json({ error: gate.error }); + if (await rateLimited('posts', author_id, 60, 3)) { + return res.status(429).json({ error: 'Slow down — you can post at most 3 times per minute.' }); + } + const title = maskProfanity(clean(b.title, 120)); + const description = maskProfanity(clean(b.description, 500)); + if (title.length < 5) return res.status(400).json({ error: 'Title must be at least 5 characters.' }); + if (description.length < 10) return res.status(400).json({ error: 'Description must be at least 10 characters.' }); + const category = CATEGORIES.includes(b.category) ? b.category : 'Other'; + const type = b.type === 'suggestion' ? 'suggestion' : 'problem'; + const priority = ['low', 'medium', 'high', 'critical'].includes(b.priority) ? b.priority : 'medium'; + const tags = Array.isArray(b.tags) ? b.tags.slice(0, 6).map((t) => clean(t, 24)).filter(Boolean) : []; + const post = { + id: b.id || `${type === 'suggestion' ? 'sug' : 'post'}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`, + type, title, description, category, priority, tags, + image_url: clean(b.image_url, 500) || null, + author_id, status: 'reported', progress: 0, + status_history: [{ status: 'reported', at: new Date().toISOString(), note: 'Submitted anonymously' }], + }; + const { data, error } = await supabase.from('posts').insert(post).select().single(); + if (error) throw error; + await ensureUser(author_id); + return res.status(201).json(data); + } + + if (req.method === 'PUT') { + const b = req.body || {}; + const { id } = b; + if (!id) return res.status(400).json({ error: 'Missing id' }); + const { data: post } = await supabase.from('posts').select('*').eq('id', id).maybeSingle(); + if (!post) return res.status(404).json({ error: 'Post not found' }); + const admin = await isAdmin(req); + const isOwner = b.author_id && b.author_id === post.author_id; + + const patch = {}; + if (isOwner || admin) { + // Owner-permitted fields + if (typeof b.deleted === 'boolean') patch.deleted = b.deleted; // soft delete + 30s restore + if (b.title !== undefined) patch.title = maskProfanity(clean(b.title, 120)); + if (b.description !== undefined) patch.description = maskProfanity(clean(b.description, 500)); + if (b.tags !== undefined && Array.isArray(b.tags)) patch.tags = b.tags.slice(0, 6).map((t) => clean(t, 24)); + } + if (admin) { + if (b.status && STATUSES.includes(b.status)) { + patch.status = b.status; + const map = { reported: 5, verified: 20, in_progress: 50, waiting: 70, solved: 100, archived: 100 }; + patch.progress = map[b.status]; + patch.status_history = [...(post.status_history || []), { status: b.status, at: new Date().toISOString(), note: clean(b.status_note, 300) || null }]; + } + for (const f of ['pinned', 'featured', 'hidden', 'locked']) if (typeof b[f] === 'boolean') patch[f] = b[f]; + if (b.admin_reply !== undefined) patch.admin_reply = clean(b.admin_reply, 1000); + if (b.admin_notes !== undefined) patch.admin_notes = clean(b.admin_notes, 2000); + if (b.ai_summary !== undefined) patch.ai_summary = clean(b.ai_summary, 2000); + if (b.category !== undefined && CATEGORIES.includes(b.category)) patch.category = b.category; + if (b.priority !== undefined) patch.priority = b.priority; + if (b.eta !== undefined) patch.eta = clean(b.eta, 60); + if (b.assigned_to !== undefined) patch.assigned_to = clean(b.assigned_to, 60); + if (typeof b.progress === 'number') patch.progress = Math.max(0, Math.min(100, b.progress)); + if (b.merged_into !== undefined) patch.merged_into = clean(b.merged_into, 60); + if (b.type !== undefined && ['problem', 'suggestion'].includes(b.type)) patch.type = b.type; // convert suggestion <-> project/problem + } + if (!isOwner && !admin) return res.status(403).json({ error: 'Not authorized' }); + if (!Object.keys(patch).length) return res.status(400).json({ error: 'Nothing to update' }); + patch.updated_at = new Date().toISOString(); + const { data, error } = await supabase.from('posts').update(patch).eq('id', id).select().single(); + if (error) throw error; + if (admin) await auditLog('admin', 'update_post', `${id}: ${Object.keys(patch).join(', ')}`); + return res.status(200).json(data); + } + + if (req.method === 'DELETE') { + if (!(await isAdmin(req))) return res.status(403).json({ error: 'Admin only' }); + const { id } = req.body || {}; + const { error } = await supabase.from('posts').delete().eq('id', id); + if (error) throw error; + await supabase.from('comments').delete().eq('post_id', id); + await auditLog('admin', 'hard_delete_post', id); + return res.status(200).json({ ok: true }); + } + + return res.status(405).json({ error: 'Method not allowed' }); + } catch (err) { + console.error('posts API error:', err); + return res.status(500).json({ error: err.message }); + } +} diff --git a/freeclaw/freeclaw/voice-box/api/_providers.js b/freeclaw/freeclaw/voice-box/api/_providers.js new file mode 100644 index 0000000..305eba8 --- /dev/null +++ b/freeclaw/freeclaw/voice-box/api/_providers.js @@ -0,0 +1,281 @@ +// Multi-provider API key management with failover chain. +// Keys stored in DB (settings.api_providers) with env vars as fallback. +// Priority: DB keys → env vars → heuristic fallback. +import supabase from './_db-client.js'; +import { cors, isAdmin, auditLog, clean } from './_auth.js'; + +// ─── Provider Registry ──────────────────────────────────────────── +const PROVIDER_DEFS = { + openai: { + name: 'OpenAI', + defaultModel: 'gpt-4o', + baseUrl: 'https://api.openai.com/v1/chat/completions', + buildHeaders: (key) => ({ Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' }), + buildBody: (model, messages) => ({ model, max_tokens: 2048, temperature: 0.2, messages }), + parseResponse: (data) => data?.choices?.[0]?.message?.content, + envKey: 'OPENAI_API_KEY', + }, + anthropic: { + name: 'Anthropic', + defaultModel: 'claude-sonnet-4-6', + baseUrl: 'https://api.anthropic.com/v1/messages', + buildHeaders: (key) => ({ 'x-api-key': key, 'anthropic-version': '2023-06-01', 'Content-Type': 'application/json' }), + buildBody: (model, messages) => { + const sys = messages.find((m) => m.role === 'system'); + const user = messages.filter((m) => m.role !== 'system'); + return { model, max_tokens: 2048, ...(sys ? { system: sys.content } : {}), messages: user }; + }, + parseResponse: (data) => data?.content?.[0]?.text, + envKey: 'ANTHROPIC_API_KEY', + }, + gemini: { + name: 'Google Gemini', + defaultModel: 'gemini-2.5-pro', + baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai/', + buildHeaders: (key) => ({ Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' }), + buildBody: (model, messages) => ({ model, max_tokens: 2048, temperature: 0.2, messages }), + parseResponse: (data) => data?.choices?.[0]?.message?.content, + envKey: 'GEMINI_API_KEY', + }, + nvidia: { + name: 'NVIDIA NIM', + defaultModel: 'meta/llama-3.1-70b-instruct', + baseUrl: 'https://integrate.api.nvidia.com/v1/chat/completions', + buildHeaders: (key) => ({ Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' }), + buildBody: (model, messages) => ({ model, max_tokens: 2048, temperature: 0.2, messages }), + parseResponse: (data) => data?.choices?.[0]?.message?.content, + envKey: 'NVIDIA_API_KEY', + }, + mistral: { + name: 'Mistral', + defaultModel: 'mistral-large-latest', + baseUrl: 'https://api.mistral.ai/v1/chat/completions', + buildHeaders: (key) => ({ Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' }), + buildBody: (model, messages) => ({ model, max_tokens: 2048, temperature: 0.2, messages }), + parseResponse: (data) => data?.choices?.[0]?.message?.content, + envKey: 'MISTRAL_API_KEY', + }, + deepseek: { + name: 'DeepSeek', + defaultModel: 'deepseek-chat', + baseUrl: 'https://api.deepseek.com/chat/completions', + buildHeaders: (key) => ({ Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' }), + buildBody: (model, messages) => ({ model, max_tokens: 2048, temperature: 0.2, messages }), + parseResponse: (data) => data?.choices?.[0]?.message?.content, + envKey: 'DEEPSEEK_API_KEY', + }, + groq: { + name: 'Groq', + defaultModel: 'llama-3.3-70b-versatile', + baseUrl: 'https://api.groq.com/openai/v1/chat/completions', + buildHeaders: (key) => ({ Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' }), + buildBody: (model, messages) => ({ model, max_tokens: 2048, temperature: 0.2, messages }), + parseResponse: (data) => data?.choices?.[0]?.message?.content, + envKey: 'GROQ_API_KEY', + }, +}; + +// ─── DB helpers ─────────────────────────────────────────────────── +async function getProviders() { + const { data } = await supabase.from('settings').select('value').eq('key', 'api_providers').maybeSingle(); + return data?.value || {}; +} + +async function saveProviders(providers) { + const { data } = await supabase.from('settings').select('key').eq('key', 'api_providers').maybeSingle(); + if (data) await supabase.from('settings').update({ value: providers }).eq('key', 'api_providers'); + else await supabase.from('settings').insert({ key: 'api_providers', value: providers }); +} + +// ─── Mask key for frontend display ──────────────────────────────── +function maskKey(key) { + if (!key || key.length < 8) return ''; + return '••••••' + key.slice(-4); +} + +// ─── Build priority-ordered chain from DB + env vars ────────────── +async function buildChain() { + const db = await getProviders(); + const chain = []; + // DB-configured providers first (sorted by priority) + const dbEntries = Object.entries(db) + .filter(([id, cfg]) => cfg.enabled && cfg.key) + .sort((a, b) => (a[1].priority || 99) - (b[1].priority || 99)); + for (const [id, cfg] of dbEntries) { + const def = PROVIDER_DEFS[id]; + if (!def) continue; + chain.push({ id, ...def, key: cfg.key, model: cfg.model || def.defaultModel }); + } + // Env var fallbacks (only if not already added from DB) + const dbIds = new Set(dbEntries.map(([id]) => id)); + for (const [id, def] of Object.entries(PROVIDER_DEFS)) { + if (dbIds.has(id)) continue; + const key = process.env[def.envKey]; + if (key) chain.push({ id, ...def, key, model: def.defaultModel }); + } + return chain; +} + +// ─── Call one provider ──────────────────────────────────────────── +async function callProvider(provider, messages, timeoutMs = 12000) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const resp = await fetch(provider.baseUrl, { + method: 'POST', + headers: provider.buildHeaders(provider.key), + body: JSON.stringify(provider.buildBody(provider.model, messages)), + signal: controller.signal, + }); + if (!resp.ok) { + const err = await resp.text().catch(() => ''); + return { ok: false, status: resp.status, error: `HTTP ${resp.status}: ${err.slice(0, 200)}` }; + } + const data = await resp.json(); + const text = provider.parseResponse(data); + if (!text) return { ok: false, error: 'Empty response from provider' }; + return { ok: true, text, provider: provider.id, model: provider.model }; + } catch (e) { + return { ok: false, error: e.name === 'AbortError' ? 'Timeout' : e.message }; + } finally { + clearTimeout(timer); + } +} + +// ─── Failover chain call ───────────────────────────────────────── +export async function callLLMChain(system, user, extraMessages = []) { + const chain = await buildChain(); + const messages = [ + { role: 'system', content: system }, + ...extraMessages, + { role: 'user', content: user }, + ]; + for (const provider of chain) { + const result = await callProvider(provider, messages); + if (result.ok) { + return { provider: result.provider, model: result.model, text: result.text }; + } + console.warn(`Provider ${provider.id} failed:`, result.error); + } + return null; // all failed → caller should use heuristic +} + +// ─── HTTP Handler ──────────────────────────────────────────────── +export default async function handler(req, res) { + cors(res); + if (req.method === 'OPTIONS') return res.status(204).end(); + + try { + if (!(await isAdmin(req))) return res.status(403).json({ error: 'Admin only' }); + + const b = req.body || {}; + const action = req.method === 'GET' ? (req.query.action || 'list') : b.action; + + // GET /api/providers?action=list → return all providers (keys masked) + if (req.method === 'GET' && action === 'list') { + const db = await getProviders(); + const result = {}; + for (const [id, def] of Object.entries(PROVIDER_DEFS)) { + const cfg = db[id] || {}; + result[id] = { + id, + name: def.name, + model: cfg.model || def.defaultModel, + enabled: !!cfg.enabled, + priority: cfg.priority || Object.keys(PROVIDER_DEFS).indexOf(id) + 1, + status: cfg.status || 'untested', + last_tested: cfg.last_tested || null, + key_masked: cfg.key ? maskKey(cfg.key) : (process.env[def.envKey] ? maskKey(process.env[def.envKey]) : ''), + has_env_key: !!process.env[def.envKey], + }; + } + return res.status(200).json(result); + } + + // POST /api/providers → update, test, reorder + if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' }); + + // update_provider + if (action === 'update_provider') { + const { provider: pid, config } = b; + if (!pid || !PROVIDER_DEFS[pid]) return res.status(400).json({ error: 'Invalid provider' }); + const db = await getProviders(); + db[pid] = { ...(db[pid] || {}), ...config }; + // If key changed, reset status + if (config.key !== undefined) db[pid].status = 'untested'; + await saveProviders(db); + await auditLog('admin', 'update_provider', `Updated provider ${pid}`); + return res.status(200).json({ ok: true }); + } + + // test_provider + if (action === 'test_provider') { + const { provider: pid } = b; + if (!pid || !PROVIDER_DEFS[pid]) return res.status(400).json({ error: 'Invalid provider' }); + const def = PROVIDER_DEFS[pid]; + const db = await getProviders(); + const cfg = db[pid] || {}; + const key = cfg.key || process.env[def.envKey]; + if (!key) return res.status(400).json({ error: 'No API key configured for this provider' }); + + const provider = { id: pid, ...def, key, model: cfg.model || def.defaultModel }; + const start = Date.now(); + const result = await callProvider(provider, [ + { role: 'system', content: 'You are a test endpoint. Respond with ONLY valid JSON.' }, + { role: 'user', content: 'Say "hello" by responding with exactly: {"response":"hello"}' }, + ], 10000); + const latency = Date.now() - start; + + // Update status in DB + db[pid] = { ...(db[pid] || {}), status: result.ok ? 'ok' : 'failed', last_tested: new Date().toISOString() }; + await saveProviders(db); + + return res.status(200).json({ + success: result.ok, + latency_ms: latency, + model: provider.model, + error: result.ok ? undefined : result.error, + }); + } + + // test_all + if (action === 'test_all') { + const db = await getProviders(); + const results = {}; + for (const [pid, def] of Object.entries(PROVIDER_DEFS)) { + const cfg = db[pid] || {}; + const key = cfg.key || process.env[def.envKey]; + if (!key) { results[pid] = { success: false, error: 'No API key' }; continue; } + const provider = { id: pid, ...def, key, model: cfg.model || def.defaultModel }; + const start = Date.now(); + const result = await callProvider(provider, [ + { role: 'system', content: 'You are a test endpoint. Respond with ONLY valid JSON.' }, + { role: 'user', content: 'Say "hello" by responding with exactly: {"response":"hello"}' }, + ], 10000); + results[pid] = { success: result.ok, latency_ms: Date.now() - start, model: provider.model, error: result.ok ? undefined : result.error }; + db[pid] = { ...(db[pid] || {}), status: result.ok ? 'ok' : 'failed', last_tested: new Date().toISOString() }; + } + await saveProviders(db); + await auditLog('admin', 'test_all_providers', `Tested all providers`); + return res.status(200).json(results); + } + + // reorder_providers + if (action === 'reorder_providers') { + const { order } = b; + if (!Array.isArray(order)) return res.status(400).json({ error: 'order must be an array of provider IDs' }); + const db = await getProviders(); + order.forEach((pid, i) => { + if (db[pid]) db[pid].priority = i + 1; + }); + await saveProviders(db); + await auditLog('admin', 'reorder_providers', `New order: ${order.join(', ')}`); + return res.status(200).json({ ok: true }); + } + + return res.status(400).json({ error: 'Unknown action' }); + } catch (err) { + console.error('providers API error:', err); + return res.status(500).json({ error: err.message }); + } +} diff --git a/freeclaw/freeclaw/voice-box/api/_reactions.js b/freeclaw/freeclaw/voice-box/api/_reactions.js new file mode 100644 index 0000000..9c1540b --- /dev/null +++ b/freeclaw/freeclaw/voice-box/api/_reactions.js @@ -0,0 +1,78 @@ +// Reaction toggles — positive-only voting (Support on problems, Upvote on ideas). +// One vote per anonymous browser per item; tapping again removes it. +import supabase from './_db-client.js'; +import { cors, checkUser, clean } from './_auth.js'; + +// Normalize legacy/synonym kinds from older cached clients so nobody +// ever gets an "invalid reaction" error. +const NORMALIZE = { + support: 'support', like: 'support', important: 'support', urgent: 'support', + disagree: 'disagree', dislike: 'disagree', unsupport: 'disagree', unsupported: 'disagree', + upvote: 'upvote', + // Nuanced emotional reactions + concerned: 'concerned', frustrated: 'frustrated', appreciate: 'appreciate', +}; +const OPPOSITES = {}; // no opposing kinds — voting is positive-only + +export default async function handler(req, res) { + cors(res); + if (req.method === 'OPTIONS') return res.status(204).end(); + + try { + if (req.method === 'GET') { + const { author, target } = req.query; + let q = supabase.from('reactions').select('*'); + if (author) q = q.eq('author_id', author); + if (target) q = q.eq('target_id', target); + const { data, error } = await q.limit(1000); + if (error) throw error; + return res.status(200).json(data); + } + + if (req.method === 'POST') { + const b = req.body || {}; + const author_id = clean(b.author_id, 40); + const kind = NORMALIZE[b.kind] || null; + const target_id = clean(b.target_id, 60); + const target_type = ['post', 'comment', 'suggestion'].includes(b.target_type) ? b.target_type : 'post'; + if (!kind || !target_id) return res.status(400).json({ error: 'Invalid reaction' }); + const gate = await checkUser(author_id); + if (!gate.ok) return res.status(403).json({ error: gate.error }); + + // Toggle: remove if exists, insert otherwise — and ALWAYS clear opposites + const { data: existing } = await supabase.from('reactions').select('id') + .eq('target_id', target_id).eq('author_id', author_id).eq('kind', kind).maybeSingle(); + + if (existing) { + await supabase.from('reactions').delete().eq('id', existing.id); + } else { + // Mutual exclusion: delete any opposing reactions by this user first + const opposites = OPPOSITES[kind] || []; + if (opposites.length) { + await supabase.from('reactions').delete() + .eq('target_id', target_id).eq('author_id', author_id).in('kind', opposites); + } + await supabase.from('reactions').insert({ target_id, target_type, author_id, kind }); + } + + // Activity resets the auto-deletion countdown + if (target_type === 'post' || target_type === 'suggestion') { + await supabase.from('posts').update({ updated_at: new Date().toISOString() }).eq('id', target_id); + } + + // Return fresh counts AND the caller's own reactions so the UI stays in perfect sync + const [{ data: rows }, { data: mineRows }] = await Promise.all([ + supabase.from('reactions').select('kind').eq('target_id', target_id), + supabase.from('reactions').select('kind').eq('target_id', target_id).eq('author_id', author_id), + ]); + const counts = {}; + (rows || []).forEach((r) => { counts[r.kind] = (counts[r.kind] || 0) + 1; }); + return res.status(200).json({ toggled: !existing, counts, mine: (mineRows || []).map((r) => r.kind) }); + } + + return res.status(405).json({ error: 'Method not allowed' }); + } catch (err) { + console.error('reactions API error:', err); + return res.status(500).json({ error: err.message }); + } +} diff --git a/freeclaw/freeclaw/voice-box/api/_reports.js b/freeclaw/freeclaw/voice-box/api/_reports.js new file mode 100644 index 0000000..7bb6384 --- /dev/null +++ b/freeclaw/freeclaw/voice-box/api/_reports.js @@ -0,0 +1,48 @@ +// Report queue for moderation +import supabase from './_db-client.js'; +import { cors, isAdmin, checkUser, auditLog, clean } from './_auth.js'; + +export default async function handler(req, res) { + cors(res); + if (req.method === 'OPTIONS') return res.status(204).end(); + + try { + if (req.method === 'GET') { + if (!(await isAdmin(req))) return res.status(403).json({ error: 'Admin only' }); + const { data, error } = await supabase.from('reports').select('*').order('created_at', { ascending: false }).limit(300); + if (error) throw error; + return res.status(200).json(data); + } + + if (req.method === 'POST') { + const b = req.body || {}; + const author_id = clean(b.author_id, 40); + const gate = await checkUser(author_id); + if (!gate.ok) return res.status(403).json({ error: gate.error }); + const row = { + target_id: clean(b.target_id, 60), + target_type: ['post', 'comment', 'poll'].includes(b.target_type) ? b.target_type : 'post', + reason: clean(b.reason, 300) || 'No reason given', + author_id, + }; + if (!row.target_id) return res.status(400).json({ error: 'Missing target' }); + const { data, error } = await supabase.from('reports').insert(row).select().single(); + if (error) throw error; + return res.status(201).json(data); + } + + if (req.method === 'PUT') { + if (!(await isAdmin(req))) return res.status(403).json({ error: 'Admin only' }); + const b = req.body || {}; + const { data, error } = await supabase.from('reports').update({ status: clean(b.status, 20) || 'resolved' }).eq('id', b.id).select().single(); + if (error) throw error; + await auditLog('admin', 'resolve_report', String(b.id)); + return res.status(200).json(data); + } + + return res.status(405).json({ error: 'Method not allowed' }); + } catch (err) { + console.error('reports API error:', err); + return res.status(500).json({ error: err.message }); + } +} diff --git a/freeclaw/freeclaw/voice-box/api/_upload.js b/freeclaw/freeclaw/voice-box/api/_upload.js new file mode 100644 index 0000000..d0af53f --- /dev/null +++ b/freeclaw/freeclaw/voice-box/api/_upload.js @@ -0,0 +1,32 @@ +// Image upload to Supabase Storage (anonymous, size-capped) +import supabase from './_db-client.js'; +import { cors, checkUser, clean } from './_auth.js'; + +export const config = { api: { bodyParser: { sizeLimit: '4mb' } } }; + +export default async function handler(req, res) { + cors(res); + if (req.method === 'OPTIONS') return res.status(204).end(); + if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' }); + + try { + const { fileBase64, contentType, author_id } = req.body || {}; + const gate = await checkUser(clean(author_id, 40)); + if (!gate.ok) return res.status(403).json({ error: gate.error }); + if (!fileBase64) return res.status(400).json({ error: 'No file' }); + if (!/^image\/(png|jpe?g|gif|webp)$/.test(contentType || '')) { + return res.status(400).json({ error: 'Only PNG, JPG, GIF or WebP images allowed.' }); + } + const buffer = Buffer.from(fileBase64, 'base64'); + if (buffer.length > 3 * 1024 * 1024) return res.status(400).json({ error: 'Image must be under 3 MB.' }); + const ext = contentType.split('/')[1].replace('jpeg', 'jpg'); + const fileName = `img_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}.${ext}`; + const { error } = await supabase.storage.from('voicebox-media').upload(fileName, buffer, { contentType, upsert: true }); + if (error) throw error; + const { data: urlData } = supabase.storage.from('voicebox-media').getPublicUrl(fileName); + return res.status(200).json({ url: urlData.publicUrl }); + } catch (err) { + console.error('upload API error:', err); + return res.status(500).json({ error: err.message }); + } +} diff --git a/freeclaw/freeclaw/voice-box/api/_users.js b/freeclaw/freeclaw/voice-box/api/_users.js new file mode 100644 index 0000000..d9a6552 --- /dev/null +++ b/freeclaw/freeclaw/voice-box/api/_users.js @@ -0,0 +1,38 @@ +// Anonymous account registration + heartbeat + own-status check +// Called on app load so every live browser shows up in admin immediately, +// and so banned/suspended users see their status. +import supabase from './_db-client.js'; +import { cors, clean } from './_auth.js'; + +export default async function handler(req, res) { + cors(res); + if (req.method === 'OPTIONS') return res.status(204).end(); + if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' }); + + try { + const anon_id = clean(req.body?.anon_id, 40).toLowerCase(); + if (!anon_id || !anon_id.startsWith('anon_')) return res.status(400).json({ error: 'Invalid anonymous ID' }); + + const { data: existing } = await supabase.from('users_meta').select('*').eq('anon_id', anon_id).maybeSingle(); + + if (!existing) { + const { error: insErr } = await supabase.from('users_meta').insert({ anon_id, warnings: [] }); + if (insErr) console.error('users_meta insert failed:', insErr.message); + } + + const meta = existing || { banned: false, suspended_until: null, strikes: 0, warnings: [] }; + const suspended = meta.suspended_until && new Date(meta.suspended_until) > new Date(); + return res.status(200).json({ + ok: true, + banned: !!meta.banned, + suspended: !!suspended, + suspended_until: suspended ? meta.suspended_until : null, + strikes: meta.strikes || 0, + warning_count: (meta.warnings || []).length, + latest_warning: (meta.warnings || []).slice(-1)[0]?.text || null, + }); + } catch (err) { + console.error('users API error:', err); + return res.status(500).json({ error: err.message }); + } +} diff --git a/freeclaw/freeclaw/voice-box/api/index.js b/freeclaw/freeclaw/voice-box/api/index.js new file mode 100644 index 0000000..d42cafc --- /dev/null +++ b/freeclaw/freeclaw/voice-box/api/index.js @@ -0,0 +1,62 @@ +// Consolidated Vercel serverless entry point. +// All /api/* routes are rewritten to this file by vercel.json. +// Individual handlers live as _prefixed modules (private, no separate function). + +import { cors as corsFn } from './_auth.js'; + +import posts from './_posts.js'; +import comments from './_comments.js'; +import polls from './_polls.js'; +import reactions from './_reactions.js'; +import chat from './_chat.js'; +import reports from './_reports.js'; +import admin from './_admin.js'; +import announcement from './_announcement.js'; +import upload from './_upload.js'; +import users from './_users.js'; +import agent from './_agent.js'; +import ai from './_ai.js'; +import assist from './_assist.js'; +import me from './_me.js'; +import providers from './_providers.js'; +import agentChat from './_agent-chat.js'; + +// upload.js needs a 4 MB body limit; the rest are fine with the default. +export const config = { + api: { bodyParser: { sizeLimit: '4mb' } }, +}; + +const routes = { + posts, + comments, + polls, + reactions, + chat, + reports, + admin, + announcement, + upload, + users, + agent, + ai, + assist, + me, + providers, + 'agent-chat': agentChat, +}; + +export default async function handler(req, res) { + // req.url will be the original e.g. /api/posts (the rewrite preserves it) + // Strip query strings before splitting + const pathname = req.url.split('?')[0]; + const parts = pathname.split('/').filter(Boolean); + const endpoint = parts[1]; // ['api', 'posts', …] → 'posts' + + const routeHandler = routes[endpoint]; + if (!routeHandler) { + corsFn(res); + return res.status(404).json({ error: 'Not found' }); + } + + return routeHandler(req, res); +} diff --git a/freeclaw/freeclaw/voice-box/docs/2026-07-12-api-providers-agent-chat-design.md b/freeclaw/freeclaw/voice-box/docs/2026-07-12-api-providers-agent-chat-design.md new file mode 100644 index 0000000..1e11be6 --- /dev/null +++ b/freeclaw/freeclaw/voice-box/docs/2026-07-12-api-providers-agent-chat-design.md @@ -0,0 +1,574 @@ +# Design: Multi-Provider API Keys + Admin Agent Chat + +**Date:** 2026-07-12 +**Status:** Approved +**Author:** Opencode AGI + +## 1. Overview + +Add two major features to Voice Box admin panel: +1. **Multi-Provider API Key Management** — Configure and switch between 7 AI providers with automatic failover +2. **Admin Agent Chat** — Natural language interface to manage the entire app with approval workflow + +## 2. Architecture + +### 2.1 Hybrid Key Storage (Safest Approach) + +**Existing env vars remain as fallback.** DB keys override when present. + +``` +Priority Chain: +1. DB settings (api_providers) — admin can change without redeploy +2. Env vars (NVIDIA_API_KEY, ANTHROPIC_API_KEY) — existing, backward compatible +3. Heuristic fallback — no API key needed +``` + +**Why hybrid is safest:** +- Zero breaking changes for existing deployments +- Env vars still work if DB is empty +- Admin can upgrade to DB-managed keys anytime +- No migration needed for existing users + +### 2.2 Provider Chain with Failover + +``` +Request → Try Provider 1 (DB or env) → Success? Return + ↓ Fail + Try Provider 2 → Success? Return + ↓ Fail + Try Provider 3 → ... + ↓ All fail + Heuristic fallback +``` + +**Failover triggers:** +- HTTP 429 (rate limit) → skip to next provider +- HTTP 401/403 (bad key) → skip, mark provider as failed +- Timeout (8s) → skip to next +- Network error → skip to next +- Invalid JSON response → skip to next + +**Provider priority order (configurable in DB):** +1. OpenAI (GPT-4o) — best reasoning +2. Anthropic (Claude Sonnet) — best analysis +3. Google Gemini (2.5 Pro) — best multimodal +4. NVIDIA NIM (Llama 3.1 70B) — free tier available +5. Mistral (Large) — fast +6. DeepSeek (V3) — cheap +7. Groq (Llama 3.3 70B) — fastest inference + +## 3. Database Schema + +### 3.1 New Settings Row + +```sql +INSERT INTO settings (key, value) VALUES ('api_providers', '{ + "openai": { + "key": "", + "model": "gpt-4o", + "enabled": false, + "priority": 1, + "status": "untested", + "last_tested": null + }, + "anthropic": { + "key": "", + "model": "claude-sonnet-4-6", + "enabled": false, + "priority": 2, + "status": "untested", + "last_tested": null + }, + "gemini": { + "key": "", + "model": "gemini-2.5-pro", + "enabled": false, + "priority": 3, + "status": "untested", + "last_tested": null + }, + "nvidia": { + "key": "", + "model": "meta/llama-3.1-70b-instruct", + "enabled": false, + "priority": 4, + "status": "untested", + "last_tested": null + }, + "mistral": { + "key": "", + "model": "mistral-large-latest", + "enabled": false, + "priority": 5, + "status": "untested", + "last_tested": null + }, + "deepseek": { + "key": "", + "model": "deepseek-chat", + "enabled": false, + "priority": 6, + "status": "untested", + "last_tested": null + }, + "groq": { + "key": "", + "model": "llama-3.3-70b-versatile", + "enabled": false, + "priority": 7, + "status": "untested", + "last_tested": null + } +}'); +``` + +### 3.2 Agent Conversations Table + +```sql +CREATE TABLE agent_conversations ( + id SERIAL PRIMARY KEY, + session_id TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'system')), + content TEXT NOT NULL, + actions JSONB DEFAULT '[]', + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX idx_agent_conv_session ON agent_conversations(session_id); +``` + +## 4. API Endpoints + +### 4.1 Provider Management (`/api/admin`) + +**GET `/api/admin?action=providers`** +- Returns all provider configs (keys masked) +- Requires admin auth + +**POST `/api/admin` with `action: "update_provider"`** +```json +{ + "action": "update_provider", + "provider": "openai", + "config": { + "key": "sk-...", + "model": "gpt-4o", + "enabled": true, + "priority": 1 + } +} +``` + +**POST `/api/admin` with `action: "test_provider"`** +```json +{ + "action": "test_provider", + "provider": "openai" +} +``` +- Sends test prompt: "Say 'hello' in JSON: {\"response\": \"hello\"}" +- Returns: `{ success: true, latency_ms: 234, model: "gpt-4o" }` + +**POST `/api/admin` with `action: "reorder_providers"`** +```json +{ + "action": "reorder_providers", + "order": ["anthropic", "openai", "gemini", "groq", "nvidia", "mistral", "deepseek"] +} +``` + +### 4.2 Agent Chat (`/api/admin`) + +**POST `/api/admin` with `action: "agent_chat"`** +```json +{ + "action": "agent_chat", + "session_id": "abc123", + "message": "Hide all spam posts" +} +``` + +**Response:** +```json +{ + "reply": "I found 3 spam posts. Here's what I'll do:", + "actions": [ + { + "id": "act_1", + "type": "hide_post", + "target": "post_xyz", + "title": "Buy cheap followers!!!", + "before": { "hidden": false }, + "after": { "hidden": true }, + "reason": "Spam: promotional content with external link" + }, + ... + ], + "requires_approval": true +} +``` + +**POST `/api/admin` with `action: "agent_execute"`** +```json +{ + "action": "agent_execute", + "session_id": "abc123", + "action_ids": ["act_1", "act_2", "act_3"] +} +``` +- Executes approved actions +- Returns results with before/after states + +## 5. LLM Tool Definitions + +The agent chat uses **function calling** (OpenAI format) or **tool use** (Anthropic format) with these tools: + +### 5.1 Tool Definitions + +```json +[ + { + "name": "get_posts", + "description": "Retrieve posts with optional filters", + "parameters": { + "type": "object", + "properties": { + "status": { "type": "string", "enum": ["reported", "verified", "in_progress", "solved", "archived"] }, + "category": { "type": "string" }, + "limit": { "type": "integer", "default": 20 } + } + } + }, + { + "name": "update_post", + "description": "Update a post's status, priority, or content", + "parameters": { + "type": "object", + "properties": { + "post_id": { "type": "string" }, + "status": { "type": "string" }, + "priority": { "type": "string" }, + "admin_reply": { "type": "string" }, + "hidden": { "type": "boolean" } + }, + "required": ["post_id"] + } + }, + { + "name": "delete_post", + "description": "Soft-delete a post (marks as deleted, not removed)", + "parameters": { + "type": "object", + "properties": { + "post_id": { "type": "string" }, + "reason": { "type": "string" } + }, + "required": ["post_id", "reason"] + } + }, + { + "name": "warn_user", + "description": "Issue a warning to an anonymous user", + "parameters": { + "type": "object", + "properties": { + "anon_id": { "type": "string" }, + "reason": { "type": "string" }, + "strike": { "type": "boolean", "default": true } + }, + "required": ["anon_id", "reason"] + } + }, + { + "name": "ban_user", + "description": "Ban an anonymous user (prevents posting)", + "parameters": { + "type": "object", + "properties": { + "anon_id": { "type": "string" }, + "reason": { "type": "string" } + }, + "required": ["anon_id", "reason"] + } + }, + { + "name": "get_user_posts", + "description": "Get all posts from a specific anonymous user", + "parameters": { + "type": "object", + "properties": { + "anon_id": { "type": "string" } + }, + "required": ["anon_id"] + } + }, + { + "name": "create_poll", + "description": "Create a new poll", + "parameters": { + "type": "object", + "properties": { + "title": { "type": "string" }, + "options": { "type": "array", "items": { "type": "string" } }, + "ptype": { "type": "string", "enum": ["yesno", "choice", "rating"] } + }, + "required": ["title"] + } + }, + { + "name": "close_poll", + "description": "Close a poll to new votes", + "parameters": { + "type": "object", + "properties": { + "poll_id": { "type": "integer" } + }, + "required": ["poll_id"] + } + }, + { + "name": "get_analytics", + "description": "Get platform analytics and statistics", + "parameters": { + "type": "object", + "properties": { + "period": { "type": "string", "enum": ["day", "week", "month", "all"] }, + "metric": { "type": "string", "enum": ["posts", "users", "reactions", "categories", "all"] } + } + } + }, + { + "name": "export_data", + "description": "Export data as CSV", + "parameters": { + "type": "object", + "properties": { + "table": { "type": "string", "enum": ["posts", "comments", "users", "polls"] }, + "format": { "type": "string", "enum": ["csv", "json"] } + }, + "required": ["table"] + } + }, + { + "name": "get_activity_logs", + "description": "Retrieve recent activity logs", + "parameters": { + "type": "object", + "properties": { + "limit": { "type": "integer", "default": 50 }, + "action": { "type": "string" } + } + } + }, + { + "name": "update_settings", + "description": "Update system settings", + "parameters": { + "type": "object", + "properties": { + "key": { "type": "string" }, + "value": { "type": "object" } + }, + "required": ["key", "value"] + } + }, + { + "name": "set_announcement", + "description": "Set or clear a site-wide announcement", + "parameters": { + "type": "object", + "properties": { + "text": { "type": "string" }, + "enabled": { "type": "boolean" } + } + } + } +] +``` + +### 5.2 System Prompt for Agent + +``` +You are the Voice Box admin agent. You help school administrators manage their anonymous feedback platform. + +CAPABILITIES: +- View, edit, hide, delete posts +- Warn or ban anonymous users +- Create and manage polls +- Generate analytics reports +- Export data +- View activity logs +- Update system settings +- Post announcements + +RULES: +1. ALWAYS show your plan before executing actions +2. NEVER auto-execute destructive actions (delete, ban, password change) +3. Group related actions together when possible +4. Explain WHY you're recommending each action +5. If uncertain, ask for clarification +6. Be concise — administrators are busy + +RESPONSE FORMAT: +- Start with a brief summary of what you found +- List specific actions you recommend (with before/after states) +- End with "Ready to execute?" or ask a clarifying question +``` + +## 6. Frontend Components + +### 6.1 AdminSettings.tsx — New Provider Section + +``` +┌─────────────────────────────────────────────┐ +│ API Providers │ +├─────────────────────────────────────────────┤ +│ │ +│ Priority Provider Model Status │ +│ ──────── ────────── ───────────── ────── │ +│ 1 OpenAI gpt-4o 🟢 OK │ +│ [Edit] [Test] [Disable] │ +│ │ +│ 2 Anthropic claude-sonnet 🟢 OK │ +│ [Edit] [Test] [Disable] │ +│ │ +│ 3 Gemini gemini-2.5-pro ⚪ Off │ +│ [Edit] [Test] [Enable] │ +│ │ +│ 4 NVIDIA NIM llama-3.1-70b ⚪ Off │ +│ [Edit] [Test] [Enable] │ +│ │ +│ 5 Mistral mistral-large ⚪ Off │ +│ [Edit] [Test] [Enable] │ +│ │ +│ 6 DeepSeek deepseek-chat ⚪ Off │ +│ [Edit] [Test] [Enable] │ +│ │ +│ 7 Groq llama-3.3-70b ⚪ Off │ +│ [Edit] [Test] [Enable] │ +│ │ +│ [Test All] [Reset to Defaults] │ +│ │ +│ ℹ️ Providers are tried in priority order. │ +│ If one fails, the next is used automatically. │ +│ Env vars are used as fallback if no DB keys. │ +└─────────────────────────────────────────────┘ +``` + +### 6.2 AgentPanel.tsx — Chat Interface + +``` +┌─────────────────────────────────────────────┐ +│ Admin Agent │ +├─────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ 🤖 Hi! I'm your admin assistant. │ │ +│ │ I can help you manage posts, users, │ │ +│ │ polls, and analytics. │ │ +│ │ │ │ +│ │ What would you like to do? │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────┐ │ +│ │ Hide all spam posts │ │ +│ └─────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ 🤖 I found 3 spam posts. Here's │ │ +│ │ what I'll do: │ │ +│ │ │ │ +│ │ ┌─────────────────────────────┐ │ │ +│ │ │ Hide "Buy cheap followers" │ │ │ +│ │ │ Reason: promotional spam │ │ │ +│ │ │ [Execute] [Cancel] │ │ │ +│ │ └─────────────────────────────┘ │ │ +│ │ ┌─────────────────────────────┐ │ │ +│ │ │ Hide "Free crypto scam" │ │ │ +│ │ │ Reason: scam content │ │ │ +│ │ │ [Execute] [Cancel] │ │ │ +│ │ └─────────────────────────────┘ │ │ +│ │ ┌─────────────────────────────┐ │ │ +│ │ │ Hide "Click here for prize" │ │ │ +│ │ │ Reason: phishing attempt │ │ │ +│ │ │ [Execute] [Cancel] │ │ │ +│ │ └─────────────────────────────┘ │ │ +│ │ │ │ +│ │ [Execute All] [Cancel All] │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ Type a command... [↑] │ │ +│ └─────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────┘ +``` + +## 7. Implementation Files + +### New Files +1. `api/_providers.js` — Provider registry, failover chain, test connections +2. `api/_agent-chat.js` — Agent chat handler with tool execution +3. `src/pages/admin/ProviderSettings.tsx` — Provider management UI +4. `src/pages/admin/AgentChat.tsx` — Chat interface component + +### Modified Files +1. `api/_ai.js` — Use provider chain instead of hardcoded providers +2. `api/_admin.js` — Add provider management and agent chat routes +3. `src/pages/admin/AdminSettings.tsx` — Import ProviderSettings component +4. `src/pages/admin/AgentPanel.tsx` — Import AgentChat component + +## 8. Error Handling + +### Provider Failover +``` +Provider timeout (8s) → Log warning → Try next provider +Provider 401/403 → Mark as failed → Try next provider +Provider 429 → Wait 1s → Try next provider +All providers failed → Use heuristic → Show warning to user +``` + +### Agent Chat Errors +``` +LLM returns invalid JSON → Retry once → Fall back to heuristic +Tool execution fails → Show error to user → Rollback if partial +User cancels mid-execution → Stop queue → Show what was done +Session expires → Prompt re-login → Preserve conversation +``` + +## 9. Security + +### API Key Protection +- Keys stored in DB `settings` table (admin-only access via RLS) +- Never sent to frontend — only used server-side +- Masked in UI (show last 4 chars only) +- Test endpoint uses isolated fetch (no side effects) + +### Agent Chat Safety +- All actions require admin auth +- Destructive actions (delete, ban) require explicit confirmation +- Rate limit: 10 actions per minute per session +- Full audit log of all agent actions +- Agent cannot access API keys or system secrets + +## 10. Testing Plan + +### Provider Failover +1. Configure OpenAI with invalid key → verify fallback to Anthropic +2. Configure all providers with invalid keys → verify heuristic fallback +3. Test rate limiting → verify automatic retry with next provider +4. Test timeout → verify failover within 8 seconds + +### Agent Chat +1. Test all tool functions with valid inputs +2. Test destructive actions require confirmation +3. Test rate limiting (10 actions/min) +4. Test session expiry preserves conversation +5. Test audit logging for all actions + +## 11. Rollback Plan + +If anything breaks: +1. Remove `api_providers` row from settings → env vars take over +2. Disable agent chat in admin → old suggestion panel still works +3. No database schema changes → zero migration risk diff --git a/freeclaw/freeclaw/voice-box/src/pages/admin/AgentChat.tsx b/freeclaw/freeclaw/voice-box/src/pages/admin/AgentChat.tsx new file mode 100644 index 0000000..641f652 --- /dev/null +++ b/freeclaw/freeclaw/voice-box/src/pages/admin/AgentChat.tsx @@ -0,0 +1,269 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { MessageSquare, Send, PlayCircle, Check, AlertTriangle, Loader2, Trash2, Key } from 'lucide-react'; +import { api } from '../../lib/api'; +import { useApp } from '../../contexts/AppContext'; + +interface Action { + id: string; + tool: string; + args: Record; + reason: string; + destructive: boolean; +} + +interface Message { + role: 'user' | 'assistant' | 'system'; + content: string; + actions?: Action[]; + provider?: string; + model?: string; + created_at: string; +} + +const TOOL_LABELS: Record = { + get_posts: 'View posts', + update_post: 'Update post', + delete_post: 'Delete post', + warn_user: 'Warn user', + ban_user: 'Ban user', + get_user_posts: 'View user posts', + create_poll: 'Create poll', + close_poll: 'Close poll', + get_analytics: 'Get analytics', + get_activity_logs: 'View activity logs', + set_announcement: 'Set announcement', +}; + +const QUICK_ACTIONS = [ + { label: 'Show analytics', msg: 'Show me a summary of platform activity' }, + { label: 'Recent posts', msg: 'Show me the 5 most recent posts' }, + { label: 'Pending reports', msg: 'Show me all reported posts' }, + { label: 'User breakdown', msg: 'Break down post counts by anonymous user' }, + { label: 'Post by category', msg: 'Show me post counts by category' }, +]; + +export default function AgentChat() { + const { toast } = useApp(); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(''); + const [sessionId] = useState(() => `s_${Date.now()}`); + const [busy, setBusy] = useState(false); + const [executing, setExecuting] = useState>(new Set()); + const [showHistory, setShowHistory] = useState(false); + const [sessions, setSessions] = useState([]); + const chatRef = useRef(null); + + // Load session history + const loadHistory = useCallback(async () => { + try { + const data = await api.post('/api/agent-chat', { action: 'history', session_id: sessionId }); + if (Array.isArray(data)) { + setMessages(data.map((d: any) => ({ + role: d.role, + content: d.content, + actions: d.actions, + created_at: d.created_at, + }))); + } + } catch { /* empty */ } + }, [sessionId]); + + const loadSessions = useCallback(async () => { + try { + const data = await api.post('/api/agent-chat', { action: 'sessions' }); + setSessions(data); + } catch { /* empty */ } + }, []); + + useEffect(() => { loadHistory(); loadSessions(); }, [loadHistory, loadSessions]); + + // Auto-scroll + useEffect(() => { + chatRef.current?.scrollTo({ top: chatRef.current.scrollHeight, behavior: 'smooth' }); + }, [messages]); + + const send = async (text?: string) => { + const msg = (text || input).trim(); + if (!msg || busy) return; + setInput(''); + setMessages((m) => [...m, { role: 'user', content: msg, created_at: new Date().toISOString() }]); + setBusy(true); + try { + const r = await api.post('/api/agent-chat', { action: 'chat', message: msg, session_id: sessionId }); + const assistantMsg: Message = { + role: 'assistant', + content: r.reply, + actions: r.actions, + provider: r.provider, + model: r.model, + created_at: new Date().toISOString(), + }; + setMessages((m) => [...m, assistantMsg]); + loadSessions(); + } catch (e: any) { + toast(e.message, 'err'); + setMessages((m) => [...m, { role: 'system', content: `Error: ${e.message}`, created_at: new Date().toISOString() }]); + } + setBusy(false); + }; + + const executeActions = async (actions: Action[]) => { + const ids = new Set(actions.map((a) => a.id)); + setExecuting(ids); + try { + const r = await api.post('/api/agent-chat', { action: 'execute', actions, session_id: sessionId }); + const results = r.results || []; + const summary = results.map((res: any) => { + const label = TOOL_LABELS[res.id] || res.id; + return res.success ? `✓ ${label}` : `✗ ${label}: ${res.error}`; + }).join('\n'); + setMessages((m) => [...m, { + role: 'system', + content: `Actions executed:\n${summary}`, + created_at: new Date().toISOString(), + }]); + toast('Actions executed', 'ok'); + } catch (e: any) { + toast(e.message, 'err'); + } + setExecuting(new Set()); + }; + + const cancelActions = (actionId: string) => { + // Remove a single action from the pending list by updating the message + setMessages((m) => m.map((msg) => { + if (msg.actions) { + const filtered = msg.actions.filter((a) => a.id !== actionId); + return { ...msg, actions: filtered.length > 0 ? filtered : undefined }; + } + return msg; + })); + }; + + return ( +
+
+
+

Agent Chat

+

Natural language admin — ask questions, get analytics, manage posts. Destructive actions require your approval.

+
+
+ +
+
+ + {showHistory ? ( +
+ {sessions.length === 0 &&

No previous sessions

} + {sessions.map((s) => ( +
+ {s.session_id} + {new Date(s.last_message).toLocaleString()} +
+ ))} +
+ ) : ( + <> + {/* Chat area */} +
+ {messages.length === 0 && ( +
+ +

Admin Agent

+

Ask me anything about your platform, or use a quick action below.

+
+ )} + {messages.map((m, i) => ( +
+
+

{m.content}

+ + {/* Provider badge */} + {m.provider && m.provider !== 'none' && ( +

{m.provider}/{m.model}

+ )} + + {/* Action cards */} + {m.actions && m.actions.length > 0 && ( +
+

Proposed actions:

+ {m.actions.map((a) => { + const isExecuting = executing.has(a.id); + return ( +
+
+ {TOOL_LABELS[a.tool] || a.tool} + {a.destructive && ⚠ Destructive} +
+ {a.reason &&

{a.reason}

} +
{JSON.stringify(a.args)}
+
+ + +
+
+ ); + })} + {/* Execute all button */} + {m.actions.length > 1 && ( + + )} +
+ )} +
+
+ ))} + {busy && ( +
+
+ Thinking… +
+
+ )} +
+ + {/* Quick actions */} +
+ {QUICK_ACTIONS.map((qa) => ( + + ))} +
+ + {/* Input */} +
+ setInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && send()} + disabled={busy} + /> + +
+ + )} +
+ ); +} diff --git a/freeclaw/freeclaw/voice-box/src/pages/admin/ProviderSettings.tsx b/freeclaw/freeclaw/voice-box/src/pages/admin/ProviderSettings.tsx new file mode 100644 index 0000000..d998724 --- /dev/null +++ b/freeclaw/freeclaw/voice-box/src/pages/admin/ProviderSettings.tsx @@ -0,0 +1,191 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Key, RefreshCcw, Check, X, AlertTriangle, ChevronDown, ChevronUp, Zap } from 'lucide-react'; +import { api } from '../../lib/api'; +import { useApp } from '../../contexts/AppContext'; + +interface ProviderConfig { + id: string; + name: string; + model: string; + enabled: boolean; + priority: number; + status: 'untested' | 'ok' | 'failed'; + last_tested: string | null; + key_masked: string; + has_env_key: boolean; +} + +const PROVIDER_COLORS: Record = { + openai: '#10a37f', + anthropic: '#d4a574', + gemini: '#4285f4', + nvidia: '#76b900', + mistral: '#ff6f00', + deepseek: '#4d6bfe', + groq: '#f55036', +}; + +export default function ProviderSettings() { + const { toast } = useApp(); + const [providers, setProviders] = useState>({}); + const [loading, setLoading] = useState(true); + const [testing, setTesting] = useState(null); + const [testingAll, setTestingAll] = useState(false); + const [editing, setEditing] = useState(null); + const [editKey, setEditKey] = useState(''); + const [editModel, setEditModel] = useState(''); + const [expanded, setExpanded] = useState(null); + + const load = useCallback(async () => { + try { + const data = await api.get('/api/providers?action=list'); + setProviders(data); + } catch (e: any) { toast(e.message, 'err'); } + setLoading(false); + }, [toast]); + + useEffect(() => { load(); }, [load]); + + const testProvider = async (pid: string) => { + setTesting(pid); + try { + const r = await api.post('/api/providers', { action: 'test_provider', provider: pid }); + if (r.success) { + toast(`${providers[pid].name}: OK (${r.latency_ms}ms)`, 'ok'); + } else { + toast(`${providers[pid].name}: Failed — ${r.error}`, 'err'); + } + await load(); + } catch (e: any) { toast(e.message, 'err'); } + setTesting(null); + }; + + const testAll = async () => { + setTestingAll(true); + try { + const r = await api.post('/api/providers', { action: 'test_all' }); + const ok = Object.values(r).filter((v: any) => v.success).length; + const total = Object.keys(r).length; + toast(`Tested ${total} providers: ${ok} OK, ${total - ok} failed`, ok > 0 ? 'ok' : 'err'); + await load(); + } catch (e: any) { toast(e.message, 'err'); } + setTestingAll(false); + }; + + const saveProvider = async (pid: string) => { + try { + const config: any = { model: editModel }; + if (editKey) config.key = editKey; + config.enabled = true; + await api.post('/api/providers', { action: 'update_provider', provider: pid, config }); + toast(`${providers[pid].name} updated`, 'ok'); + setEditing(null); setEditKey(''); setEditModel(''); + await load(); + } catch (e: any) { toast(e.message, 'err'); } + }; + + const toggleProvider = async (pid: string, enabled: boolean) => { + try { + await api.post('/api/providers', { action: 'update_provider', provider: pid, config: { enabled } }); + toast(`${providers[pid].name} ${enabled ? 'enabled' : 'disabled'}`, 'ok'); + await load(); + } catch (e: any) { toast(e.message, 'err'); } + }; + + const sorted = Object.values(providers).sort((a, b) => a.priority - b.priority); + + return ( +
+
+
+

API Providers

+

Configure AI providers for analysis and agent chat. Tried in priority order — if one fails, the next is used automatically. Env vars work as fallback.

+
+ +
+ + {loading &&
{[1, 2, 3].map((i) =>
)}
} + + {!loading && sorted.map((p) => ( +
+
+ {/* Priority number */} +
+ {p.priority} +
+ + {/* Name + model */} +
+
+ {p.name} + {p.model} +
+
+ {p.key_masked ? ( + Key: {p.key_masked} + ) : p.has_env_key ? ( + Using env var + ) : ( + No key configured + )} + {p.status === 'ok' && ● OK} + {p.status === 'failed' && ● Failed} + {p.status === 'untested' && ● Untested} +
+
+ + {/* Actions */} +
+ + + +
+
+ + {/* Expanded config */} + {expanded === p.id && ( +
+ {editing === p.id ? ( + <> +
+ + setEditKey(e.target.value)} /> +
+
+ + setEditModel(e.target.value)} /> +
+
+ + +
+ + ) : ( +
+ + {p.last_tested && Last tested: {new Date(p.last_tested).toLocaleString()}} +
+ )} +
+ )} +
+ ))} + +
+ + API keys are stored in your database and never sent to the browser. If no DB key is configured, environment variables are used as fallback. If neither exists, built-in heuristic engines keep all features functional. +
+
+ ); +} From 57460620836ce557cca57726f21d5aa358805685 Mon Sep 17 00:00:00 2001 From: VoiceBox Dev Date: Sun, 12 Jul 2026 15:19:43 +0530 Subject: [PATCH 03/37] =?UTF-8?q?feat:=20fully=20customizable=20provider?= =?UTF-8?q?=20system=20=E2=80=94=20default=20star,=20model=20selector,=20u?= =?UTF-8?q?nified=20failover?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add is_default flag to providers DB — click star to set default provider - buildChain() puts default provider first, then priority order - _ai.js now uses callLLMChain from _providers.js — no hardcoded NIM/Claude - _agent-chat.js shows which provider answered in response - ProviderSettings.tsx: star toggle, model presets dropdown, custom model input, key editing - All 7 providers fully configurable from admin UI - set_default, update_provider, test_provider, test_all actions in API --- .../freeclaw/voice-box/api/_agent-chat.js | 471 ++++++++++++++---- freeclaw/freeclaw/voice-box/api/_ai.js | 88 +--- freeclaw/freeclaw/voice-box/api/_providers.js | 80 ++- .../src/pages/admin/ProviderSettings.tsx | 229 ++++++--- 4 files changed, 626 insertions(+), 242 deletions(-) diff --git a/freeclaw/freeclaw/voice-box/api/_agent-chat.js b/freeclaw/freeclaw/voice-box/api/_agent-chat.js index 29b7aac..6d46133 100644 --- a/freeclaw/freeclaw/voice-box/api/_agent-chat.js +++ b/freeclaw/freeclaw/voice-box/api/_agent-chat.js @@ -1,52 +1,283 @@ -// Admin Agent Chat — natural language interface for managing Voice Box. -// Uses LLM function calling to plan actions; admin approves before execution. -// All actions are audited. Destructive actions require explicit confirmation. +// Admin Agent Chat — works autonomously with built-in analytics engine. +// LLM enhances responses when available; built-in intent matcher handles everything. +// All destructive actions require approval. All actions audited. import supabase from './_db-client.js'; import { cors, isAdmin, auditLog, clean } from './_auth.js'; import { callLLMChain } from './_providers.js'; -const TOOL_DEFS = [ - { name: 'get_posts', description: 'Retrieve posts with optional filters', parameters: { type: 'object', properties: { status: { type: 'string' }, category: { type: 'string' }, limit: { type: 'integer', default: 20 } } } }, - { name: 'update_post', description: "Update a post's status, priority, or content", parameters: { type: 'object', properties: { post_id: { type: 'string' }, status: { type: 'string' }, priority: { type: 'string' }, admin_reply: { type: 'string' }, hidden: { type: 'boolean' } }, required: ['post_id'] } }, - { name: 'delete_post', description: 'Soft-delete a post', parameters: { type: 'object', properties: { post_id: { type: 'string' }, reason: { type: 'string' } }, required: ['post_id', 'reason'] } }, - { name: 'warn_user', description: 'Issue a warning to an anonymous user', parameters: { type: 'object', properties: { anon_id: { type: 'string' }, reason: { type: 'string' } }, required: ['anon_id', 'reason'] } }, - { name: 'ban_user', description: 'Ban an anonymous user (prevents posting)', parameters: { type: 'object', properties: { anon_id: { type: 'string' }, reason: { type: 'string' } }, required: ['anon_id', 'reason'] } }, - { name: 'get_user_posts', description: 'Get all posts from a specific anonymous user', parameters: { type: 'object', properties: { anon_id: { type: 'string' } }, required: ['anon_id'] } }, - { name: 'create_poll', description: 'Create a new poll', parameters: { type: 'object', properties: { title: { type: 'string' }, options: { type: 'array', items: { type: 'string' } }, ptype: { type: 'string', enum: ['yesno', 'choice', 'rating'] } }, required: ['title'] } }, - { name: 'close_poll', description: 'Close a poll to new votes', parameters: { type: 'object', properties: { poll_id: { type: 'integer' } }, required: ['poll_id'] } }, - { name: 'get_analytics', description: 'Get platform analytics', parameters: { type: 'object', properties: { period: { type: 'string', enum: ['day', 'week', 'month', 'all'] } } } }, - { name: 'get_activity_logs', description: 'Retrieve recent activity logs', parameters: { type: 'object', properties: { limit: { type: 'integer', default: 50 } } } }, - { name: 'set_announcement', description: 'Set or clear a site-wide announcement', parameters: { type: 'object', properties: { text: { type: 'string' }, enabled: { type: 'boolean' } } } }, -]; +// ─── Built-in Intent Engine ─────────────────────────────────────── +// Pattern-matches user intent and queries DB directly. No LLM needed. -const SYSTEM_PROMPT = `You are the Voice Box admin agent. You help school administrators manage their anonymous feedback platform. +const INTENTS = [ + { + patterns: /\b(analytics?|stats?|dashboard|overview|summary|numbers?|count|how many)\b/i, + handler: async () => { + const [{ data: posts }, { data: users }, { data: comments }, { data: reactions }, { data: polls }, { data: chatThreads }] = await Promise.all([ + supabase.from('posts').select('id,category,status,created_at,deleted'), + supabase.from('users_meta').select('anon_id,created_at,banned'), + supabase.from('comments').select('id,created_at'), + supabase.from('reactions').select('id,kind'), + supabase.from('polls').select('id,title,total_votes,archived'), + supabase.from('chat_threads').select('id,created_at'), + ]); + const active = (posts || []).filter((p) => !p.deleted); + const cats = {}; + active.forEach((p) => { cats[p.category] = (cats[p.category] || 0) + 1; }); + const stats = {}; + active.forEach((p) => { stats[p.status] = (stats[p.status] || 0) + 1; }); + const bans = (users || []).filter((u) => u.banned).length; -CAPABILITIES: -- View, edit, hide, delete posts -- Warn or ban anonymous users -- Create and manage polls -- Generate analytics reports -- View activity logs -- Post announcements + return { + reply: `📊 **Platform Overview**\n\n` + + `**Posts:** ${active.length} total (${(posts || []).length - active.length} deleted)\n` + + `**Users:** ${(users || []).length} registered (${bans} banned)\n` + + `**Comments:** ${(comments || []).length}\n` + + `**Reactions:** ${(reactions || []).length}\n` + + `**Polls:** ${(polls || []).length} (${(polls || []).filter((p) => p.archived).length} archived)\n` + + `**Chat threads:** ${(chatThreads || []).length}\n\n` + + `**By category:** ${Object.entries(cats).map(([k, v]) => `${k}: ${v}`).join(', ') || 'none'}\n` + + `**By status:** ${Object.entries(stats).map(([k, v]) => `${k}: ${v}`).join(', ') || 'none'}`, + actions: [], + }; + }, + }, + { + patterns: /\b(recent|latest|new|posts?|show)\s*(posts?|content|feedback)?\b/i, + handler: async () => { + const { data } = await supabase.from('posts').select('id,title,category,status,created_at,deleted').order('created_at', { ascending: false }).limit(10); + const active = (data || []).filter((p) => !p.deleted); + if (!active.length) return { reply: 'No posts found.', actions: [] }; + const list = active.map((p, i) => `${i + 1}. **${p.title}** [${p.category}] — ${p.status} (${new Date(p.created_at).toLocaleDateString()})`).join('\n'); + return { reply: `📝 **Recent Posts** (last 10)\n\n${list}`, actions: [] }; + }, + }, + { + patterns: /\b(report|reported|flag|flagged|complaint|complaints)\b/i, + handler: async () => { + const { data } = await supabase.from('reports').select('*').order('created_at', { ascending: false }).limit(20); + if (!data?.length) return { reply: '✅ No reports found. Platform is clean.', actions: [] }; + const list = data.map((r, i) => `${i + 1}. Post \`${r.post_id}\` — ${r.reason || 'no reason'} (${r.status || 'pending'})`).join('\n'); + return { reply: `🚨 **Reports** (${data.length})\n\n${list}`, actions: [] }; + }, + }, + { + patterns: /\b(analy|break|category|categor|by cat|per cat)\b/i, + handler: async () => { + const { data } = await supabase.from('posts').select('category,deleted').eq('deleted', false); + const cats = {}; + (data || []).forEach((p) => { cats[p.category] = (cats[p.category] || 0) + 1; }); + const sorted = Object.entries(cats).sort((a, b) => b[1] - a[1]); + const total = (data || []).length; + const bars = sorted.map(([cat, count]) => { + const pct = total > 0 ? Math.round((count / total) * 100) : 0; + const bar = '█'.repeat(Math.round(pct / 5)); + return ` ${cat.padEnd(12)} ${bar} ${count} (${pct}%)`; + }).join('\n'); + return { reply: `📂 **Posts by Category**\n\n${bars || 'No posts yet'}`, actions: [] }; + }, + }, + { + patterns: /\b(user|users?|who|people|contributors?)\b.*(post|author|writ|creat|count)/i, + handler: async () => { + const { data } = await supabase.from('posts').select('author_id,deleted').eq('deleted', false); + const userCounts = {}; + (data || []).forEach((p) => { userCounts[p.author_id] = (userCounts[p.author_id] || 0) + 1; }); + const sorted = Object.entries(userCounts).sort((a, b) => b[1] - a[1]).slice(0, 10); + if (!sorted.length) return { reply: 'No posts from users yet.', actions: [] }; + const list = sorted.map(([id, count], i) => `${i + 1}. \`${id}\` — ${count} post${count > 1 ? 's' : ''}`).join('\n'); + return { reply: `👥 **Top Contributors**\n\n${list}`, actions: [] }; + }, + }, + { + patterns: /\b(activity|log|logs|recent action|audit|what happened|history)\b/i, + handler: async () => { + const { data } = await supabase.from('activity_logs').select('*').order('created_at', { ascending: false }).limit(15); + if (!data?.length) return { reply: 'No activity logs found.', actions: [] }; + const list = data.map((l) => `• [${l.actor}] ${l.action} — ${(l.detail || '').slice(0, 80)} (${new Date(l.created_at).toLocaleString()})`).join('\n'); + return { reply: `📋 **Recent Activity** (last 15)\n\n${list}`, actions: [] }; + }, + }, + { + patterns: /\b(poll|polls?|vote|voting|survey|survey)\b/i, + handler: async () => { + const { data } = await supabase.from('polls').select('id,title,total_votes,archived,created_at').order('created_at', { ascending: false }); + if (!data?.length) return { reply: 'No polls found.', actions: [] }; + const list = data.map((p, i) => `${i + 1}. **${p.title}** — ${p.total_votes || 0} votes ${p.archived ? '(archived)' : '(active)'}`).join('\n'); + return { reply: `📊 **Polls** (${data.length})\n\n${list}`, actions: [] }; + }, + }, + { + patterns: /\b(announcement|announce|banner|notice|message to all|broadcast)\b/i, + handler: async () => { + const { data } = await supabase.from('settings').select('value').eq('key', 'announcement').maybeSingle(); + const ann = data?.value; + if (!ann?.text) return { reply: '📢 No announcement is currently active.', actions: [] }; + return { + reply: `📢 **Current Announcement**\n\n"${ann.text}"\n\nStatus: ${ann.enabled ? '✅ Active' : '⏸️ Disabled'}`, + actions: [{ + tool: 'set_announcement', + args: { text: ann.text, enabled: !ann.enabled }, + reason: ann.enabled ? 'Disable announcement' : 'Enable announcement', + destructive: false, + }], + }; + }, + }, + { + patterns: /\b(hide|hidden|hidden post|show hidden)\b/i, + handler: async () => { + const { data } = await supabase.from('posts').select('id,title,category,hidden,created_at').eq('hidden', true).order('created_at', { ascending: false }); + if (!data?.length) return { reply: 'No hidden posts.', actions: [] }; + const list = data.map((p, i) => `${i + 1}. **${p.title}** [${p.category}] — hidden`).join('\n'); + return { reply: `🫥 **Hidden Posts** (${data.length})\n\n${list}`, actions: [] }; + }, + }, + { + patterns: /\b(help|what can you|commands?|capabilities|options)\b/i, + handler: async () => ({ + reply: `🤖 **Agent Chat — What I Can Do**\n\n` + + `**Analytics & Data:**\n` + + ` • "Show analytics" — platform overview with numbers\n` + + ` • "Recent posts" — latest 10 posts\n` + + ` • "Show reports" — reported/flagged posts\n` + + ` • "Posts by category" — breakdown chart\n` + + ` • "User contributions" — who posts the most\n` + + ` • "Activity logs" — recent admin actions\n` + + ` • "Polls" — all polls and vote counts\n` + + ` • "Announcements" — current site announcement\n` + + ` • "Hidden posts" — posts hidden from public view\n\n` + + `**Actions (need your approval):**\n` + + ` • "Hide post [id]" — hide a post\n` + + ` • "Delete post [id]" — soft-delete a post\n` + + ` • "Ban user [id]" — ban an anonymous user\n` + + ` • "Warn user [id] for [reason]" — issue warning\n` + + ` • "Create poll: [title]" — create a new poll\n` + + ` • "Set announcement: [text]" — post announcement\n\n` + + `**Tip:** Just ask naturally — I'll understand.`, + actions: [], + }), + }, + { + patterns: /\b(ban|suspend|block)\s*(user|account)?\s*(\w+)?/i, + handler: async (msg) => { + const match = msg.match(/\b(ban|suspend|block)\s*(?:user|account)?\s*(\w+)/i); + const anonId = match?.[2]; + if (!anonId) return { reply: 'Usage: "ban user [anonymous_id]"', actions: [] }; + const { data: user } = await supabase.from('users_meta').select('*').eq('anon_id', anonId.toLowerCase()).maybeSingle(); + if (!user) return { reply: `User \`${anonId}\` not found.`, actions: [] }; + if (user.banned) return { reply: `User \`${anonId}\` is already banned.`, actions: [] }; + return { + reply: `⚠️ **Ban User**\n\nUser: \`${anonId}\`\nPosts: ${(await supabase.from('posts').select('*', { count: 'exact', head: true }).eq('author_id', anonId.toLowerCase())).count || 0}\nWarnings: ${user.warnings?.length || 0}\n\nReady to ban — click Execute to confirm.`, + actions: [{ tool: 'ban_user', args: { anon_id: anonId, reason: 'Banned via admin agent' }, reason: `Ban user ${anonId}`, destructive: true }], + }; + }, + }, + { + patterns: /\b(warn|warning)\s*(user|account)?\s*(\w+)?(?:\s*(?:for|because|reason)[:\s]+(.+))?/i, + handler: async (msg) => { + const match = msg.match(/\b(warn|warning)\s*(?:user|account)?\s*(\w+)?(?:\s*(?:for|because|reason)[:\s]+(.+))?/i); + const anonId = match?.[2]; + const reason = match?.[3] || 'Warning issued by admin'; + if (!anonId) return { reply: 'Usage: "warn user [id] for [reason]"', actions: [] }; + const { data: user } = await supabase.from('users_meta').select('warnings,strikes').eq('anon_id', anonId.toLowerCase()).maybeSingle(); + if (!user) return { reply: `User \`${anonId}\` not found.`, actions: [] }; + return { + reply: `⚠️ **Warn User**\n\nUser: \`${anonId}\`\nPrevious warnings: ${user.warnings?.length || 0}\nReason: ${reason}\n\nClick Execute to issue the warning.`, + actions: [{ tool: 'warn_user', args: { anon_id: anonId, reason }, reason: `Warn user ${anonId}: ${reason}`, destructive: false }], + }; + }, + }, + { + patterns: /\b(hide|remove)\s*(?:post)?\s*(\w{8,})/i, + handler: async (msg) => { + const match = msg.match(/\b(hide|remove)\s*(?:post)?\s*(\w{8,})/i); + const postId = match?.[2]; + if (!postId) return { reply: 'Usage: "hide post [id]"', actions: [] }; + const { data: post } = await supabase.from('posts').select('id,title,category,hidden').eq('id', postId).maybeSingle(); + if (!post) return { reply: `Post \`${postId}\` not found.`, actions: [] }; + return { + reply: `${post.hidden ? 'Already hidden' : 'Ready to hide'}: **${post.title}** [${post.category}]\n\nClick Execute to ${post.hidden ? 'unhide' : 'hide'} this post.`, + actions: [{ tool: 'update_post', args: { post_id: postId, hidden: !post.hidden }, reason: post.hidden ? `Unhide post` : `Hide post: ${post.title}`, destructive: false }], + }; + }, + }, + { + patterns: /\b(delete|remove)\s*(?:post)?\s*(\w{8,})/i, + handler: async (msg) => { + const match = msg.match(/\b(delete|remove)\s*(?:post)?\s*(\w{8,})/i); + const postId = match?.[2]; + if (!postId) return { reply: 'Usage: "delete post [id]"', actions: [] }; + const { data: post } = await supabase.from('posts').select('id,title,category,deleted').eq('id', postId).maybeSingle(); + if (!post) return { reply: `Post \`${postId}\` not found.`, actions: [] }; + if (post.deleted) return { reply: `Post \`${postId}\` is already deleted.`, actions: [] }; + return { + reply: `🗑️ **Delete Post**\n\nTitle: **${post.title}**\nCategory: ${post.category}\n\n⚠️ This is a soft-delete — the post will be hidden but not removed from the database. Click Execute to confirm.`, + actions: [{ tool: 'delete_post', args: { post_id: postId, reason: 'Deleted via admin agent' }, reason: `Delete post: ${post.title}`, destructive: true }], + }; + }, + }, + { + patterns: /\b(create|make|new)\s*(?:a\s*)?(?:poll|survey|vote)\s*:?\s*(.+)/i, + handler: async (msg) => { + const match = msg.match(/\b(create|make|new)\s*(?:a\s*)?(?:poll|survey|vote)\s*:?\s*(.+)/i); + const title = match?.[1]?.trim(); + if (!title) return { reply: 'Usage: "create poll: [title]"', actions: [] }; + return { + reply: `📊 **Create Poll**\n\nTitle: **${title}**\nOptions: Yes / No\nType: yesno\n\nClick Execute to create this poll.`, + actions: [{ tool: 'create_poll', args: { title, options: ['Yes', 'No'], ptype: 'yesno' }, reason: `Create poll: ${title}`, destructive: false }], + }; + }, + }, + { + patterns: /\b(set|post|update)\s*(?:a\s*)?(?:announcement|banner|notice)\s*:?\s*(.+)/i, + handler: async (msg) => { + const match = msg.match(/\b(set|post|update)\s*(?:a\s*)?(?:announcement|banner|notice)\s*:?\s*(.+)/i); + const text = match?.[1]?.trim(); + if (!text) return { reply: 'Usage: "set announcement: [text]"', actions: [] }; + return { + reply: `📢 **Set Announcement**\n\nText: "${text}"\n\nClick Execute to post this announcement site-wide.`, + actions: [{ tool: 'set_announcement', args: { text, enabled: true }, reason: `Set announcement: ${text.slice(0, 50)}`, destructive: false }], + }; + }, + }, + { + patterns: /\b(close|end|archive)\s*(?:the\s*)?(?:poll|survey)\s*(\d+)?/i, + handler: async (msg) => { + const match = msg.match(/\b(close|end|archive)\s*(?:the\s*)?(?:poll|survey)\s*(\d+)?/i); + const pollId = match?.[1] ? parseInt(match[1]) : null; + if (!pollId) { + const { data } = await supabase.from('polls').select('id,title,archived').eq('archived', false); + if (!data?.length) return { reply: 'No active polls to close.', actions: [] }; + const list = data.map((p) => `• ID ${p.id}: ${p.title}`).join('\n'); + return { reply: `Which poll to close?\n\n${list}\n\nUsage: "close poll [id]"`, actions: [] }; + } + const { data: poll } = await supabase.from('polls').select('id,title,archived').eq('id', pollId).maybeSingle(); + if (!poll) return { reply: `Poll ${pollId} not found.`, actions: [] }; + if (poll.archived) return { reply: `Poll "${poll.title}" is already closed.`, actions: [] }; + return { + reply: `📊 **Close Poll**\n\n"${poll.title}"\n\nClick Execute to archive this poll.`, + actions: [{ tool: 'close_poll', args: { poll_id: pollId }, reason: `Close poll: ${poll.title}`, destructive: false }], + }; + }, + }, +]; -RULES: -1. ALWAYS describe what you want to do before suggesting actions -2. NEVER auto-execute destructive actions (delete, ban) -3. Group related actions together when possible -4. Explain WHY you're recommending each action -5. Be concise — administrators are busy +// ─── Default fallback — query all data and present summary ──────── +async function fallbackHandler(message) { + // Try a general data pull + const [{ count: posts }, { count: users }, { count: comments }] = await Promise.all([ + supabase.from('posts').select('*', { count: 'exact', head: true }), + supabase.from('users_meta').select('*', { count: 'exact', head: true }), + supabase.from('comments').select('*', { count: 'exact', head: true }), + ]); -When you want to perform actions, respond with a JSON block like: -\`\`\`json -{ - "reply": "Brief explanation of what you found and recommend", - "actions": [ - { "tool": "tool_name", "args": { ... }, "reason": "Why this action" } - ] + return { + reply: `I'm not sure what you mean by "${message.slice(0, 80)}".\n\n` + + `**Current stats:** ${posts || 0} posts, ${users || 0} users, ${comments || 0} comments\n\n` + + `Try asking about: analytics, posts, reports, polls, users, announcements, activity logs, or say "help" for all commands.`, + actions: [], + }; } -\`\`\` - -If no actions are needed, just reply normally.`; // ─── Execute a single tool call against the database ────────────── async function executeTool(toolName, args) { @@ -116,15 +347,7 @@ async function executeTool(toolName, args) { (posts || []).forEach((p) => { cats[p.category] = (cats[p.category] || 0) + 1; }); const statuses = {}; (posts || []).forEach((p) => { statuses[p.status] = (statuses[p.status] || 0) + 1; }); - return { - posts: (posts || []).length, - users: (users || []).length, - comments: (comments || []).length, - reactions: (reactions || []).length, - polls: (polls || []).length, - categories: cats, - statuses, - }; + return { posts: (posts || []).length, users: (users || []).length, comments: (comments || []).length, reactions: (reactions || []).length, polls: (polls || []).length, categories: cats, statuses }; } case 'get_activity_logs': { const { data } = await supabase.from('activity_logs').select('*').order('created_at', { ascending: false }).limit(args.limit || 50); @@ -142,21 +365,6 @@ async function executeTool(toolName, args) { } } -// ─── Parse LLM response for actions ────────────────────────────── -function parseAgentResponse(text) { - // Try to extract JSON block from response - const jsonMatch = text.match(/```json\s*([\s\S]*?)```/) || text.match(/\{[\s\S]*"actions"[\s\S]*\}/); - if (jsonMatch) { - try { - const json = JSON.parse(jsonMatch[1] || jsonMatch[0]); - if (json.actions && Array.isArray(json.actions)) { - return { reply: json.reply || text, actions: json.actions }; - } - } catch { /* fall through */ } - } - return { reply: text, actions: [] }; -} - // ─── HTTP Handler ──────────────────────────────────────────────── export default async function handler(req, res) { cors(res); @@ -168,7 +376,7 @@ export default async function handler(req, res) { const b = req.body || {}; const action = req.method === 'GET' ? req.query.action : b.action; - // chat — send message, get response with proposed actions + // chat — send message, get response with real data if (action === 'chat') { const { message, session_id } = b; if (!message) return res.status(400).json({ error: 'Message required' }); @@ -181,60 +389,75 @@ export default async function handler(req, res) { .order('created_at', { ascending: true }) .limit(40); - // Get fresh analytics context for the LLM - const [{ count: postCount }, { count: userCount }] = await Promise.all([ - supabase.from('posts').select('*', { count: 'exact', head: true }), - supabase.from('users_meta').select('*', { count: 'exact', head: true }), - ]); + // Try built-in intent engine first + let reply = ''; + let actions = []; + let matched = false; + + for (const intent of INTENTS) { + if (intent.patterns.test(message)) { + const result = await intent.handler(message); + reply = result.reply; + actions = result.actions || []; + matched = true; + break; + } + } - const contextMsg = `[Current platform state: ${postCount || 0} posts, ${userCount || 0} registered users. Current time: ${new Date().toISOString()}]`; + // If no intent matched, try LLM (if available via configured provider chain) + let providerUsed = 'builtin'; + if (!matched) { + const [{ count: postCount }, { count: userCount }] = await Promise.all([ + supabase.from('posts').select('*', { count: 'exact', head: true }), + supabase.from('users_meta').select('*', { count: 'exact', head: true }), + ]); - const messages = [ - ...(history || []).map((h) => ({ role: h.role, content: h.content })), - { role: 'user', content: message }, - ]; + const systemWithTools = SYSTEM_PROMPT + `\n\nAvailable tools:\n${JSON.stringify(TOOL_DEFS, null, 2)}\n\n[Platform state: ${postCount || 0} posts, ${userCount || 0} users. Time: ${new Date().toISOString()}]`; - // Call LLM with tool definitions - const systemWithTools = SYSTEM_PROMPT + `\n\nAvailable tools:\n${JSON.stringify(TOOL_DEFS, null, 2)}\n\n${contextMsg}`; + const messages = [ + ...(history || []).map((h) => ({ role: h.role, content: h.content })), + { role: 'user', content: message }, + ]; - const llmResult = await callLLMChain(systemWithTools, '', messages); + const llmResult = await callLLMChain(systemWithTools, '', messages); - if (!llmResult) { - return res.status(200).json({ - reply: "I couldn't connect to an AI provider. Please configure an API key in Settings → Provider Settings.", - actions: [], - session_id: sid, - provider: 'none', - }); + if (llmResult) { + const parsed = parseAgentResponse(llmResult.text); + reply = parsed.reply; + actions = parsed.actions || []; + providerUsed = `${llmResult.provider}:${llmResult.model}`; + } else { + // LLM unavailable — use fallback + const fb = await fallbackHandler(message); + reply = fb.reply; + actions = fb.actions || []; + } } - const parsed = parseAgentResponse(llmResult.text); - // Save user message await supabase.from('agent_conversations').insert({ session_id: sid, role: 'user', content: message, }); // Save assistant response await supabase.from('agent_conversations').insert({ - session_id: sid, role: 'assistant', content: parsed.reply, - actions: parsed.actions, + session_id: sid, role: 'assistant', content: reply, + actions: actions.length > 0 ? actions : undefined, }); - await auditLog('admin', 'agent_chat', `Message: "${message.slice(0, 80)}" → ${parsed.actions.length} action(s) proposed`); + await auditLog('admin', 'agent_chat', `Message: "${message.slice(0, 80)}" → ${actions.length} action(s) proposed`); return res.status(200).json({ - reply: parsed.reply, - actions: parsed.actions.map((a, i) => ({ + reply, + actions: actions.map((a, i) => ({ id: `act_${Date.now()}_${i}`, tool: a.tool, args: a.args, reason: a.reason || '', destructive: ['delete_post', 'ban_user'].includes(a.tool), })), - requires_approval: parsed.actions.some((a) => ['delete_post', 'ban_user'].includes(a.tool)), + requires_approval: actions.some((a) => ['delete_post', 'ban_user'].includes(a.tool)), session_id: sid, - provider: llmResult.provider, - model: llmResult.model, + provider: providerUsed, }); } @@ -282,8 +505,6 @@ export default async function handler(req, res) { // sessions — list recent sessions if (action === 'sessions') { - const { data } = await supabase.rpc ? { data: null } : { data: null }; - // Workaround: get unique session_ids from recent conversations const { data: rows } = await supabase.from('agent_conversations') .select('session_id, created_at') .order('created_at', { ascending: false }) @@ -301,3 +522,59 @@ export default async function handler(req, res) { return res.status(500).json({ error: err.message }); } } + +const SYSTEM_PROMPT = `You are the Voice Box admin agent. You help school administrators manage their anonymous feedback platform. + +CAPABILITIES: +- View, edit, hide, delete posts +- Warn or ban anonymous users +- Create and manage polls +- Generate analytics reports +- View activity logs +- Post announcements + +RULES: +1. ALWAYS describe what you want to do before suggesting actions +2. NEVER auto-execute destructive actions (delete, ban) +3. Group related actions together when possible +4. Explain WHY you're recommending each action +5. Be concise — administrators are busy + +When you want to perform actions, respond with a JSON block like: +\`\`\`json +{ + "reply": "Brief explanation of what you found and recommend", + "actions": [ + { "tool": "tool_name", "args": { ... }, "reason": "Why this action" } + ] +} +\`\`\` + +If no actions are needed, just reply normally.`; + +const TOOL_DEFS = [ + { name: 'get_posts', description: 'Retrieve posts with optional filters', parameters: { type: 'object', properties: { status: { type: 'string' }, category: { type: 'string' }, limit: { type: 'integer', default: 20 } } } }, + { name: 'update_post', description: "Update a post's status, priority, or content", parameters: { type: 'object', properties: { post_id: { type: 'string' }, status: { type: 'string' }, priority: { type: 'string' }, admin_reply: { type: 'string' }, hidden: { type: 'boolean' } }, required: ['post_id'] } }, + { name: 'delete_post', description: 'Soft-delete a post', parameters: { type: 'object', properties: { post_id: { type: 'string' }, reason: { type: 'string' } }, required: ['post_id', 'reason'] } }, + { name: 'warn_user', description: 'Issue a warning to an anonymous user', parameters: { type: 'object', properties: { anon_id: { type: 'string' }, reason: { type: 'string' } }, required: ['anon_id', 'reason'] } }, + { name: 'ban_user', description: 'Ban an anonymous user (prevents posting)', parameters: { type: 'object', properties: { anon_id: { type: 'string' }, reason: { type: 'string' } }, required: ['anon_id', 'reason'] } }, + { name: 'get_user_posts', description: 'Get all posts from a specific anonymous user', parameters: { type: 'object', properties: { anon_id: { type: 'string' } }, required: ['anon_id'] } }, + { name: 'create_poll', description: 'Create a new poll', parameters: { type: 'object', properties: { title: { type: 'string' }, options: { type: 'array', items: { type: 'string' } }, ptype: { type: 'string', enum: ['yesno', 'choice', 'rating'] } }, required: ['title'] } }, + { name: 'close_poll', description: 'Close a poll to new votes', parameters: { type: 'object', properties: { poll_id: { type: 'integer' } }, required: ['poll_id'] } }, + { name: 'get_analytics', description: 'Get platform analytics', parameters: { type: 'object', properties: { period: { type: 'string', enum: ['day', 'week', 'month', 'all'] } } } }, + { name: 'get_activity_logs', description: 'Retrieve recent activity logs', parameters: { type: 'object', properties: { limit: { type: 'integer', default: 50 } } } }, + { name: 'set_announcement', description: 'Set or clear a site-wide announcement', parameters: { type: 'object', properties: { text: { type: 'string' }, enabled: { type: 'boolean' } } } }, +]; + +function parseAgentResponse(text) { + const jsonMatch = text.match(/```json\s*([\s\S]*?)```/) || text.match(/\{[\s\S]*"actions"[\s\S]*\}/); + if (jsonMatch) { + try { + const json = JSON.parse(jsonMatch[1] || jsonMatch[0]); + if (json.actions && Array.isArray(json.actions)) { + return { reply: json.reply || text, actions: json.actions }; + } + } catch { /* fall through */ } + } + return { reply: text, actions: [] }; +} diff --git a/freeclaw/freeclaw/voice-box/api/_ai.js b/freeclaw/freeclaw/voice-box/api/_ai.js index 14ec526..d69edf0 100644 --- a/freeclaw/freeclaw/voice-box/api/_ai.js +++ b/freeclaw/freeclaw/voice-box/api/_ai.js @@ -1,9 +1,8 @@ -// AI Analysis — NVIDIA NIM (default) → Anthropic Claude (fallback) → built-in heuristics. -// All keys live server-side in environment variables. Structured JSON responses only. +// AI Analysis — uses configurable provider chain from _providers.js. +// No hardcoded providers. Admin configures default + failover order in the UI. +// Built-in heuristic fallback when no provider is available. import { cors, isAdmin } from './_auth.js'; - -const NIM_MODEL = process.env.NVIDIA_MODEL || 'meta/llama-3.1-70b-instruct'; -const CLAUDE_MODEL = process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-6'; +import { callLLMChain } from './_providers.js'; function parseJson(text) { try { @@ -11,55 +10,11 @@ function parseJson(text) { } catch { return null; } } -/** NVIDIA NIM (OpenAI-compatible endpoint) */ -async function callNim(system, user) { - const key = process.env.NVIDIA_API_KEY; - if (!key) return null; - try { - const resp = await fetch('https://integrate.api.nvidia.com/v1/chat/completions', { - method: 'POST', - headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model: NIM_MODEL, max_tokens: 2048, temperature: 0.2, - messages: [ - { role: 'system', content: system + '\nRespond with STRICT valid JSON only. No markdown, no prose.' }, - { role: 'user', content: user }, - ], - }), - }); - if (!resp.ok) { console.error('NIM error', resp.status, await resp.text()); return null; } - const data = await resp.json(); - return parseJson(data?.choices?.[0]?.message?.content); - } catch (e) { console.error('NIM fetch failed', e); return null; } -} - -/** Anthropic Claude fallback */ -async function callClaude(system, user) { - const key = process.env.ANTHROPIC_API_KEY; - if (!key) return null; - try { - const resp = await fetch('https://api.anthropic.com/v1/messages', { - method: 'POST', - headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' }, - body: JSON.stringify({ - model: CLAUDE_MODEL, max_tokens: 2048, - system: system + '\nRespond with STRICT valid JSON only. No markdown, no prose.', - messages: [{ role: 'user', content: user }], - }), - }); - if (!resp.ok) { console.error('Claude error', resp.status, await resp.text()); return null; } - const data = await resp.json(); - return parseJson(data?.content?.[0]?.text); - } catch (e) { console.error('Claude fetch failed', e); return null; } -} - -/** Try NIM first, then Claude. Returns { engine, result } or null. */ -async function callLLM(system, user) { - const nim = await callNim(system, user); - if (nim) return { engine: `nvidia:${NIM_MODEL}`, result: nim }; - const claude = await callClaude(system, user); - if (claude) return { engine: `anthropic:${CLAUDE_MODEL}`, result: claude }; - return null; +/** Call LLM via the shared provider chain. Returns parsed JSON or null. */ +async function callLLMJson(system, user) { + const result = await callLLMChain(system + '\nRespond with STRICT valid JSON only. No markdown, no prose.', user); + if (!result) return null; + return { engine: `${result.provider}:${result.model}`, result: parseJson(result.text) }; } // ---------- Deterministic heuristic fallback (no API key needed) ---------- @@ -151,11 +106,11 @@ export default async function handler(req, res) { const { task, posts, text, poll } = req.body || {}; if (task === 'moderate') { - const ai = await callLLM( + const ai = await callLLMJson( 'You are a school-content moderator. Analyze the text for abuse, bullying, spam, and safety risks.', `Text: """${String(text || '').slice(0, 1500)}"""\nReturn JSON: {"abuse":bool,"bullying":bool,"spam":bool,"safety_risk":bool,"action":"allow|review|escalate","reason":string,"confidence":0-1}` ); - return res.status(200).json(ai ? { engine: ai.engine, ...ai.result } : heuristicModeration(String(text || ''))); + return res.status(200).json(ai && ai.result ? { engine: ai.engine, ...ai.result } : heuristicModeration(String(text || ''))); } if (task === 'analyze') { @@ -165,16 +120,15 @@ export default async function handler(req, res) { category: p.category, priority: p.priority, status: p.status, reactions: p.reactions, comments: p.comment_count, created_at: p.created_at, })); - const ai = await callLLM( + const ai = await callLLMJson( 'You are an analyst for an anonymous school feedback platform. Cluster duplicates, detect urgency, rank issues using votes, support ratio, severity, recurrence, comment volume and growth rate. Detect abuse/spam/bullying/safety risks.', `Feedback items JSON:\n${JSON.stringify(items)}\n\nReturn JSON with keys: summary (string), ranked_issues (array of {id,title,category,urgency_score:0-100,rank_score,support_ratio,flags:[],recommended_action,confidence:0-1}), duplicate_clusters (array of {topic,post_ids,count}), safety_alerts (array of {id,title,reason}), weekly_insights ({total,high_urgency,trending_category,recommendation}).` ); - if (ai) return res.status(200).json({ engine: ai.engine, generated_at: new Date().toISOString(), ...ai.result }); + if (ai && ai.result) return res.status(200).json({ engine: ai.engine, generated_at: new Date().toISOString(), ...ai.result }); return res.status(200).json(heuristicAnalysis(posts || [])); } if (task === 'categorize') { - // AI category suggestion while typing — instant heuristic + optional Claude refinement const input = String(text || '').slice(0, 600).toLowerCase(); const KEYWORDS = { Academics: ['exam', 'homework', 'class', 'lesson', 'grade', 'test', 'study', 'curriculum', 'syllabus', 'timetable'], @@ -197,21 +151,20 @@ export default async function handler(req, res) { const score = words.reduce((a, w) => a + (input.includes(w) ? 1 : 0), 0); if (score > bestScore) { best = cat; bestScore = score; } } - // Try Claude for higher accuracy when a key is configured - const ai = bestScore > 0 ? null : await callClaude( + const ai = bestScore > 0 ? null : await callLLMJson( 'Classify school feedback into exactly one category.', `Text: """${input}"""\nCategories: Academics, Facilities, Food, Bullying, Teachers, Events, Transport, Sports, Technology, Library, Hostel, Security, Cleanliness, Medical, Other.\nReturn JSON: {"category": string, "confidence": 0-1}` ); - const category = ai?.category && Object.keys(KEYWORDS).concat('Other').includes(ai.category) ? ai.category : best; + const category = ai?.result?.category && Object.keys(KEYWORDS).concat('Other').includes(ai.result.category) ? ai.result.category : best; return res.status(200).json({ - engine: ai ? MODEL : 'heuristic-keywords', + engine: ai ? ai.engine : 'heuristic-keywords', category, - confidence: ai?.confidence ?? Math.min(0.95, 0.4 + bestScore * 0.18), + confidence: ai?.result?.confidence ?? Math.min(0.95, 0.4 + bestScore * 0.18), }); } if (task === 'summarize') { - const ai = await callLLM( + const ai = await callLLMJson( 'Summarize this school feedback item in 1-2 neutral sentences for administrators.', `Item: ${JSON.stringify({ title: req.body.title, description: req.body.description })}\nReturn JSON: {"summary": string}` ); @@ -221,12 +174,11 @@ export default async function handler(req, res) { } if (task === 'poll_insight') { - const ai = await callLLM( + const ai = await callLLMJson( 'You analyze school poll results and give one short, neutral insight for students and staff.', `Poll: ${JSON.stringify(poll)}\nReturn JSON: {"insight": string}` ); if (ai?.result?.insight) return res.status(200).json({ engine: ai.engine, insight: ai.result.insight }); - // heuristic insight const counts = poll?.vote_counts || {}; const total = poll?.total_votes || 0; const top = Object.entries(counts).sort((a, b) => b[1] - a[1])[0]; @@ -234,7 +186,7 @@ export default async function handler(req, res) { const opt = poll?.options?.[Number(top?.[0])] || 'the leading option'; return res.status(200).json({ engine: 'heuristic-fallback', - insight: total === 0 ? 'No votes yet — share the poll to gather opinions.' : `“${opt}” leads with ${pct}% of ${total} vote${total !== 1 ? 's' : ''}${pct >= 70 ? ' — a strong consensus.' : pct >= 50 ? ' — a clear majority.' : ' — opinions are split.'}`, + insight: total === 0 ? 'No votes yet — share the poll to gather opinions.' : `"${opt}" leads with ${pct}% of ${total} vote${total !== 1 ? 's' : ''}${pct >= 70 ? ' — a strong consensus.' : pct >= 50 ? ' — a clear majority.' : ' — opinions are split.'}`, }); } diff --git a/freeclaw/freeclaw/voice-box/api/_providers.js b/freeclaw/freeclaw/voice-box/api/_providers.js index 305eba8..cb83620 100644 --- a/freeclaw/freeclaw/voice-box/api/_providers.js +++ b/freeclaw/freeclaw/voice-box/api/_providers.js @@ -1,6 +1,7 @@ // Multi-provider API key management with failover chain. // Keys stored in DB (settings.api_providers) with env vars as fallback. // Priority: DB keys → env vars → heuristic fallback. +// is_default provider goes FIRST in chain, then priority order. import supabase from './_db-client.js'; import { cors, isAdmin, auditLog, clean } from './_auth.js'; @@ -30,7 +31,7 @@ const PROVIDER_DEFS = { }, gemini: { name: 'Google Gemini', - defaultModel: 'gemini-2.5-pro', + defaultModel: 'gemini-2.5-flash', baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai/', buildHeaders: (key) => ({ Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' }), buildBody: (model, messages) => ({ model, max_tokens: 2048, temperature: 0.2, messages }), @@ -93,19 +94,51 @@ function maskKey(key) { return '••••••' + key.slice(-4); } +// ─── Get the current default provider ID from DB ────────────────── +export async function getDefaultProviderId() { + const db = await getProviders(); + for (const [id, cfg] of Object.entries(db)) { + if (cfg.is_default && cfg.enabled && cfg.key) return id; + } + // If none explicitly marked, first enabled+keyed provider by priority + const sorted = Object.entries(db) + .filter(([, cfg]) => cfg.enabled && cfg.key) + .sort((a, b) => (a[1].priority || 99) - (b[1].priority || 99)); + return sorted[0]?.[0] || null; +} + // ─── Build priority-ordered chain from DB + env vars ────────────── -async function buildChain() { +// is_default provider goes first, then priority order. +export async function buildChain() { const db = await getProviders(); const chain = []; - // DB-configured providers first (sorted by priority) + + // Find the default provider + let defaultId = null; + for (const [id, cfg] of Object.entries(db)) { + if (cfg.is_default && cfg.enabled && cfg.key) { defaultId = id; break; } + } + + // DB-configured providers (enabled + have key) const dbEntries = Object.entries(db) .filter(([id, cfg]) => cfg.enabled && cfg.key) .sort((a, b) => (a[1].priority || 99) - (b[1].priority || 99)); + + // Default provider first + if (defaultId) { + const def = PROVIDER_DEFS[defaultId]; + const cfg = db[defaultId]; + if (def) chain.push({ id: defaultId, ...def, key: cfg.key, model: cfg.model || def.defaultModel, isDefault: true }); + } + + // Then the rest in priority order (skip default since it's already first) for (const [id, cfg] of dbEntries) { + if (id === defaultId) continue; const def = PROVIDER_DEFS[id]; if (!def) continue; chain.push({ id, ...def, key: cfg.key, model: cfg.model || def.defaultModel }); } + // Env var fallbacks (only if not already added from DB) const dbIds = new Set(dbEntries.map(([id]) => id)); for (const [id, def] of Object.entries(PROVIDER_DEFS)) { @@ -113,9 +146,30 @@ async function buildChain() { const key = process.env[def.envKey]; if (key) chain.push({ id, ...def, key, model: def.defaultModel }); } + return chain; } +// ─── Get full provider config for a specific provider ───────────── +export async function getProviderConfig(id) { + const db = await getProviders(); + const cfg = db[id] || {}; + const def = PROVIDER_DEFS[id]; + if (!def) return null; + return { + id, + name: def.name, + model: cfg.model || def.defaultModel, + enabled: !!cfg.enabled, + key: cfg.key || process.env[def.envKey] || null, + isDefault: !!cfg.is_default, + baseUrl: def.baseUrl, + buildHeaders: def.buildHeaders, + buildBody: def.buildBody, + parseResponse: def.parseResponse, + }; +} + // ─── Call one provider ──────────────────────────────────────────── async function callProvider(provider, messages, timeoutMs = 12000) { const controller = new AbortController(); @@ -183,6 +237,7 @@ export default async function handler(req, res) { model: cfg.model || def.defaultModel, enabled: !!cfg.enabled, priority: cfg.priority || Object.keys(PROVIDER_DEFS).indexOf(id) + 1, + is_default: !!cfg.is_default, status: cfg.status || 'untested', last_tested: cfg.last_tested || null, key_masked: cfg.key ? maskKey(cfg.key) : (process.env[def.envKey] ? maskKey(process.env[def.envKey]) : ''), @@ -192,9 +247,26 @@ export default async function handler(req, res) { return res.status(200).json(result); } - // POST /api/providers → update, test, reorder + // POST /api/providers → update, test, reorder, set_default if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' }); + // set_default — make one provider the default (clears is_default on all others) + if (action === 'set_default') { + const { provider: pid } = b; + if (!pid || !PROVIDER_DEFS[pid]) return res.status(400).json({ error: 'Invalid provider' }); + const db = await getProviders(); + // Clear all defaults + for (const id of Object.keys(db)) { + if (db[id].is_default) db[id].is_default = false; + } + // Set new default + if (!db[pid]) db[pid] = {}; + db[pid].is_default = true; + await saveProviders(db); + await auditLog('admin', 'set_default_provider', `Default provider set to ${pid}`); + return res.status(200).json({ ok: true, default: pid }); + } + // update_provider if (action === 'update_provider') { const { provider: pid, config } = b; diff --git a/freeclaw/freeclaw/voice-box/src/pages/admin/ProviderSettings.tsx b/freeclaw/freeclaw/voice-box/src/pages/admin/ProviderSettings.tsx index d998724..2e25149 100644 --- a/freeclaw/freeclaw/voice-box/src/pages/admin/ProviderSettings.tsx +++ b/freeclaw/freeclaw/voice-box/src/pages/admin/ProviderSettings.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from 'react'; -import { Key, RefreshCcw, Check, X, AlertTriangle, ChevronDown, ChevronUp, Zap } from 'lucide-react'; +import { Key, RefreshCcw, Check, X, AlertTriangle, ChevronDown, ChevronUp, Zap, Star } from 'lucide-react'; import { api } from '../../lib/api'; import { useApp } from '../../contexts/AppContext'; @@ -9,6 +9,7 @@ interface ProviderConfig { model: string; enabled: boolean; priority: number; + is_default: boolean; status: 'untested' | 'ok' | 'failed'; last_tested: string | null; key_masked: string; @@ -25,16 +26,28 @@ const PROVIDER_COLORS: Record = { groq: '#f55036', }; +const DEFAULT_MODELS: Record = { + openai: ['gpt-4o', 'gpt-4o-mini', 'gpt-4.1', 'o3-mini'], + anthropic: ['claude-sonnet-4-6', 'claude-opus-4', 'claude-haiku-3.5'], + gemini: ['gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-2.0-flash'], + nvidia: ['meta/llama-3.1-70b-instruct', 'meta/llama-3.1-405b-instruct', 'mistralai/mistral-large-2-instruct', 'google/gemma-2-27b-it'], + mistral: ['mistral-large-latest', 'mistral-medium-latest', 'mistral-small-latest'], + deepseek: ['deepseek-chat', 'deepseek-reasoner'], + groq: ['llama-3.3-70b-versatile', 'llama-3.1-8b-instant', 'mixtral-8x7b-32768'], +}; + export default function ProviderSettings() { const { toast } = useApp(); const [providers, setProviders] = useState>({}); const [loading, setLoading] = useState(true); const [testing, setTesting] = useState(null); const [testingAll, setTestingAll] = useState(false); - const [editing, setEditing] = useState(null); + const [expanded, setExpanded] = useState(null); + // Per-provider edit state const [editKey, setEditKey] = useState(''); const [editModel, setEditModel] = useState(''); - const [expanded, setExpanded] = useState(null); + const [editCustomModel, setEditCustomModel] = useState(''); + const [savingModel, setSavingModel] = useState(null); const load = useCallback(async () => { try { @@ -72,18 +85,36 @@ export default function ProviderSettings() { setTestingAll(false); }; - const saveProvider = async (pid: string) => { + const setDefault = async (pid: string) => { try { - const config: any = { model: editModel }; + await api.post('/api/providers', { action: 'set_default', provider: pid }); + toast(`${providers[pid].name} set as default`, 'ok'); + await load(); + } catch (e: any) { toast(e.message, 'err'); } + }; + + const saveKey = async (pid: string) => { + try { + const config: any = {}; if (editKey) config.key = editKey; config.enabled = true; await api.post('/api/providers', { action: 'update_provider', provider: pid, config }); - toast(`${providers[pid].name} updated`, 'ok'); - setEditing(null); setEditKey(''); setEditModel(''); + toast(`${providers[pid].name} key updated`, 'ok'); + setExpanded(null); setEditKey(''); await load(); } catch (e: any) { toast(e.message, 'err'); } }; + const saveModel = async (pid: string, model: string) => { + setSavingModel(pid); + try { + await api.post('/api/providers', { action: 'update_provider', provider: pid, config: { model } }); + toast(`${providers[pid].name} → ${model}`, 'ok'); + await load(); + } catch (e: any) { toast(e.message, 'err'); } + setSavingModel(null); + }; + const toggleProvider = async (pid: string, enabled: boolean) => { try { await api.post('/api/providers', { action: 'update_provider', provider: pid, config: { enabled } }); @@ -92,14 +123,19 @@ export default function ProviderSettings() { } catch (e: any) { toast(e.message, 'err'); } }; - const sorted = Object.values(providers).sort((a, b) => a.priority - b.priority); + const sorted = Object.values(providers).sort((a, b) => { + // Default always first + if (a.is_default && !b.is_default) return -1; + if (!a.is_default && b.is_default) return 1; + return a.priority - b.priority; + }); return (

API Providers

-

Configure AI providers for analysis and agent chat. Tried in priority order — if one fails, the next is used automatically. Env vars work as fallback.

+

Configure AI providers for analysis and agent chat. Click ★ to set the default provider. Models are fully customizable. Failover chain: default → priority order.

- + return ( +
+
+ {/* Default star */} + + {/* Name + model */} +
+
+ {p.name} + {p.is_default && ( + Default + )} +
+ + {/* Model selector — always visible */} +
+ Model: + +
+ + {/* Key + status row */} +
+ {p.key_masked ? ( + Key: {p.key_masked} + ) : p.has_env_key ? ( + Using env var + ) : ( + No key configured + )} + {p.status === 'ok' && ● OK} + {p.status === 'failed' && ● Failed} + {p.status === 'untested' && ● Untested} +
+
+ + {/* Actions */} +
+ + + +
-
- {/* Expanded config */} - {expanded === p.id && ( -
- {editing === p.id ? ( - <> -
- - setEditKey(e.target.value)} /> -
-
- - setEditModel(e.target.value)} /> + {/* Expanded: API key + custom model */} + {isExpanded && ( +
+
+ +
+ setEditKey(e.target.value)} /> + {editKey && ( + + )}
+
+ +
+
- - + setEditCustomModel(e.target.value)} + /> +
- - ) : ( -
- - {p.last_tested && Last tested: {new Date(p.last_tested).toLocaleString()}} +

Type any model ID from this provider — it will override the preset.

- )} -
- )} -
- ))} + +
+ {p.last_tested && Last tested: {new Date(p.last_tested).toLocaleString()}} + Priority: {p.priority} +
+
+ )} +
+ ); + })}
- API keys are stored in your database and never sent to the browser. If no DB key is configured, environment variables are used as fallback. If neither exists, built-in heuristic engines keep all features functional. + + How it works: Click the ★ star to set the default provider (tried first). Disable providers you don't use. Models are fully customizable — pick from presets or enter any model ID. Keys are stored in your database (never sent to the browser). Env vars work as fallback. If no provider is configured, built-in heuristic engines keep all features functional. +
); From 62743e53b9427d64577ef00c1ae3b9192186ae0c Mon Sep 17 00:00:00 2001 From: VoiceBox Dev Date: Sun, 12 Jul 2026 15:29:32 +0530 Subject: [PATCH 04/37] fix: image upload broken bucket + fallback system - _upload.js: bucket name 'voicebox-media' -> 'chat-media' (the actual bucket) - _upload.js: try multiple buckets with automatic fallback - _upload.js: last-resort fallback returns data URL if all storage fails - api.ts: new uploadImage() with 30s timeout + retry + data URL fallback - Submit.tsx: uses api.uploadImage() instead of raw /api/upload - UserChat.tsx: uses api.uploadImage() instead of raw /api/upload - Images now work in posts, chat inbox, and admin chat --- freeclaw/freeclaw/voice-box/api/_upload.js | 41 +- freeclaw/freeclaw/voice-box/src/lib/api.ts | 66 +++ .../freeclaw/voice-box/src/pages/Submit.tsx | 397 ++++++++++++++++++ .../freeclaw/voice-box/src/pages/UserChat.tsx | 97 +++++ 4 files changed, 595 insertions(+), 6 deletions(-) create mode 100644 freeclaw/freeclaw/voice-box/src/lib/api.ts create mode 100644 freeclaw/freeclaw/voice-box/src/pages/Submit.tsx create mode 100644 freeclaw/freeclaw/voice-box/src/pages/UserChat.tsx diff --git a/freeclaw/freeclaw/voice-box/api/_upload.js b/freeclaw/freeclaw/voice-box/api/_upload.js index d0af53f..d5ae693 100644 --- a/freeclaw/freeclaw/voice-box/api/_upload.js +++ b/freeclaw/freeclaw/voice-box/api/_upload.js @@ -1,9 +1,13 @@ // Image upload to Supabase Storage (anonymous, size-capped) +// FALLBACK: If Supabase Storage fails, returns a data-URL so images always work. import supabase from './_db-client.js'; import { cors, checkUser, clean } from './_auth.js'; export const config = { api: { bodyParser: { sizeLimit: '4mb' } } }; +// Primary bucket — if it doesn't exist, try alternatives +const BUCKETS = ['chat-media', 'voicebox-media']; + export default async function handler(req, res) { cors(res); if (req.method === 'OPTIONS') return res.status(204).end(); @@ -19,14 +23,39 @@ export default async function handler(req, res) { } const buffer = Buffer.from(fileBase64, 'base64'); if (buffer.length > 3 * 1024 * 1024) return res.status(400).json({ error: 'Image must be under 3 MB.' }); - const ext = contentType.split('/')[1].replace('jpeg', 'jpg'); - const fileName = `img_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}.${ext}`; - const { error } = await supabase.storage.from('voicebox-media').upload(fileName, buffer, { contentType, upsert: true }); - if (error) throw error; - const { data: urlData } = supabase.storage.from('voicebox-media').getPublicUrl(fileName); - return res.status(200).json({ url: urlData.publicUrl }); + + // Try each available bucket + for (const bucket of BUCKETS) { + try { + const ext = (contentType.split('/')[1] || 'png').replace('jpeg', 'jpg'); + const fileName = `img_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}.${ext}`; + const { error } = await supabase.storage.from(bucket).upload(fileName, buffer, { contentType, upsert: true }); + if (error) { + console.warn(`Upload to bucket '${bucket}' failed:`, error.message); + continue; // try next bucket + } + const { data: urlData } = supabase.storage.from(bucket).getPublicUrl(fileName); + return res.status(200).json({ url: urlData.publicUrl, storage: bucket }); + } catch (e) { + console.warn(`Bucket '${bucket}' error:`, e.message); + continue; + } + } + + // FALLBACK: All storage buckets failed — return data URL so the image still works + console.warn('All storage buckets failed — falling back to data URL'); + const dataUrl = `data:${contentType || 'image/png'};base64,${fileBase64}`; + return res.status(200).json({ url: dataUrl, storage: 'fallback-data-url' }); } catch (err) { console.error('upload API error:', err); + // Last-resort fallback: return data URL + try { + const { fileBase64: fb64, contentType: ct } = req.body || {}; + if (fb64) { + const dataUrl = `data:${ct || 'image/png'};base64,${fb64}`; + return res.status(200).json({ url: dataUrl, storage: 'fallback-data-url' }); + } + } catch { /* give up */ } return res.status(500).json({ error: err.message }); } } diff --git a/freeclaw/freeclaw/voice-box/src/lib/api.ts b/freeclaw/freeclaw/voice-box/src/lib/api.ts new file mode 100644 index 0000000..430ed9a --- /dev/null +++ b/freeclaw/freeclaw/voice-box/src/lib/api.ts @@ -0,0 +1,66 @@ +/** Thin fetch wrapper for Voice Box API routes */ + +function adminToken(): string | null { + try { + const raw = sessionStorage.getItem('vb:adminAuth'); + if (!raw) return null; + const { token, exp } = JSON.parse(raw); + if (exp && exp < Date.now()) { sessionStorage.removeItem('vb:adminAuth'); return null; } + return token; + } catch { return null; } +} + +export function hasAdminSession(): boolean { return !!adminToken(); } + +export function setAdminSession(token: string, exp: number) { + sessionStorage.setItem('vb:adminAuth', JSON.stringify({ token, exp })); +} + +export function clearAdminSession() { sessionStorage.removeItem('vb:adminAuth'); } + +const TIMEOUT_MS = 8000; // hard ceiling — no request may hang forever +const UPLOAD_TIMEOUT_MS = 30000; // uploads need more time + +async function request(method: string, path: string, body?: unknown, timeoutMs = TIMEOUT_MS) { + const headers: Record = { 'Content-Type': 'application/json' }; + const t = adminToken(); + if (t) headers['X-Admin-Token'] = t; + + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), timeoutMs); + try { + const res = await fetch(path, { method, headers, body: body ? JSON.stringify(body) : undefined, signal: ctrl.signal }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`); + return data; + } catch (err: any) { + if (err?.name === 'AbortError') throw new Error('Request timed out — check your connection and retry.'); + throw err; + } finally { + clearTimeout(timer); + } +} + +/** + * Upload image with automatic retry and fallback. + * Strategy: try server upload → if fails, return data URL (works everywhere, no storage needed). + */ +async function uploadImage(fileBase64: string, contentType: string, author_id: string): Promise { + // Attempt 1: server-side storage upload + try { + const up = await request('POST', '/api/upload', { fileBase64, contentType, author_id }, UPLOAD_TIMEOUT_MS); + if (up.url) return up.url; + } catch (e: any) { + console.warn('Upload to storage failed, using data URL fallback:', e.message); + } + // Fallback: inline data URL — works without any storage + return `data:${contentType};base64,${fileBase64}`; +} + +export const api = { + get: (path: string) => request('GET', path), + post: (path: string, body: unknown) => request('POST', path, body), + put: (path: string, body: unknown) => request('PUT', path, body), + del: (path: string, body: unknown) => request('DELETE', path, body), + uploadImage, +}; diff --git a/freeclaw/freeclaw/voice-box/src/pages/Submit.tsx b/freeclaw/freeclaw/voice-box/src/pages/Submit.tsx new file mode 100644 index 0000000..8471791 --- /dev/null +++ b/freeclaw/freeclaw/voice-box/src/pages/Submit.tsx @@ -0,0 +1,397 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { useNavigate, useSearchParams, Link } from 'react-router-dom'; +import { Eye, Send, ImagePlus, X, Save, AlertTriangle, Lightbulb, Megaphone, BarChart3, Copy, Mic, MicOff } from 'lucide-react'; +import { speechSupported, startDictation, type SpeechSession } from '../lib/speech'; +import { useApp } from '../contexts/AppContext'; +import { api } from '../lib/api'; +import { CATEGORIES, CAT_EMOJI, sanitize } from '../lib/utils'; +import { lsGet, lsSet, checkCooldown, stampCooldown } from '../lib/identity'; +import { fireConfetti } from '../components/Confetti'; + +const DRAFT_KEY = 'vb:drafts'; + +export default function Submit() { + const { anonId, toast, pushNotif, accountStatus } = useApp(); + const restricted = !!(accountStatus?.banned || accountStatus?.suspended); + const nav = useNavigate(); + const [params] = useSearchParams(); + const initialType = params.get('type') === 'suggestion' ? 'suggestion' : params.get('type') === 'poll' ? 'poll' : 'problem'; + + const [type, setType] = useState<'problem' | 'suggestion' | 'poll'>(initialType as any); + const [title, setTitle] = useState(''); + const [desc, setDesc] = useState(''); + const [category, setCategory] = useState('Academics'); + const [priority, setPriority] = useState('medium'); + const [tags, setTags] = useState(''); + const [image, setImage] = useState<{ preview: string; base64: string; type: string } | null>(null); + const [preview, setPreview] = useState(false); + const [busy, setBusy] = useState(false); + const [draftSaved, setDraftSaved] = useState(false); + // poll fields + const [pollType, setPollType] = useState<'yesno' | 'single' | 'multi'>('yesno'); + const [options, setOptions] = useState(['', '']); + const [expiry, setExpiry] = useState(''); + const [linkPost, setLinkPost] = useState(''); + const [linkablePosts, setLinkablePosts] = useState([]); + + // Load open problems for the "link poll to complaint" selector + useEffect(() => { + if (type !== 'poll') return; + api.get('/api/posts?type=problem') + .then((all) => setLinkablePosts(all.filter((p: any) => !['solved', 'archived'].includes(p.status)).slice(0, 50))) + .catch(() => {}); + }, [type]); + const fileRef = useRef(null); + const restoredRef = useRef(false); + const [duplicates, setDuplicates] = useState([]); + const allPostsRef = useRef(null); + /** Real-time AI suggestions (category + tags + priority + improved title) */ + const [aiSuggest, setAiSuggest] = useState(null); + const suggestSeq = useRef(0); + + useEffect(() => { + if (type === 'poll') { setAiSuggest(null); return; } + const text = `${title}. ${desc}`.trim(); + if (text.length < 10) { setAiSuggest(null); return; } + const seq = ++suggestSeq.current; + const t = setTimeout(async () => { + try { + const r = await api.post('/api/assist', { task: 'suggest', text }); + if (seq === suggestSeq.current && r.category) setAiSuggest(r); + } catch { /* non-blocking */ } + }, 600); + return () => clearTimeout(t); + }, [title, desc, type]); + + /** Optimized dictation: continuous, live interim text, vocabulary correction */ + const [dictating, setDictating] = useState(false); + const [interim, setInterim] = useState(''); + const sessionRef = useRef(null); + + const toggleDictation = () => { + if (dictating) { sessionRef.current?.stop(); return; } + const session = startDictation({ + onInterim: setInterim, + onFinal: (chunk) => { setDesc((d) => (d + (d && !d.endsWith(' ') ? ' ' : '') + chunk).slice(0, 500)); setInterim(''); }, + onEnd: () => { setDictating(false); setInterim(''); }, + onError: (msg) => { toast(msg, 'err'); setDictating(false); setInterim(''); }, + }); + if (session) { + sessionRef.current = session; setDictating(true); + toast('Listening… say “full stop” or “comma” for punctuation', 'info'); + } + }; + + useEffect(() => () => sessionRef.current?.stop(), []); + + /** Duplicate detection: word-overlap similarity against open posts in the same category */ + const checkDuplicates = useCallback(async (titleText: string, descText: string, category: string) => { + const words = (t: string) => new Set(t.toLowerCase().split(/\W+/).filter((w) => w.length > 3)); + const mine = words(titleText + ' ' + descText); + if (mine.size < 2) { setDuplicates([]); return; } + try { + if (!allPostsRef.current) allPostsRef.current = await api.get('/api/posts'); + const matches = (allPostsRef.current || []) + .filter((p: any) => p.category === category && !['solved', 'archived'].includes(p.status)) + .map((p: any) => { + const theirs = words(p.title + ' ' + p.description); + const overlap = [...mine].filter((w) => theirs.has(w)).length; + return { post: p, score: overlap / Math.max(3, Math.min(mine.size, theirs.size)) }; + }) + .filter((m) => m.score >= 0.45) + .sort((a, b) => b.score - a.score) + .slice(0, 2); + setDuplicates(matches.map((m) => m.post)); + } catch { /* non-blocking */ } + }, []); + + useEffect(() => { + if (type === 'poll') { setDuplicates([]); return; } + const t = setTimeout(() => checkDuplicates(title, desc, category), 700); + return () => clearTimeout(t); + }, [title, desc, category, type, checkDuplicates]); + + // restore draft once + useEffect(() => { + if (restoredRef.current) return; + restoredRef.current = true; + const d = lsGet(DRAFT_KEY, null); + if (d && (d.title || d.desc)) { + setTitle(d.title || ''); setDesc(d.desc || ''); setCategory(d.category || 'Academics'); + setPriority(d.priority || 'medium'); setTags(d.tags || ''); setType(d.type || initialType); + toast('Draft restored ✍️', 'info'); + } + }, [initialType, toast]); + + // autosave draft (debounced) + useEffect(() => { + const t = setTimeout(() => { + if (title || desc) { + lsSet(DRAFT_KEY, { title, desc, category, priority, tags, type, savedAt: new Date().toISOString() }); + setDraftSaved(true); + setTimeout(() => setDraftSaved(false), 1600); + } + }, 900); + return () => clearTimeout(t); + }, [title, desc, category, priority, tags, type]); + + const pickImage = (f: File) => { + if (f.size > 3 * 1024 * 1024) { toast('Image must be under 3 MB', 'err'); return; } + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + setImage({ preview: result, base64: result.split(',')[1], type: f.type }); + }; + reader.readAsDataURL(f); + }; + + const submit = async () => { + const cd = checkCooldown('post', 20); + if (cd) { toast(`Cooldown active — wait ${cd}s before posting again`, 'err'); return; } + + if (type === 'poll') { + if (title.trim().length < 5) { toast('Poll question must be at least 5 characters', 'err'); return; } + const opts = pollType === 'yesno' ? [] : options.map((o) => sanitize(o, 60)).filter(Boolean); + if (pollType !== 'yesno' && opts.length < 2) { toast('Add at least 2 options', 'err'); return; } + setBusy(true); + try { + await api.post('/api/polls', { title: sanitize(title, 140), ptype: pollType, options: opts, author_id: anonId, expires_at: expiry ? new Date(expiry).toISOString() : null, post_id: linkPost || null }); + stampCooldown('post'); + lsSet(DRAFT_KEY, null); + fireConfetti(); + toast('Poll published anonymously 🎉', 'ok'); + nav('/polls'); + } catch (e: any) { toast(e.message, 'err'); } + setBusy(false); + return; + } + + if (title.trim().length < 5) { toast('Title must be at least 5 characters', 'err'); return; } + if (desc.trim().length < 10) { toast('Description must be at least 10 characters', 'err'); return; } + setBusy(true); + try { + let image_url = null; + if (image) { + image_url = await api.uploadImage(image.base64, image.type, anonId); + } + const post = await api.post('/api/posts', { + type, title: sanitize(title, 120), description: sanitize(desc, 500), + category, priority, author_id: anonId, image_url, + tags: tags.split(',').map((t) => sanitize(t.trim(), 24)).filter(Boolean).slice(0, 6), + }); + stampCooldown('post'); + lsSet(DRAFT_KEY, null); + pushNotif({ kind: 'submitted', title: '📨 Your post is live', body: post.title, link: `/post/${post.id}` }); + fireConfetti(); + toast('Submitted anonymously 🎉', 'ok'); + nav(type === 'suggestion' ? '/suggestions' : `/post/${post.id}`); + } catch (e: any) { toast(e.message, 'err'); } + setBusy(false); + }; + + const TABS = [ + { key: 'problem', label: 'Problem', icon: Megaphone }, + { key: 'suggestion', label: 'Suggestion', icon: Lightbulb }, + { key: 'poll', label: 'Poll', icon: BarChart3 }, + ] as const; + + return ( +
+ {restricted && ( +
+ {accountStatus?.banned + ? '🚫 This anonymous ID has been permanently banned — publishing is disabled.' + : `⏸️ Your ID is suspended until ${new Date(accountStatus!.suspended_until!).toLocaleDateString()} — publishing is paused.`} +
+ )} +

Submit anonymously

+

No name, no email, no tracking. Only your anonymous browser ID is attached — and only you know it's yours.

+ +
+ {TABS.map(({ key, label, icon: Icon }) => ( + + ))} +
+ +
+
+ + setTitle(e.target.value)} maxLength={type === 'poll' ? 140 : 120} /> +

{title.length}/{type === 'poll' ? 140 : 120}

+
+ + {type === 'poll' ? ( + <> +
+ +
+ {([['yesno', 'Yes / No'], ['single', 'Single choice'], ['multi', 'Multiple choice']] as const).map(([k, l]) => ( + + ))} +
+
+ {pollType !== 'yesno' && ( +
+ +
+ {options.map((o, i) => ( +
+ setOptions((prev) => prev.map((x, j) => (j === i ? e.target.value : x)))} /> + {options.length > 2 && } +
+ ))} +
+ {options.length < 10 && } +
+ )} +
+ + +

Linked polls appear on the complaint's page and help admins gauge community opinion.

+
+
+ + setExpiry(e.target.value)} min={new Date().toISOString().slice(0, 16)} /> +
+ + ) : ( + <> +
+
+ + {speechSupported && ( + + )} +
+
+