diff --git a/.env.example b/.env.example index e5ee50e..28254b1 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,21 @@ PORT=8787 HOST=127.0.0.1 OLLAMA_BASE_URL=http://127.0.0.1:11434 SMARTSHIT_MODEL=smartshit -NUM_CTX=4096 -NUM_PREDICT=256 +NUM_CTX=2048 +NUM_PREDICT=512 CORS_ORIGIN=* + +# Cloud AI providers (optional, recommended for production) +OPENROUTER_API_KEY= +OPENROUTER_MODEL=qwen/qwen3-32b +OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 + +HUGGINGFACE_API_KEY= +HUGGINGFACE_MODEL=Qwen/Qwen3-32B +HUGGINGFACE_BASE_URL=https://router.huggingface.co/v1 + +GROQ_API_KEY= +GROQ_MODEL=llama-3.1-8b-instant + +# Priority order for provider failover +LLM_PROVIDER_ORDER=openrouter,huggingface,groq,ollama diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..edc55ce --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "snyk.advanced.autoSelectOrganization": true +} \ No newline at end of file diff --git a/README.md b/README.md index 7005f2a..d0537d1 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ **Talk to your spreadsheet. No formulas required.** +🌐 **[smartsht.com](https://smartsht.com)** + [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![TypeScript](https://img.shields.io/badge/TypeScript-5.9-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/) [![React](https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black)](https://react.dev/) @@ -46,7 +48,7 @@ Chat on the left, spreadsheet on the right. Describe budgets, expenses, or invoi |------|---------| | Node.js | 20+ | | Ollama | latest | -| RAM | 8 GB+ recommended for local AI | +| RAM | 4 GB+ recommended for local AI | ### 1. Clone and install @@ -59,7 +61,7 @@ npm install --prefix server ### 2. Add a local model (optional but recommended) -Download **Qwen3.5-4B** GGUF (~2.7 GB) into `models/` β€” see [models/README.md](models/README.md). +Download **Qwen2.5-Coder-1.5B** GGUF (~1.6 GB) into `models/` β€” see [models/README.md](models/README.md). ```bash npm run model:setup @@ -114,7 +116,7 @@ Click **Apply** on suggested actions to write to the sheet. |-------|------| | Frontend | React 19, Vite 7, Tailwind CSS 4, Zustand, HyperFormula | | Backend | Express 5, TypeScript | -| AI | Ollama + Qwen3.5-4B (GGUF), intent fast-path for templates | +| AI | Ollama + Qwen2.5-Coder-1.5B (GGUF), streaming SSE, intent fast-path | | I/O | SheetJS (`xlsx`) import/export | --- @@ -142,8 +144,14 @@ Copy `.env.example` to `.env` for server overrides: | `PORT` | `8787` | API port | | `OLLAMA_BASE_URL` | `http://127.0.0.1:11434` | Ollama endpoint | | `SMARTSHIT_MODEL` | `smartshit` | Registered Ollama model name | -| `NUM_PREDICT` | `256` | Max tokens (keeps CPU inference fast) | +| `NUM_PREDICT` | `512` | Max tokens per response | | `VITE_AI_API_URL` | *(empty)* | Production API URL for built frontend | +| `OPENROUTER_API_KEY` | *(empty)* | OpenRouter API key (recommended primary provider) | +| `OPENROUTER_MODEL` | `qwen/qwen3-32b` | OpenRouter model slug | +| `HUGGINGFACE_API_KEY` | *(empty)* | Hugging Face Inference Router key | +| `HUGGINGFACE_MODEL` | `Qwen/Qwen3-32B` | Hugging Face model id | +| `GROQ_API_KEY` | *(empty)* | Groq API key | +| `LLM_PROVIDER_ORDER` | `openrouter,huggingface,groq,ollama` | Failover order for chat providers | --- @@ -159,7 +167,8 @@ See **[CONTRIBUTING.md](CONTRIBUTING.md)** for setup and PR guidelines. ## Roadmap -- [ ] Streaming chat responses +- [x] Streaming chat responses +- [x] Faster local model (Qwen2.5-Coder-1.5B) - [ ] More expense/inventory templates - [ ] Deploy guide (static frontend + API VM) - [ ] Optional cloud model providers diff --git a/landing/index.html b/landing/index.html new file mode 100644 index 0000000..591b4e0 --- /dev/null +++ b/landing/index.html @@ -0,0 +1,590 @@ + + + + + + smartsht β€” Talk to Your Spreadsheet + + + + + + + + + + + + + + + + + + + + + + + +
+
+
Open source · MIT Licensed
+

Talk to your spreadsheet.
No formulas required.

+

+ Describe budgets, expenses, and invoices in plain English. The AI builds them. Your data stays on your machine. +

+ +
+
+ + +
+
+
+
+
+
+
+
+
+
you: Build a monthly budget for my apartment
+
 
+
sht: I'll create a monthly budget with rent, utilities, groceries, transport, and savings β€” with automatic totals.
+
 
+
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
+
β”‚ Category β”‚ Budget β”‚ Actual β”‚ Difference β”‚
+
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
+
β”‚ Rent β”‚ $1,500 β”‚ $1,500 β”‚ $0 β”‚
+
β”‚ Utilities β”‚ $180 β”‚ $162 β”‚ +$18 β”‚
+
β”‚ Groceries β”‚ $400 β”‚ $385 β”‚ +$15 β”‚
+
β”‚ Transport β”‚ $120 β”‚ $98 β”‚ +$22 β”‚
+
β”‚ Savings β”‚ $300 β”‚ $300 β”‚ $0 β”‚
+
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
+
β”‚ Total β”‚ $2,500 β”‚ $2,445 β”‚ +$55 β”‚
+
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
+
 
+
Click Apply to add this to your spreadsheet. βœ“
+
+
+
+
+ + +
+
+

Built for everyday people

+

No spreadsheet expertise needed. Just describe what you want.

+ +
+
+
πŸ’¬
+

Plain English commands

+

Type what you need β€” "track my expenses", "build an invoice", "sum column B" β€” and it happens.

+
+
+
πŸ”’
+

100% local AI

+

Runs on your machine via Ollama. Your financial data never leaves your computer. No cloud, no accounts.

+
+
+
⚑
+

Instant templates

+

Common requests (budgets, invoices, trackers) are resolved instantly without waiting for the AI model.

+
+
+
πŸ“Š
+

Real spreadsheet engine

+

Powered by HyperFormula β€” the same formula engine behind professional spreadsheet software. Full formula support.

+
+
+
πŸ‘€
+

Preview before apply

+

Every AI suggestion shows a preview first. You approve changes before they touch your data. Nothing happens without your click.

+
+
+
πŸ“
+

Excel import/export

+

Import existing .xlsx files and export your work back. Works alongside your current workflow.

+
+
+
+
+ + +
+
+

Up and running in 3 minutes

+
+
+
1
+

Clone & install

+

git clone, npm install. That's it. No complex setup, no Docker, no cloud accounts.

+
+
+
2
+

Start Ollama

+

Install Ollama, run the model setup script. One command registers the AI model on your machine.

+
+
+
3
+

Talk to your sheet

+

Open the app, type what you want in plain English. Review the preview, click Apply. Done.

+
+
+
+
+ + +
+
+
+

Stop fighting formulas

+

Open source. Works in your browser. Your data stays yours.

+ + + Open smartsht + +
+
+
+ + + + + + diff --git a/landing/smartsht.nginx.conf b/landing/smartsht.nginx.conf new file mode 100644 index 0000000..60a1970 --- /dev/null +++ b/landing/smartsht.nginx.conf @@ -0,0 +1,63 @@ +server { + server_name smartsht.com www.smartsht.com; + + root /var/www/smartsht; + index index.html; + + add_header X-Frame-Options SAMEORIGIN always; + add_header X-Content-Type-Options nosniff always; + add_header Referrer-Policy strict-origin-when-cross-origin always; + + # API proxy β€” routes to Express server + location /api/ { + proxy_pass http://127.0.0.1:8787; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ''; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 120s; + chunked_transfer_encoding off; + } + + # Health check + location /health { + proxy_pass http://127.0.0.1:8787; + proxy_http_version 1.1; + proxy_set_header Host $host; + } + + # App (built SPA) + location /app { + alias /var/www/smartsht/app; + index index.html; + try_files $uri $uri/ /app/index.html; + } + + # Landing page + location / { + try_files $uri $uri/ /index.html; + } + + location ~* \.(css|js|jpg|jpeg|png|gif|ico|svg|woff|woff2)$ { + expires 30d; + add_header Cache-Control "public, no-transform"; + } + + location ~ /\. { deny all; } + + listen 443 ssl; + ssl_certificate /etc/letsencrypt/live/smartsht.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/smartsht.com/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; +} + +server { + listen 80; + server_name smartsht.com www.smartsht.com; + return 301 https://$host$request_uri; +} diff --git a/models/README.md b/models/README.md index 00fa352..bc9d9f0 100644 --- a/models/README.md +++ b/models/README.md @@ -4,10 +4,12 @@ Large model files are **not** committed to this repository. ## Recommended local model -**Qwen3.5-4B** (quantized GGUF, ~2.7 GB) β€” runs on CPU with 8–16 GB RAM. +**Qwen2.5-Coder-1.5B-Instruct** (Q8_0 GGUF, ~1.57 GB) β€” fast CPU inference, excellent at structured JSON output. -1. Download a GGUF build (e.g. `Qwen3.5-4B.q8q4.gguf`) from Hugging Face. -2. Place it in this folder: `models/Qwen3.5-4B.q8q4.gguf` +This model is ~2Γ— faster than the previous Qwen3.5-4B while producing better structured responses for our use case (tool calling + short explanations). + +1. Download `qwen2.5-coder-1.5b-q8_0.gguf` from Hugging Face or copy from your local models folder. +2. Place it in this folder: `models/qwen2.5-coder-1.5b-q8_0.gguf` 3. From the project root: ```bash @@ -19,4 +21,15 @@ This registers the `smartshit` Ollama model used by the API server. ## Requirements - [Ollama](https://ollama.com/) installed and running -- ~8 GB free RAM for comfortable local inference +- ~4 GB free RAM for comfortable local inference (much less than the previous 4B model) + +## Why this model? + +- **Speed**: 1.5B params generates tokens 3–4Γ— faster than 4B on CPU +- **Quality**: Qwen2.5-Coder-Instruct is specifically trained for structured output / JSON β€” perfect for tool calling +- **Size**: Q8_0 quantization preserves quality while keeping the file under 1.6GB +- **Streaming**: The server now streams tokens as they generate, so you see responses appear in real-time + +## Legacy model + +The previous `Qwen3.5-4B.q8q4.gguf` (2.7GB) still works if you prefer quality over speed. Update the Modelfile's `FROM` line to point at it and bump `num_ctx` to 4096. diff --git a/package-lock.json b/package-lock.json index 959d344..c914663 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "smartsh!t", "version": "0.1.0", + "license": "MIT", "dependencies": { "@types/uuid": "^10.0.0", "clsx": "2.1.1", diff --git a/package.json b/package.json index d754465..ee88e7d 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "bugs": { "url": "https://github.com/Ocean82/smartshit/issues" }, - "homepage": "https://github.com/Ocean82/smartshit#readme", + "homepage": "https://smartsht.com", "keywords": [ "spreadsheet", "ai", diff --git a/server/Modelfile b/server/Modelfile index 41c9e23..0bc4a1f 100644 --- a/server/Modelfile +++ b/server/Modelfile @@ -1,10 +1,22 @@ -# smartsh!t local inference model -# Path is relative to this file when running: ollama create smartshit -f Modelfile -FROM ../models/Qwen3.5-4B.q8q4.gguf +# smartsh!t local inference model β€” Qwen2.5-Coder-1.5B (fast CPU inference) +# Run: ollama create smartshit -f server/Modelfile +FROM ../models/qwen2.5-coder-1.5b-q8_0.gguf -PARAMETER num_ctx 4096 -PARAMETER num_predict 256 -PARAMETER temperature 0.3 -PARAMETER top_p 0.9 +PARAMETER num_ctx 2048 +PARAMETER num_predict 512 +PARAMETER temperature 0.2 +PARAMETER top_p 0.85 +PARAMETER repeat_penalty 1.1 -SYSTEM """You are the smartsh!t spreadsheet assistant. You help people manage budgets and expenses in plain English. You never assume spreadsheet expertise. When you need to change data, you propose actions as JSON.""" +SYSTEM """You are smartsh!t, an AI spreadsheet assistant for everyday people managing budgets, expenses, and small business data. + +Rules: +- Respond ONLY with valid JSON (no markdown, no code fences, no extra text). +- Use plain English in the "message" field. Avoid jargon. +- Propose changes using "actions" with the available tools. +- If the user just wants info or explanation, use an empty actions array. + +Response format: +{"message":"your friendly explanation","actions":[{"tool":"tool_name","params":{},"description":"short label"}]} + +Available tools: create_budget_template, create_sales_tracker, create_invoice, create_project_tracker, create_employee_roster, apply_formula, format_cells, create_chart, analyze_data, modify_column, clear_sheet""" diff --git a/server/ecosystem.config.cjs b/server/ecosystem.config.cjs new file mode 100644 index 0000000..8c10f89 --- /dev/null +++ b/server/ecosystem.config.cjs @@ -0,0 +1,18 @@ +module.exports = { + apps: [{ + name: 'smartsht-api', + script: 'npx', + args: 'tsx src/index.ts', + cwd: '/home/ubuntu/smartsht/server', + env: { + NODE_ENV: 'production', + PORT: '8787', + HOST: '127.0.0.1', + OLLAMA_BASE_URL: 'http://127.0.0.1:11434', + SMARTSHIT_MODEL: 'smartshit', + NUM_CTX: '2048', + NUM_PREDICT: '512', + CORS_ORIGIN: '*' + } + }] +}; diff --git a/server/scripts/setup-model.mjs b/server/scripts/setup-model.mjs index 516ece7..1e83b3e 100644 --- a/server/scripts/setup-model.mjs +++ b/server/scripts/setup-model.mjs @@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const projectRoot = path.resolve(__dirname, '../..') -const modelPath = path.join(projectRoot, 'models', 'Qwen3.5-4B.q8q4.gguf') +const modelPath = path.join(projectRoot, 'models', 'qwen2.5-coder-1.5b-q8_0.gguf') const modelfilePath = path.join(projectRoot, 'server', 'Modelfile') const modelName = process.env.SMARTSHIT_MODEL ?? 'smartshit' @@ -23,7 +23,8 @@ function run(command, args, cwd) { async function main() { if (!fs.existsSync(modelPath)) { console.error('Model file missing:', modelPath) - console.error('Run from project root: npm run model:copy') + console.error('Expected: models/qwen2.5-coder-1.5b-q8_0.gguf') + console.error('Download from Hugging Face or copy from D:\\MY-MODELS\\under 2\\') process.exit(1) } @@ -32,12 +33,15 @@ async function main() { process.exit(1) } - console.log('Registering Ollama model', modelName) + console.log('Registering Ollama model:', modelName) console.log('GGUF:', modelPath) + console.log('Model: Qwen2.5-Coder-1.5B (Q8_0) β€” optimized for fast CPU inference') await run('ollama', ['create', modelName, '-f', modelfilePath], projectRoot) - console.log('Model ready:', modelName) - console.log('Test: ollama run', modelName) + console.log('') + console.log('βœ“ Model ready:', modelName) + console.log(' Test with: ollama run', modelName) + console.log(' Then start: npm run dev:server') } main().catch((err) => { diff --git a/server/src/config.ts b/server/src/config.ts index c4e7654..0b73ff1 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -7,11 +7,36 @@ const projectRoot = path.resolve(__dirname, '../..') export const config = { port: Number(process.env.PORT ?? 8787), host: process.env.HOST ?? '127.0.0.1', + + // Groq (primary β€” fast cloud inference) + groqApiKey: process.env.GROQ_API_KEY ?? '', + groqModel: process.env.GROQ_MODEL ?? 'llama-3.1-8b-instant', + + // OpenRouter (optional primary) + openRouterApiKey: process.env.OPENROUTER_API_KEY ?? '', + openRouterModel: process.env.OPENROUTER_MODEL ?? 'qwen/qwen3-32b', + openRouterBaseUrl: process.env.OPENROUTER_BASE_URL ?? 'https://openrouter.ai/api/v1', + + // Hugging Face Inference Router (optional primary) + huggingFaceApiKey: process.env.HUGGINGFACE_API_KEY ?? '', + huggingFaceModel: process.env.HUGGINGFACE_MODEL ?? 'Qwen/Qwen3-32B', + huggingFaceBaseUrl: process.env.HUGGINGFACE_BASE_URL ?? 'https://router.huggingface.co/v1', + + // Provider failover priority + llmProviderOrder: (process.env.LLM_PROVIDER_ORDER ?? 'openrouter,huggingface,groq,ollama') + .split(',') + .map((provider) => provider.trim().toLowerCase()) + .filter(Boolean), + + // Ollama (fallback β€” local CPU inference) ollamaBaseUrl: process.env.OLLAMA_BASE_URL ?? 'http://127.0.0.1:11434', modelName: process.env.SMARTSHIT_MODEL ?? 'smartshit', - modelPath: path.join(projectRoot, 'models', 'Qwen3.5-4B.q8q4.gguf'), + modelPath: path.join(projectRoot, 'models', 'qwen2.5-coder-1.5b-q8_0.gguf'), modelfilePath: path.join(projectRoot, 'server', 'Modelfile'), - numCtx: Number(process.env.NUM_CTX ?? 4096), - numPredict: Number(process.env.NUM_PREDICT ?? 256), + + /** Context window β€” smaller = faster on CPU */ + numCtx: Number(process.env.NUM_CTX ?? 2048), + /** Max tokens to generate per response */ + numPredict: Number(process.env.NUM_PREDICT ?? 512), corsOrigin: process.env.CORS_ORIGIN ?? '*', } diff --git a/server/src/groq.ts b/server/src/groq.ts new file mode 100644 index 0000000..9d6d84c --- /dev/null +++ b/server/src/groq.ts @@ -0,0 +1,119 @@ +import { config } from './config.js' +import type { ChatMessageInput } from './prompt.js' + +/** + * Groq API client β€” OpenAI-compatible endpoint. + * Primary AI backend for production (sub-1-second responses). + */ + +interface GroqChoice { + message?: { role: string; content: string } +} + +interface GroqResponse { + choices?: GroqChoice[] + error?: { message: string } +} + +export function groqAvailable(): boolean { + return !!config.groqApiKey +} + +export async function chatWithGroq(messages: ChatMessageInput[]): Promise { + if (!config.groqApiKey) { + throw new Error('GROQ_API_KEY not set') + } + + const res = await fetch('https://api.groq.com/openai/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${config.groqApiKey}`, + }, + body: JSON.stringify({ + model: config.groqModel, + messages, + temperature: 0.2, + max_tokens: 512, + stream: false, + }), + signal: AbortSignal.timeout(30_000), + }) + + if (!res.ok) { + const text = await res.text() + throw new Error(`Groq API failed (${res.status}): ${text}`) + } + + const data = (await res.json()) as GroqResponse + if (data.error) throw new Error(data.error.message) + return data.choices?.[0]?.message?.content?.trim() ?? '' +} + +/** + * Streaming version β€” calls onChunk for each token. + */ +export async function chatWithGroqStream( + messages: ChatMessageInput[], + onChunk: (chunk: string) => void, + signal?: AbortSignal, +): Promise { + if (!config.groqApiKey) { + throw new Error('GROQ_API_KEY not set') + } + + const res = await fetch('https://api.groq.com/openai/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${config.groqApiKey}`, + }, + body: JSON.stringify({ + model: config.groqModel, + messages, + temperature: 0.2, + max_tokens: 512, + stream: true, + }), + signal: signal ?? AbortSignal.timeout(30_000), + }) + + if (!res.ok) { + const text = await res.text() + throw new Error(`Groq streaming failed (${res.status}): ${text}`) + } + + const reader = res.body?.getReader() + if (!reader) throw new Error('No readable stream from Groq') + + const decoder = new TextDecoder() + let accumulated = '' + + while (true) { + const { done, value } = await reader.read() + if (done) break + + const text = decoder.decode(value, { stream: true }) + const lines = text.split('\n') + + for (const line of lines) { + if (!line.startsWith('data: ')) continue + const jsonStr = line.slice(6).trim() + if (jsonStr === '[DONE]') continue + if (!jsonStr) continue + + try { + const parsed = JSON.parse(jsonStr) as { choices?: Array<{ delta?: { content?: string } }> } + const token = parsed.choices?.[0]?.delta?.content ?? '' + if (token) { + accumulated += token + onChunk(token) + } + } catch { + // Skip malformed chunks + } + } + } + + return accumulated +} diff --git a/server/src/index.ts b/server/src/index.ts index a1511b0..2d5c1f0 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -2,7 +2,9 @@ import fs from 'node:fs' import cors from 'cors' import express from 'express' import { config } from './config.js' -import { chatWithOllama, modelIsRegistered, ollamaReachable } from './ollama.js' +import { chatWithOllama, chatWithOllamaStream, modelIsRegistered, ollamaReachable } from './ollama.js' +import { groqAvailable, chatWithGroqStream } from './groq.js' +import { chatWithOpenAiCompatible, chatWithOpenAiCompatibleStream, openAiCompatibleAvailable } from './openaiCompatible.js' import { buildSystemPrompt, type ChatRequestBody, type ChatResponseBody } from './prompt.js' import { parseAgentResponse } from './parseResponse.js' import { resolveIntent, isWeakResponse } from './intent.js' @@ -11,24 +13,128 @@ const app = express() app.use(cors({ origin: config.corsOrigin })) app.use(express.json({ limit: '1mb' })) +type ProviderName = 'openrouter' | 'huggingface' | 'groq' | 'ollama' + +function providerOrder(): ProviderName[] { + const allowed: ProviderName[] = ['openrouter', 'huggingface', 'groq', 'ollama'] + return config.llmProviderOrder.filter((provider): provider is ProviderName => + allowed.includes(provider as ProviderName), + ) +} + +function providerIsConfigured(provider: ProviderName): boolean { + if (provider === 'openrouter') { + return openAiCompatibleAvailable({ + apiKey: config.openRouterApiKey, + model: config.openRouterModel, + baseUrl: config.openRouterBaseUrl, + }) + } + if (provider === 'huggingface') { + return openAiCompatibleAvailable({ + apiKey: config.huggingFaceApiKey, + model: config.huggingFaceModel, + baseUrl: config.huggingFaceBaseUrl, + }) + } + if (provider === 'groq') return groqAvailable() + return true +} + +async function callProviderStream( + provider: ProviderName, + messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>, + onChunk: (chunk: string) => void, + signal: AbortSignal, +): Promise { + if (provider === 'openrouter') { + return chatWithOpenAiCompatibleStream( + { + apiKey: config.openRouterApiKey, + model: config.openRouterModel, + baseUrl: config.openRouterBaseUrl, + }, + messages, + onChunk, + signal, + ) + } + if (provider === 'huggingface') { + return chatWithOpenAiCompatibleStream( + { + apiKey: config.huggingFaceApiKey, + model: config.huggingFaceModel, + baseUrl: config.huggingFaceBaseUrl, + }, + messages, + onChunk, + signal, + ) + } + if (provider === 'groq') return chatWithGroqStream(messages, onChunk, signal) + + const ollama = await ollamaReachable() + const modelReady = ollama ? await modelIsRegistered() : false + if (!ollama || !modelReady) throw new Error('Ollama is unavailable or model is not registered') + return chatWithOllamaStream(messages, onChunk, signal) +} + +async function callProvider(provider: ProviderName, messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>): Promise { + if (provider === 'openrouter') { + return chatWithOpenAiCompatible( + { + apiKey: config.openRouterApiKey, + model: config.openRouterModel, + baseUrl: config.openRouterBaseUrl, + }, + messages, + ) + } + if (provider === 'huggingface') { + return chatWithOpenAiCompatible( + { + apiKey: config.huggingFaceApiKey, + model: config.huggingFaceModel, + baseUrl: config.huggingFaceBaseUrl, + }, + messages, + ) + } + if (provider === 'groq') { + const { chatWithGroq } = await import('./groq.js') + return chatWithGroq(messages) + } + return chatWithOllama(messages) +} + +// ─── Health ────────────────────────────────────────────────────────────────── + app.get('/health', async (_req, res) => { const ollama = await ollamaReachable() const modelReady = ollama ? await modelIsRegistered() : false - const modelFileExists = fs.existsSync(config.modelPath) + const groq = groqAvailable() + const openrouter = providerIsConfigured('openrouter') + const huggingface = providerIsConfigured('huggingface') + const order = providerOrder() res.json({ - ok: ollama && modelReady, + ok: groq || openrouter || huggingface || (ollama && modelReady), service: 'smartshit-server', + groq, + openrouter, + huggingface, + providerOrder: order, + groqModel: groq ? config.groqModel : null, ollama, modelRegistered: modelReady, modelName: config.modelName, - modelFileExists, - modelPath: config.modelPath, port: config.port, }) }) -app.post('/api/chat', async (req, res) => { +// ─── Streaming SSE endpoint (primary) ──────────────────────────────────────── + +app.post('/api/chat/stream', async (req, res) => { const body = req.body as ChatRequestBody const userMessage = body.message?.trim() @@ -39,58 +145,182 @@ app.post('/api/chat', async (req, res) => { const intent = resolveIntent(userMessage) - // Reliable instant path β€” keyword intent with actions or helpful replies + // Fast path β€” instant template response if (!body.forceLlm && (intent.actions.length > 0 || intent.message.length > 0)) { + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + res.setHeader('X-Accel-Buffering', 'no') + const payload: ChatResponseBody = { message: intent.message, actions: intent.actions, - source: intent.actions.length > 0 ? 'template' : 'fallback', + source: 'template', } - res.json(payload) + res.write(`data: ${JSON.stringify({ type: 'complete', ...payload })}\n\n`) + res.end() return } - const ollama = await ollamaReachable() - const modelReady = ollama ? await modelIsRegistered() : false + // Set up SSE headers + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + res.setHeader('X-Accel-Buffering', 'no') + res.flushHeaders() + + const abortController = new AbortController() + req.on('close', () => abortController.abort()) + + const history = (body.history ?? []).filter((m) => m.role === 'user' || m.role === 'assistant') + const messages = [ + { role: 'system' as const, content: buildSystemPrompt(body.context) }, + ...history.slice(-4), + { role: 'user' as const, content: userMessage }, + ] + + const availableProviders = providerOrder().filter(providerIsConfigured) + const providerErrors: string[] = [] + + try { + let fullText = '' + let usedProvider: ProviderName | null = null + + for (const provider of availableProviders) { + try { + fullText = await callProviderStream( + provider, + messages, + (chunk) => { + if (!res.writableEnded) { + res.write(`data: ${JSON.stringify({ type: 'token', content: chunk })}\n\n`) + } + }, + abortController.signal, + ) + usedProvider = provider + break + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error' + providerErrors.push(`${provider}: ${message}`) + } + } + + if (!usedProvider) { + const payload: ChatResponseBody = { + message: + intent.message || + 'AI is currently unavailable. Configure OPENROUTER_API_KEY, HUGGINGFACE_API_KEY, GROQ_API_KEY, or a local Ollama model.', + actions: intent.actions, + source: 'fallback', + } + res.write(`data: ${JSON.stringify({ type: 'complete', ...payload, errors: providerErrors })}\n\n`) + res.end() + return + } + + // Parse the final accumulated text into structured response + let parsed = parseAgentResponse(fullText) + + if (isWeakResponse(parsed.message, parsed.actions)) { + parsed = { + message: intent.message || fullText || 'Try a specific request like "build a monthly budget".', + actions: intent.actions, + } + } + + if (!res.writableEnded) { + const payload: ChatResponseBody = { + message: parsed.message, + actions: parsed.actions, + source: 'llm', + } + res.write(`data: ${JSON.stringify({ type: 'complete', ...payload })}\n\n`) + res.end() + } + } catch (err) { + if (!res.writableEnded) { + const message = err instanceof Error ? err.message : 'Unknown error' + const payload: ChatResponseBody = { + message: `${intent.message || 'Something went wrong.'}\n\n(${message})`, + actions: intent.actions, + source: 'fallback', + } + res.write(`data: ${JSON.stringify({ type: 'complete', ...payload })}\n\n`) + res.end() + } + } +}) + +// ─── Classic JSON endpoint (non-streaming, kept for compatibility) ──────────── + +app.post('/api/chat', async (req, res) => { + const body = req.body as ChatRequestBody + const userMessage = body.message?.trim() - if (!ollama || !modelReady) { + if (!userMessage) { + res.status(400).json({ error: 'message is required' }) + return + } + + const intent = resolveIntent(userMessage) + + if (!body.forceLlm && (intent.actions.length > 0 || intent.message.length > 0)) { const payload: ChatResponseBody = { - message: intent.message || 'AI server is offline. Start the server with npm run dev:server and run npm run model:setup.', + message: intent.message, actions: intent.actions, - source: 'fallback', + source: intent.actions.length > 0 ? 'template' : 'fallback', } res.json(payload) return } + // Try configured providers in order with graceful fallback try { const history = (body.history ?? []).filter((m) => m.role === 'user' || m.role === 'assistant') const messages = [ { role: 'system' as const, content: buildSystemPrompt(body.context) }, - ...history, + ...history.slice(-4), { role: 'user' as const, content: userMessage }, ] - const raw = await chatWithOllama(messages) - let parsed = parseAgentResponse(raw) + const availableProviders = providerOrder().filter(providerIsConfigured) + let raw = '' + let usedProvider: ProviderName | null = null - if (isWeakResponse(parsed.message, parsed.actions)) { - parsed = { - message: intent.message || 'I could not generate a detailed answer from the local model. Try a specific request like "build a monthly budget".', + for (const provider of availableProviders) { + try { + raw = await callProvider(provider, messages) + usedProvider = provider + break + } catch { + // keep trying the next provider + } + } + + if (!usedProvider) { + const payload: ChatResponseBody = { + message: + intent.message || + 'AI is currently unavailable. Configure OPENROUTER_API_KEY, HUGGINGFACE_API_KEY, GROQ_API_KEY, or a local Ollama model.', actions: intent.actions, + source: 'fallback', } + res.status(200).json(payload) + return } - const payload: ChatResponseBody = { - message: parsed.message, - actions: parsed.actions, - source: 'llm', + let parsed = parseAgentResponse(raw) + if (isWeakResponse(parsed.message, parsed.actions)) { + parsed = { message: intent.message || 'Try a specific request.', actions: intent.actions } } + + const payload: ChatResponseBody = { message: parsed.message, actions: parsed.actions, source: 'llm' } res.json(payload) } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error' const payload: ChatResponseBody = { - message: `${intent.message || 'Something went wrong with the local model.'}\n\n(${message})`, + message: `${intent.message || 'Something went wrong.'}\n\n(${message})`, actions: intent.actions, source: 'fallback', } @@ -98,7 +328,13 @@ app.post('/api/chat', async (req, res) => { } }) +// ─── Start ─────────────────────────────────────────────────────────────────── + app.listen(config.port, config.host, () => { console.log(`smartsh!t server listening on http://${config.host}:${config.port}`) - console.log(`Model: ${config.modelName} | Ollama: ${config.ollamaBaseUrl}`) + console.log(`Provider order: ${providerOrder().join(' -> ')}`) + console.log(`OpenRouter: ${providerIsConfigured('openrouter') ? `βœ“ (${config.openRouterModel})` : 'βœ— (no API key)'}`) + console.log(`HuggingFace: ${providerIsConfigured('huggingface') ? `βœ“ (${config.huggingFaceModel})` : 'βœ— (no API key)'}`) + console.log(`Groq: ${groqAvailable() ? `βœ“ (${config.groqModel})` : 'βœ— (no API key)'}`) + console.log(`Ollama: ${config.ollamaBaseUrl} (model: ${config.modelName})`) }) diff --git a/server/src/intent.ts b/server/src/intent.ts index 1dac1f4..910dd36 100644 --- a/server/src/intent.ts +++ b/server/src/intent.ts @@ -6,6 +6,8 @@ export function resolveIntent(message: string): { } { const lower = message.toLowerCase().trim() + // ─── Template creation intents ────────────────────────────────────────────── + if (lower.includes('budget') || lower.includes('expense') || lower.includes('spending') || lower.includes('spend') || (lower.includes('track') && (lower.includes('money') || lower.includes('cost')))) { return { message: 'I will build a monthly budget with income, fixed expenses, variable expenses, and automatic totals. Review the preview, then click Apply.', @@ -27,29 +29,33 @@ export function resolveIntent(message: string): { } } - if (lower.includes('project') || lower.includes('timeline') || lower.includes('schedule') || lower.includes('tasks')) { + if (lower.includes('project') || lower.includes('timeline') || lower.includes('schedule') || lower.includes('tasks') || lower.includes('todo') || lower.includes('to-do')) { return { message: 'I will create a project tracker with tasks, dates, assignees, and status columns.', actions: [{ tool: 'create_project_tracker', params: {}, description: 'Create project tracker' }], } } - if (lower.includes('employee') || lower.includes('roster') || lower.includes('team') || lower.includes('staff')) { + if (lower.includes('employee') || lower.includes('roster') || lower.includes('team') || lower.includes('staff') || lower.includes('hr') || lower.includes('payroll')) { return { message: 'I will build an employee roster with roles, contact info, and departments.', actions: [{ tool: 'create_employee_roster', params: {}, description: 'Create employee roster' }], } } - if (lower.includes('chart') || lower.includes('graph')) { - const chartType = lower.includes('pie') ? 'pie' : lower.includes('line') ? 'line' : 'bar' + // ─── Chart intents ──────────────────────────────────────────────────────────── + + if (lower.includes('chart') || lower.includes('graph') || lower.includes('visuali')) { + const chartType = lower.includes('pie') ? 'pie' : lower.includes('line') ? 'line' : lower.includes('scatter') ? 'scatter' : 'bar' return { message: `I will create a ${chartType} chart from your sheet data.`, actions: [{ tool: 'create_chart', params: { type: chartType }, description: `Create ${chartType} chart` }], } } - if (lower.includes('sum') || lower.includes('total')) { + // ─── Formula intents ────────────────────────────────────────────────────────── + + if (lower.includes('sum') || lower.includes('total') || lower.includes('add up')) { const colMatch = lower.match(/column\s+([a-z])/i) const col = colMatch ? colMatch[1].toUpperCase() : 'B' return { @@ -58,34 +64,126 @@ export function resolveIntent(message: string): { } } - if (lower.includes('analyze') || lower.includes('explain')) { + if (lower.includes('average') || lower.includes('avg') || lower.includes('mean')) { + const colMatch = lower.match(/column\s+([a-z])/i) + const col = colMatch ? colMatch[1].toUpperCase() : 'B' + return { + message: `I will add an AVERAGE formula for column ${col}.`, + actions: [{ tool: 'apply_formula', params: { column: col, formula: 'AVERAGE' }, description: `Average column ${col}` }], + } + } + + if (lower.includes('count') && !lower.includes('discount')) { + const colMatch = lower.match(/column\s+([a-z])/i) + const col = colMatch ? colMatch[1].toUpperCase() : 'A' + return { + message: `I will count the entries in column ${col}.`, + actions: [{ tool: 'apply_formula', params: { column: col, formula: 'COUNT' }, description: `Count column ${col}` }], + } + } + + if (lower.includes('max') || lower.includes('highest') || lower.includes('largest')) { + const colMatch = lower.match(/column\s+([a-z])/i) + const col = colMatch ? colMatch[1].toUpperCase() : 'B' + return { + message: `I will find the maximum value in column ${col}.`, + actions: [{ tool: 'apply_formula', params: { column: col, formula: 'MAX' }, description: `Max of column ${col}` }], + } + } + + if (lower.includes('min') || lower.includes('lowest') || lower.includes('smallest')) { + const colMatch = lower.match(/column\s+([a-z])/i) + const col = colMatch ? colMatch[1].toUpperCase() : 'B' + return { + message: `I will find the minimum value in column ${col}.`, + actions: [{ tool: 'apply_formula', params: { column: col, formula: 'MIN' }, description: `Min of column ${col}` }], + } + } + + // ─── Column modification intents ────────────────────────────────────────────── + + if (lower.match(/(\d+)\s*%/) && (lower.includes('add') || lower.includes('increase') || lower.includes('raise') || lower.includes('markup'))) { + const pctMatch = lower.match(/(\d+)\s*%/) + const pct = pctMatch ? parseInt(pctMatch[1]) : 10 + const colMatch = lower.match(/column\s+([a-z])/i) + const col = colMatch ? colMatch[1].toUpperCase() : 'B' + return { + message: `I will increase all values in column ${col} by ${pct}%.`, + actions: [{ tool: 'modify_column', params: { column: col, operation: 'multiply', factor: 1 + pct / 100 }, description: `Add ${pct}% to column ${col}` }], + } + } + + if (lower.match(/(\d+)\s*%/) && (lower.includes('reduce') || lower.includes('decrease') || lower.includes('discount') || lower.includes('subtract'))) { + const pctMatch = lower.match(/(\d+)\s*%/) + const pct = pctMatch ? parseInt(pctMatch[1]) : 10 + const colMatch = lower.match(/column\s+([a-z])/i) + const col = colMatch ? colMatch[1].toUpperCase() : 'B' + return { + message: `I will decrease all values in column ${col} by ${pct}%.`, + actions: [{ tool: 'modify_column', params: { column: col, operation: 'multiply', factor: 1 - pct / 100 }, description: `Reduce column ${col} by ${pct}%` }], + } + } + + if (lower.includes('double') || lower.includes('multiply by 2') || lower.includes('times 2')) { + const colMatch = lower.match(/column\s+([a-z])/i) + const col = colMatch ? colMatch[1].toUpperCase() : 'B' + return { + message: `I will double all values in column ${col}.`, + actions: [{ tool: 'modify_column', params: { column: col, operation: 'multiply', factor: 2 }, description: `Double column ${col}` }], + } + } + + // ─── Format intents ─────────────────────────────────────────────────────────── + + if (lower.includes('bold') || lower.includes('format') || lower.includes('highlight') || lower.includes('color')) { + return { + message: 'I will format the selected cells. Click Apply to confirm.', + actions: [{ tool: 'format_cells', params: { bold: lower.includes('bold'), bgColor: lower.includes('highlight') || lower.includes('color') ? '#FFF9C4' : undefined }, description: 'Format selected cells' }], + } + } + + // ─── Analysis intents ───────────────────────────────────────────────────────── + + if (lower.includes('analyze') || lower.includes('explain') || lower.includes('summarize') || lower.includes('summary') || lower.includes('what does') || lower.includes('describe')) { return { message: 'Here is a plain-English summary of your sheet based on the current data.', actions: [{ tool: 'analyze_data', params: {}, description: 'Analyze sheet data' }], } } - if (lower.includes('hello') || lower.includes('hi') || lower === 'hey') { + // ─── Clear / reset ──────────────────────────────────────────────────────────── + + if (lower.includes('clear') || lower.includes('reset') || lower.includes('start over') || lower.includes('blank')) { + return { + message: 'This will clear all data on the current sheet. Click Apply to confirm.', + actions: [{ tool: 'clear_sheet', params: {}, description: 'Clear current sheet' }], + } + } + + // ─── Greeting & help ────────────────────────────────────────────────────────── + + if (lower.includes('hello') || lower.includes('hi') || lower === 'hey' || lower === 'yo' || lower.includes('good morning') || lower.includes('good evening')) { return { message: 'Hi! I can build budgets, track expenses, create invoices, and explain your spreadsheet in plain English. Try: "Build a monthly budget" or "Track my business expenses".', actions: [], } } - if (lower.includes('help') || lower.includes('what can you do')) { + if (lower.includes('help') || lower.includes('what can you do') || lower.includes('how do i') || lower.includes('what do you')) { return { - message: 'I can help you:\n- Build a monthly budget\n- Track sales or inventory\n- Create invoices\n- Plan projects\n- Add formulas and charts\n\nDescribe what you need in everyday language, then click Apply on any suggested changes.', + message: 'I can help you:\n- Build a monthly budget\n- Track sales or inventory\n- Create invoices\n- Plan projects\n- Manage employee rosters\n- Add formulas (SUM, AVERAGE, MAX, MIN, COUNT)\n- Create charts (bar, line, pie)\n- Format cells (bold, highlight)\n- Increase/decrease column values by %\n\nDescribe what you need in everyday language, then click Apply on any suggested changes.', actions: [], } } - if (lower.includes('clear') || lower.includes('reset')) { + if (lower.includes('thank') || lower.includes('thanks') || lower.includes('awesome') || lower.includes('great') || lower.includes('perfect') || lower.includes('nice')) { return { - message: 'This will clear all data on the current sheet. Click Apply to confirm.', - actions: [{ tool: 'clear_sheet', params: {}, description: 'Clear current sheet' }], + message: 'You\'re welcome! Let me know if there\'s anything else I can help with.', + actions: [], } } + // ─── No match β€” fall through to LLM ────────────────────────────────────────── return { message: '', actions: [], diff --git a/server/src/ollama.ts b/server/src/ollama.ts index 3e78094..db46bb7 100644 --- a/server/src/ollama.ts +++ b/server/src/ollama.ts @@ -26,6 +26,7 @@ export async function modelIsRegistered(name = config.modelName): Promise { const res = await fetch(`${config.ollamaBaseUrl}/api/chat`, { method: 'POST', @@ -37,10 +38,10 @@ export async function chatWithOllama(messages: ChatMessageInput[]): Promise void, + signal?: AbortSignal, +): Promise { + const res = await fetch(`${config.ollamaBaseUrl}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: config.modelName, + messages, + stream: true, + options: { + num_ctx: config.numCtx, + num_predict: config.numPredict, + temperature: 0.2, + }, + }), + signal: signal ?? AbortSignal.timeout(120_000), + }) + + if (!res.ok) { + const text = await res.text() + throw new Error(`Ollama streaming failed (${res.status}): ${text}`) + } + + const reader = res.body?.getReader() + if (!reader) throw new Error('No readable stream from Ollama') + + const decoder = new TextDecoder() + let accumulated = '' + + while (true) { + const { done, value } = await reader.read() + if (done) break + + const text = decoder.decode(value, { stream: true }) + // Ollama streams newline-delimited JSON objects + const lines = text.split('\n').filter(Boolean) + + for (const line of lines) { + try { + const parsed = JSON.parse(line) as { message?: { content?: string }; done?: boolean } + const token = parsed.message?.content ?? '' + if (token) { + accumulated += token + onChunk(token) + } + } catch { + // Skip malformed lines + } + } + } + + return accumulated +} diff --git a/server/src/openaiCompatible.ts b/server/src/openaiCompatible.ts new file mode 100644 index 0000000..f647f77 --- /dev/null +++ b/server/src/openaiCompatible.ts @@ -0,0 +1,114 @@ +import type { ChatMessageInput } from './prompt.js' + +interface OpenAICompatibleChoice { + message?: { role: string; content: string } +} + +interface OpenAICompatibleResponse { + choices?: OpenAICompatibleChoice[] + error?: { message?: string } +} + +interface OpenAICompatibleParams { + baseUrl: string + apiKey: string + model: string +} + +function buildUrl(baseUrl: string): string { + return `${baseUrl.replace(/\/$/, '')}/chat/completions` +} + +export function openAiCompatibleAvailable({ apiKey, model, baseUrl }: OpenAICompatibleParams): boolean { + return Boolean(apiKey && model && baseUrl) +} + +export async function chatWithOpenAiCompatible( + params: OpenAICompatibleParams, + messages: ChatMessageInput[], +): Promise { + const res = await fetch(buildUrl(params.baseUrl), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${params.apiKey}`, + }, + body: JSON.stringify({ + model: params.model, + messages, + temperature: 0.2, + max_tokens: 512, + stream: false, + }), + signal: AbortSignal.timeout(30_000), + }) + + if (!res.ok) { + const text = await res.text() + throw new Error(`OpenAI-compatible API failed (${res.status}): ${text}`) + } + + const data = (await res.json()) as OpenAICompatibleResponse + if (data.error?.message) throw new Error(data.error.message) + return data.choices?.[0]?.message?.content?.trim() ?? '' +} + +export async function chatWithOpenAiCompatibleStream( + params: OpenAICompatibleParams, + messages: ChatMessageInput[], + onChunk: (chunk: string) => void, + signal?: AbortSignal, +): Promise { + const res = await fetch(buildUrl(params.baseUrl), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${params.apiKey}`, + }, + body: JSON.stringify({ + model: params.model, + messages, + temperature: 0.2, + max_tokens: 512, + stream: true, + }), + signal: signal ?? AbortSignal.timeout(30_000), + }) + + if (!res.ok) { + const text = await res.text() + throw new Error(`OpenAI-compatible streaming failed (${res.status}): ${text}`) + } + + const reader = res.body?.getReader() + if (!reader) throw new Error('No readable stream from OpenAI-compatible provider') + + const decoder = new TextDecoder() + let accumulated = '' + + while (true) { + const { done, value } = await reader.read() + if (done) break + + const text = decoder.decode(value, { stream: true }) + const lines = text.split('\n') + + for (const line of lines) { + if (!line.startsWith('data: ')) continue + const jsonStr = line.slice(6).trim() + if (!jsonStr || jsonStr === '[DONE]') continue + + try { + const parsed = JSON.parse(jsonStr) as { choices?: Array<{ delta?: { content?: string } }> } + const token = parsed.choices?.[0]?.delta?.content ?? '' + if (!token) continue + accumulated += token + onChunk(token) + } catch { + // Skip malformed chunks + } + } + } + + return accumulated +} diff --git a/server/src/prompt.ts b/server/src/prompt.ts index 6990c82..a5dad46 100644 --- a/server/src/prompt.ts +++ b/server/src/prompt.ts @@ -47,38 +47,31 @@ export interface ChatResponseBody { source: 'llm' | 'fallback' | 'template' } +/** + * Build a compact system prompt β€” shorter = faster on small CPU models. + */ export function buildSystemPrompt(context?: SpreadsheetContextInput): string { const tools = SPREADSHEET_AGENT_TOOLS.join(', ') - const contextBlock = context - ? `\n\nCurrent workbook:\n- Name: ${context.workbookName ?? 'Workbook'}\n- Active sheet: ${context.activeSheet ?? 'Sheet1'}\n- Sheets: ${(context.sheetNames ?? []).join(', ') || 'none'}\n- Selection: ${(context.selectedCells ?? []).join(', ') || 'none'}\n- Populated cells (sample): ${JSON.stringify(context.cellSummary ?? {}).slice(0, 2000)}` - : '' - return `You are smartsh!t, an AI assistant for everyday people managing budgets, expenses, and simple business spreadsheets. + // Only include a slim context β€” max 30 cells to keep prompt short + let contextBlock = '' + if (context) { + const cellEntries = Object.entries(context.cellSummary ?? {}).slice(0, 30) + const cellStr = cellEntries.length > 0 + ? cellEntries.map(([k, v]) => `${k}=${v ?? ''}`).join(', ') + : 'empty' + contextBlock = `\nSheet: "${context.activeSheet}" | Cells: ${cellStr}` + } -Rules: -- Explain things in plain English. Avoid jargon unless the user asks for formulas. -- When the user wants changes, respond with JSON ONLY (no markdown fences). -- Use "actions" for spreadsheet mutations. Use an empty actions array for explanations only. -- Prefer high-level tools over raw cell writes when a template fits. + return `You are smartsh!t, a spreadsheet AI assistant. Respond ONLY with valid JSON. -Available action tools: ${tools} +Format: {"message":"explanation","actions":[{"tool":"name","params":{},"description":"label"}]} -JSON response shape: -{ - "message": "friendly explanation for the user", - "actions": [ - { - "tool": "create_budget_template", - "params": {}, - "description": "short label shown in preview" - } - ] -} +Tools: ${tools} -Examples: -- "build a monthly budget" -> create_budget_template -- "track sales" -> create_sales_tracker -- "explain my sheet" -> message only, actions: [] -- "add 10% to column B" -> modify_column with params { "column": "B", "operation": "multiply", "factor": 1.1 } +Rules: +- message: plain English, friendly, short +- actions: array of tool calls (empty if just explaining) +- No markdown fences, no extra text outside JSON ${contextBlock}` } diff --git a/src/ai/agentClient.ts b/src/ai/agentClient.ts index 0dca4ce..b02b3b8 100644 --- a/src/ai/agentClient.ts +++ b/src/ai/agentClient.ts @@ -34,6 +34,7 @@ export async function fetchServerHealth(): Promise { } } +/** Non-streaming chat β€” fallback if SSE fails */ export async function chatWithAgentServer( message: string, context: SpreadsheetContextPayload, @@ -44,7 +45,7 @@ export async function chatWithAgentServer( method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message, context, history }), - signal: AbortSignal.timeout(300_000), + signal: AbortSignal.timeout(120_000), }) if (!res.ok) return null return (await res.json()) as ServerChatResponse @@ -53,6 +54,72 @@ export async function chatWithAgentServer( } } +/** + * Streaming chat via SSE. + * Calls `onToken` with each text chunk as it arrives. + * Returns the final structured response when complete. + */ +export async function chatWithAgentServerStream( + message: string, + context: SpreadsheetContextPayload, + history: Array<{ role: 'user' | 'assistant'; content: string }>, + onToken: (token: string) => void, + signal?: AbortSignal, +): Promise { + try { + const res = await fetch(`${API_BASE}/api/chat/stream`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message, context, history }), + signal: signal ?? AbortSignal.timeout(120_000), + }) + + if (!res.ok) return null + const reader = res.body?.getReader() + if (!reader) return null + + const decoder = new TextDecoder() + let finalResponse: ServerChatResponse | null = null + + while (true) { + const { done, value } = await reader.read() + if (done) break + + const text = decoder.decode(value, { stream: true }) + const lines = text.split('\n') + + for (const line of lines) { + if (!line.startsWith('data: ')) continue + const jsonStr = line.slice(6).trim() + if (!jsonStr) continue + + try { + const event = JSON.parse(jsonStr) as + | { type: 'token'; content: string } + | { type: 'complete'; message: string; actions: ServerAgentAction[]; source: string } + + if (event.type === 'token') { + onToken(event.content) + } else if (event.type === 'complete') { + finalResponse = { + message: event.message, + actions: event.actions, + source: event.source as ServerChatResponse['source'], + } + } + } catch { + // Skip malformed events + } + } + } + + return finalResponse + } catch (err) { + if ((err as Error).name === 'AbortError') return null + return null + } +} + export function serverResponseToChatMessage( response: ServerChatResponse, timestamp = Date.now(), diff --git a/src/ai/buildContext.ts b/src/ai/buildContext.ts index d627876..be9bd0b 100644 --- a/src/ai/buildContext.ts +++ b/src/ai/buildContext.ts @@ -9,7 +9,7 @@ export interface SpreadsheetContextPayload { cellSummary: Record } -const MAX_SUMMARY_CELLS = 80 +const MAX_SUMMARY_CELLS = 30 export function buildSpreadsheetContext( workbook: WorkbookData, diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx index 6409318..5a18d22 100644 --- a/src/components/ChatPanel.tsx +++ b/src/components/ChatPanel.tsx @@ -125,13 +125,15 @@ export function ChatPanel() {
- {waitSeconds >= 15 && ( + {waitSeconds >= 15 && !messages[messages.length - 1]?.content && (

- Template requests like "build a budget" should be instant. Open-ended questions can take 15–30s. + Template requests like "build a budget" are instant. Open-ended questions take a few seconds.

)} diff --git a/src/store/useStore.ts b/src/store/useStore.ts index 64c53a8..9e5273f 100644 --- a/src/store/useStore.ts +++ b/src/store/useStore.ts @@ -23,8 +23,7 @@ import { } from '@/engine/spreadsheet'; import { loadPersistedState } from '@/lib/persistence'; import { buildSpreadsheetContext } from '@/ai/buildContext'; -import { chatWithAgentServer, serverResponseToChatMessage } from '@/ai/agentClient'; -import { isWeakServerResponse } from '@/ai/responseQuality'; +import { chatWithAgentServerStream, serverResponseToChatMessage } from '@/ai/agentClient'; import { v4 as uuid } from 'uuid'; import { defaultSkills } from '@/data/skills'; @@ -369,8 +368,18 @@ export const useStore = create()( timestamp: Date.now(), }; + // Create a placeholder assistant message for streaming + const streamingMsgId = uuid(); + const streamingMsg: ChatMessage = { + id: streamingMsgId, + role: 'assistant', + content: '', + timestamp: Date.now(), + }; + set((s) => { s.messages.push(userMsg); + s.messages.push(streamingMsg); s.chatInput = ''; s.isAiProcessing = true; }); @@ -386,23 +395,44 @@ export const useStore = create()( ); const history = state.messages .filter((m) => m.role === 'user' || m.role === 'assistant') - .slice(0, -1) - .slice(-12) + .slice(0, -2) // Exclude the user msg and streaming placeholder we just added + .slice(-8) .map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content })); - const serverResult = await chatWithAgentServer(input, context, history); + const serverResult = await chatWithAgentServerStream( + input, + context, + history, + (token) => { + // Update the streaming message with each token + set((s) => { + const msg = s.messages.find((m) => m.id === streamingMsgId); + if (msg) msg.content += token; + }); + }, + ); - let response: ChatMessage; - if (serverResult && !isWeakServerResponse(serverResult)) { - response = serverResponseToChatMessage(serverResult); + if (serverResult) { + // Replace streaming message with the final parsed response + const finalMsg = serverResponseToChatMessage(serverResult); + set((s) => { + const idx = s.messages.findIndex((m) => m.id === streamingMsgId); + if (idx >= 0) { + s.messages[idx] = { ...finalMsg, id: streamingMsgId }; + } + s.isAiProcessing = false; + }); } else { - response = processAICommand(input, get); + // Streaming failed β€” fall back to local pattern matching + const fallbackResponse = processAICommand(input, get); + set((s) => { + const idx = s.messages.findIndex((m) => m.id === streamingMsgId); + if (idx >= 0) { + s.messages[idx] = { ...fallbackResponse, id: streamingMsgId }; + } + s.isAiProcessing = false; + }); } - - set((s) => { - s.messages.push(response); - s.isAiProcessing = false; - }); })(); },