From 37ca2352e26a3c13cda7b993fdee2443ec1b7f06 Mon Sep 17 00:00:00 2001 From: dharaneesh-r Date: Wed, 19 Aug 2026 17:08:45 +0530 Subject: [PATCH] implemenation on the bot --- .gitignore | 5 + docker-compose.yml | 47 + services/ai/README.md | 59 + services/ai/collections.js | 77 + services/ai/confidence.js | 36 + services/ai/embed.js | 66 + services/ai/eval-set.json | 77 + services/ai/eval.js | 79 + services/ai/index.js | 97 + services/ai/package.json | 18 + services/ai/seed/phrasings.json | 271 + services/ai/seed/run.js | 148 + .../nearle_components/PageHeader.js | 2 +- .../nearle_components/PageLayout.js | 7 +- src/components/nearle_components/StatCard.js | 110 +- src/globalPolish.css | 19 + src/pages/api/api.js | 94 +- src/pages/nearle/assistant/CLAUDE.md | 119 +- src/pages/nearle/assistant/DoormileAI.css | 517 +- .../assistant/DoormileAI/AICustomerForm.js | 193 - .../nearle/assistant/DoormileAI/AIFlowStep.js | 113 + .../nearle/assistant/DoormileAI/AIMessage.js | 132 +- .../assistant/DoormileAI/AIOrderForm.js | 276 - .../nearle/assistant/DoormileAI/AIPanel.js | 627 ++- .../nearle/assistant/DoormileAI/AIParts.js | 9 +- .../nearle/assistant/DoormileAI/AIRowsStep.js | 150 + .../nearle/assistant/DoormileAI/AIWelcome.js | 13 +- .../nearle/assistant/DoormileAI/index.js | 4 +- .../assistant/DoormileAI/pageContext.js | 42 +- src/pages/nearle/assistant/actions.js | 71 +- src/pages/nearle/assistant/bulkFile.js | 223 + src/pages/nearle/assistant/bulkFlow.js | 193 + .../nearle/assistant/bulkOrderActions.js | 261 + src/pages/nearle/assistant/customerFlow.js | 142 + src/pages/nearle/assistant/flowEngine.js | 83 + src/pages/nearle/assistant/intents.js | 240 +- src/pages/nearle/assistant/orderActions.js | 9 +- src/pages/nearle/assistant/orderFlow.js | 267 + src/pages/nearle/assistant/ragRouter.js | 66 + src/pages/nearle/clients/createCustomer.js | 94 +- src/pages/nearle/customers/customers.js | 67 +- src/pages/nearle/deliveries/deliveries.js | 158 +- src/pages/nearle/dispatch/CLAUDE.md | 20 +- src/pages/nearle/dispatch/Dispatch.js | 4886 +++++++++-------- src/pages/nearle/orders/CreateOrder.css | 21 + src/pages/nearle/orders/createorder1.js | 163 +- src/pages/nearle/orders/multipleOrders.js | 32 +- src/pages/nearle/orders/orders.js | 22 +- src/themes/dt/status.js | 15 +- src/utils/batchBucket.js | 36 +- src/utils/bulkOrderColumns.js | 150 + src/utils/doormileAxios.js | 16 +- 52 files changed, 7074 insertions(+), 3568 deletions(-) create mode 100644 docker-compose.yml create mode 100644 services/ai/README.md create mode 100644 services/ai/collections.js create mode 100644 services/ai/confidence.js create mode 100644 services/ai/embed.js create mode 100644 services/ai/eval-set.json create mode 100644 services/ai/eval.js create mode 100644 services/ai/index.js create mode 100644 services/ai/package.json create mode 100644 services/ai/seed/phrasings.json create mode 100644 services/ai/seed/run.js delete mode 100644 src/pages/nearle/assistant/DoormileAI/AICustomerForm.js create mode 100644 src/pages/nearle/assistant/DoormileAI/AIFlowStep.js delete mode 100644 src/pages/nearle/assistant/DoormileAI/AIOrderForm.js create mode 100644 src/pages/nearle/assistant/DoormileAI/AIRowsStep.js create mode 100644 src/pages/nearle/assistant/bulkFile.js create mode 100644 src/pages/nearle/assistant/bulkFlow.js create mode 100644 src/pages/nearle/assistant/bulkOrderActions.js create mode 100644 src/pages/nearle/assistant/customerFlow.js create mode 100644 src/pages/nearle/assistant/flowEngine.js create mode 100644 src/pages/nearle/assistant/orderFlow.js create mode 100644 src/pages/nearle/assistant/ragRouter.js create mode 100644 src/utils/bulkOrderColumns.js diff --git a/.gitignore b/.gitignore index e6ba584..bdc5f41 100644 --- a/.gitignore +++ b/.gitignore @@ -104,3 +104,8 @@ dist # wincompare file *.bak + +# Local AI sidecar (dev only) +.chroma/ +.cache/ +services/ai/node_modules/ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1f7a412 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,47 @@ +# Local-only stack for the Doormile AI retrieval sidecar. +# +# This is DEV TOOLING. The production image is still the static nginx build in +# Dockerfile — nothing here is deployed, and the console works with this stack +# absent (the bot falls back to its deterministic matcher when REACT_APP_AI_URL +# is unset or unreachable). +# +# docker compose up -d start Chroma + the sidecar +# npm --prefix services/ai run seed build the collections +# +services: + chroma: + image: chromadb/chroma:latest + container_name: doormile-chroma + ports: + - '8000:8000' + volumes: + # Bind-mounted so embeddings survive a container rebuild. Gitignored. + - ./.chroma:/chroma/chroma + environment: + - IS_PERSISTENT=TRUE + - ANONYMIZED_TELEMETRY=FALSE + healthcheck: + test: ['CMD', 'curl', '-f', 'http://localhost:8000/api/v2/heartbeat'] + interval: 10s + timeout: 5s + retries: 5 + + ai-sidecar: + image: node:20 + container_name: doormile-ai + working_dir: /app + command: sh -c "npm install --no-audit --no-fund && node index.js" + ports: + - '8787:8787' + volumes: + - ./services/ai:/app + # The MiniLM weights (~25MB) are downloaded once and cached here rather + # than re-fetched on every container start. + - ./.cache/transformers:/app/.cache + environment: + - CHROMA_URL=http://chroma:8000 + - PORT=8787 + - TRANSFORMERS_CACHE=/app/.cache + depends_on: + chroma: + condition: service_healthy diff --git a/services/ai/README.md b/services/ai/README.md new file mode 100644 index 0000000..6344e21 --- /dev/null +++ b/services/ai/README.md @@ -0,0 +1,59 @@ +# Doormile AI — retrieval sidecar + +Semantic intent routing and document Q&A over a local ChromaDB. + +**This is dev tooling.** The production image is still the static nginx build. +The console works with this stack absent — the assistant falls back to its +deterministic matcher whenever `REACT_APP_AI_URL` is unset or unreachable. + +## Run it + +```bash +docker compose up -d # chroma :8000, sidecar :8787 +docker compose exec ai-sidecar npm run seed +curl localhost:8787/health +``` + +Then point the console at it: + +``` +REACT_APP_AI_URL=http://localhost:8787 +``` + +Leave that unset and nothing changes — the bot behaves exactly as it does today. + +## What it does + +| Endpoint | Purpose | +|---|---| +| `POST /route` | which intent is this question? + confidence | +| `POST /ask` | which documentation passages answer this? | +| `GET /health` | model + collection counts | + +## What it deliberately does not do + +- **No operational data is embedded.** No bookings, riders or customers. A + vector store is a snapshot; this data changes by the minute. Every figure the + operator sees still comes from a live API call. +- **No generation.** `/ask` returns source passages verbatim with attribution. + Summarising needs a hosted model — see `assistant/CLAUDE.md` §2. +- **No key.** The embedding model runs in-process. That is what puts this + outside §2's blocker. + +## Evaluation + +```bash +docker compose exec ai-sidecar npm run eval +``` + +Runs the **held-out** set in `eval-set.json` — phrasings that appear nowhere in +`phrasings.json` and were not used to tune the thresholds. Retrieval always +looks good against its own seed data, so this is the only number worth quoting. + +Ship criteria: ≥80% accuracy and **zero false write routes**. + +## After changing anything + +Re-run `npm run seed` after editing `phrasings.json`, any indexed markdown, or +the embedding model. Nothing errors if you forget — answers just quietly drift +from the source, which is the worst kind of failure to chase. diff --git a/services/ai/collections.js b/services/ai/collections.js new file mode 100644 index 0000000..e56417c --- /dev/null +++ b/services/ai/collections.js @@ -0,0 +1,77 @@ +import { ChromaClient } from 'chromadb'; +import { MODEL_ID, embed, embedMany } from './embed.js'; + +// ==============================|| Chroma collections ||============================== // +// +// Two collections, deliberately separate: +// +// intent_examples — ~20 phrasings per intent. Answers "which question is +// this?", never "what is the number?". +// console_docs — chunked markdown. Genuine document Q&A. +// +// NO OPERATIONAL DATA IS EMBEDDED. No bookings, riders or customers ever enter +// the vector store. A vector store is a snapshot and this data changes by the +// minute; every figure still comes from a live API call through the existing +// typed functions. That is the whole reason this design is safe to build. + +export const INTENTS_COLLECTION = 'intent_examples'; +export const DOCS_COLLECTION = 'console_docs'; + +const client = new ChromaClient({ path: process.env.CHROMA_URL || 'http://localhost:8000' }); + +// Chroma would otherwise call its own default embedding function (which +// downloads a different model). We embed ourselves so queries and documents +// are guaranteed to come from the same model. +const noopEmbeddingFunction = { generate: async (texts) => embedMany(texts) }; + +export const getCollection = async (name) => + client.getOrCreateCollection({ + name, + embeddingFunction: noopEmbeddingFunction, + metadata: { + // Stamped so a model swap is detectable rather than silently degrading + // every score. Re-seed after changing it. + 'hnsw:space': 'cosine', + embeddingModel: MODEL_ID + } + }); + +export const resetCollection = async (name) => { + try { + await client.deleteCollection({ name }); + } catch { + // Not present yet — nothing to delete. + } + return getCollection(name); +}; + +// Chroma returns cosine DISTANCE (0 = identical). Similarity is the useful +// direction for a human-facing confidence, so convert once, here, rather than +// leaving every call site to remember which way round it is. +const toSimilarity = (distance) => 1 - Number(distance); + +export const query = async (name, text, topK = 5) => { + const collection = await getCollection(name); + const vector = await embed(text); + const res = await collection.query({ queryEmbeddings: [vector], nResults: topK }); + + const ids = res.ids?.[0] || []; + return ids.map((id, i) => ({ + id, + document: res.documents?.[0]?.[i] || '', + metadata: res.metadatas?.[0]?.[i] || {}, + score: toSimilarity(res.distances?.[0]?.[i] ?? 1) + })); +}; + +export const health = async () => { + await client.heartbeat(); + const names = (await client.listCollections()).map((c) => c.name ?? c); + const counts = {}; + for (const name of [INTENTS_COLLECTION, DOCS_COLLECTION]) { + if (!names.includes(name)) continue; + // eslint-disable-next-line no-await-in-loop + counts[name] = await (await getCollection(name)).count(); + } + return { chroma: 'up', collections: counts }; +}; diff --git a/services/ai/confidence.js b/services/ai/confidence.js new file mode 100644 index 0000000..23ea24e --- /dev/null +++ b/services/ai/confidence.js @@ -0,0 +1,36 @@ +// ==============================|| Confidence ||============================== // +// +// Its own module, not part of index.js, because index.js calls app.listen() at +// module scope — importing it from eval.js would boot a second HTTP server as +// a side effect of running the evaluation. +// +// ---- Why margin and not similarity ----------------------------------------- +// +// Cosine similarity is NOT a probability of correctness. A score of 0.87 does +// not mean "87% likely right", and showing it as though it does would be the +// same false precision this assistant avoids everywhere else. +// +// What carries information is the MARGIN between the best and second-best +// match. A wide margin means the question is unambiguous. A narrow one means it +// genuinely could be two things — and that is exactly when the bot should ask +// instead of guessing. +// +// The answer's correctness never comes from this number. It comes from the +// intent's deterministic run() hitting a real endpoint. + +// Starting values. Tune from eval.js output, not intuition. +export const HIGH_SCORE = 0.75; +export const HIGH_MARGIN = 0.1; +export const MED_SCORE = 0.6; +export const MED_MARGIN = 0.05; + +export const classify = (hits) => { + if (!hits.length) return { confidence: 'low', score: 0, margin: 0 }; + const score = hits[0].score; + // With a single hit there is nothing to be ambiguous against, so the margin + // is the score itself rather than a fabricated 0. + const margin = hits.length > 1 ? score - hits[1].score : score; + if (score >= HIGH_SCORE && margin >= HIGH_MARGIN) return { confidence: 'high', score, margin }; + if (score >= MED_SCORE && margin >= MED_MARGIN) return { confidence: 'medium', score, margin }; + return { confidence: 'low', score, margin }; +}; diff --git a/services/ai/embed.js b/services/ai/embed.js new file mode 100644 index 0000000..f935b78 --- /dev/null +++ b/services/ai/embed.js @@ -0,0 +1,66 @@ +import { pipeline, env } from '@xenova/transformers'; + +// ==============================|| Embeddings ||============================== // +// +// all-MiniLM-L6-v2 running IN PROCESS. No API key, no network call per query, +// no per-call cost. +// +// That is not an incidental choice. assistant/CLAUDE.md §2 blocked LLM work on +// exactly one ground: a hosted model needs a secret key and a static CRA build +// has nowhere to put one. A local embedding model has no key, so this plan is +// outside that blocker. Swapping to a hosted embedding model (text-embedding-3 +// -small et al) re-opens §2 and needs its own decision — do not do it quietly. + +export const MODEL_ID = 'Xenova/all-MiniLM-L6-v2'; +export const DIMENSIONS = 384; + +// Weights are cached on disk (mounted in docker-compose) so a container +// restart doesn't re-download 25MB. +env.cacheDir = process.env.TRANSFORMERS_CACHE || './.cache'; +// Nothing here should reach the network except the one-time model fetch. +env.allowRemoteModels = true; + +let extractor = null; +let loading = null; + +// Loaded once, lazily, and shared. Concurrent callers await the same promise +// rather than each triggering their own model load. +const getExtractor = async () => { + if (extractor) return extractor; + if (!loading) { + loading = pipeline('feature-extraction', MODEL_ID).then((p) => { + extractor = p; + return p; + }); + } + return loading; +}; + +export const warmUp = async () => { + await getExtractor(); + return { model: MODEL_ID, dimensions: DIMENSIONS }; +}; + +// Mean-pooled + L2-normalised sentence embedding. +// +// Normalisation matters: Chroma's cosine space assumes unit vectors, and +// queries must be embedded exactly the same way documents were. A mismatch +// doesn't error — it silently degrades every score, which is the worst kind of +// bug to chase. `seed` writes the model id into collection metadata so a +// mismatch is at least detectable. +export const embed = async (text) => { + const pipe = await getExtractor(); + const output = await pipe(String(text || '').trim(), { pooling: 'mean', normalize: true }); + return Array.from(output.data); +}; + +export const embedMany = async (texts) => { + const out = []; + for (const t of texts) { + // Sequential on purpose: batching MiniLM in-process gives no meaningful + // speed-up at this corpus size (~700 vectors) and makes memory spikier. + // eslint-disable-next-line no-await-in-loop + out.push(await embed(t)); + } + return out; +}; diff --git a/services/ai/eval-set.json b/services/ai/eval-set.json new file mode 100644 index 0000000..d629e77 --- /dev/null +++ b/services/ai/eval-set.json @@ -0,0 +1,77 @@ +{ + "_comment": "HELD-OUT evaluation set. None of these phrasings appear in phrasings.json, and none were used to choose the confidence thresholds. That is the entire point — retrieval will always score well against its own seed data, so this is the only number worth reporting. Written as an operator would speak, not as the regex matchers were written. If you add a case here, do NOT then add it to phrasings.json; that would quietly turn the held-out set into training data.", + + "cases": [ + { "text": "how much did we ship today", "expect": "totalOrders" }, + { "text": "did we get many bookings", "expect": "totalOrders" }, + { "text": "whats the tally for today", "expect": "totalOrders" }, + + { "text": "seven day totals", "expect": "weekOrders" }, + { "text": "how did the week go", "expect": "weekOrders" }, + + { "text": "anything not picked up yet", "expect": "statusBreakdown" }, + { "text": "count the scrapped ones", "expect": "statusBreakdown" }, + { "text": "how many made it to the customer", "expect": "statusBreakdown" }, + { "text": "orders sitting with no rider", "expect": "statusBreakdown" }, + + { "text": "is anything running behind", "expect": "delayedOrders" }, + { "text": "show me what missed the window", "expect": "delayedOrders" }, + { "text": "are we late anywhere", "expect": "delayedOrders" }, + { "text": "which sites are struggling", "expect": "delayedOrders" }, + + { "text": "money in today", "expect": "revenueTotal" }, + { "text": "what did we bill this week", "expect": "revenueTotal" }, + + { "text": "how does this week stack up against last", "expect": "comparisonIntent" }, + { "text": "better or worse than yesterday", "expect": "comparisonIntent" }, + + { "text": "spread of orders through the day", "expect": "orderTrend" }, + { "text": "what time are we busiest", "expect": "orderTrend" }, + + { "text": "what share get scrapped", "expect": "orderRate" }, + { "text": "how much of our volume completes", "expect": "orderRate" }, + + { "text": "run me the numbers for today", "expect": "opsSummary" }, + { "text": "quick rundown please", "expect": "opsSummary" }, + + { "text": "how many lads are working", "expect": "riderCounts" }, + { "text": "anyone free to take a job", "expect": "riderCounts" }, + { "text": "whats the driver situation", "expect": "riderCounts" }, + + { "text": "how did Kumar get on", "expect": "riderActivity" }, + { "text": "how many drops did Suresh finish", "expect": "riderActivity" }, + { "text": "how many jobs did rider Ali turn down", "expect": "riderActivity" }, + + { "text": "which client sends us the most work", "expect": "orderQuery" }, + { "text": "Acme deliveries that completed this week", "expect": "orderQuery" }, + + { "text": "who are our clients", "expect": "tenantList" }, + { "text": "count the businesses we serve", "expect": "tenantList" }, + + { "text": "what have we got on Acme Foods", "expect": "tenantDetail" }, + + { "text": "are the depots all running", "expect": "hubStatus" }, + { "text": "how many depots do we run", "expect": "hubStatus" }, + + { "text": "how many bikes are spare", "expect": "vehicleStatus" }, + { "text": "whats sitting idle in the fleet", "expect": "vehicleStatus" }, + + { "text": "any failed drops", "expect": "exceptionStatus" }, + { "text": "what problems came up", "expect": "exceptionStatus" }, + + { "text": "how many runs are out", "expect": "tripsheetStatus" }, + + { "text": "how many people are on our books", "expect": "customerCount" }, + + { "text": "whats the state of order 4821", "expect": "orderLookup" }, + { "text": "chase up DM-BK-900 for me", "expect": "orderLookup" }, + + { "text": "follow parcel DM-CN-77", "expect": "parcelTrack" }, + + { "text": "I want to add someone new", "expect": "createCustomer", "isWrite": true }, + { "text": "put a new client on the system", "expect": "createCustomer", "isWrite": true }, + + { "text": "I need to raise a job", "expect": "createOrder", "isWrite": true }, + { "text": "set up a delivery for me", "expect": "createOrder", "isWrite": true } + ] +} diff --git a/services/ai/eval.js b/services/ai/eval.js new file mode 100644 index 0000000..7121e9a --- /dev/null +++ b/services/ai/eval.js @@ -0,0 +1,79 @@ +import { INTENTS_COLLECTION, query } from './collections.js'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { classify } from './confidence.js'; + +// Read rather than `import ... assert`: the import-assertion syntax changed +// between Node 20 (`assert`) and Node 22 (`with`), and this has to run on both. +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const heldOut = JSON.parse(fs.readFileSync(path.join(HERE, 'eval-set.json'), 'utf8')); + +// ==============================|| Routing evaluation ||============================== // +// +// The question this answers is NOT "does retrieval work" — it will always look +// good against the phrasings it was seeded with. It is: +// +// does it route phrasings NOBODY tuned it against? +// +// eval-set.json is deliberately held out: none of these appear in +// phrasings.json, and none were used to pick the confidence thresholds. +// +// Ship criteria (RAG_PLAN.md §7): +// • accuracy and coverage beat the regex baseline +// • ZERO false write routes — a wrong create is the worst failure here, +// the same class as answering "0" when the page shows 19. + +const pct = (n, d) => (d ? `${((n / d) * 100).toFixed(1)}%` : '—'); + +const main = async () => { + const cases = heldOut.cases || []; + let correct = 0; + let answered = 0; + let falseWrite = 0; + const wrong = []; + const latencies = []; + + for (const c of cases) { + const started = Date.now(); + // eslint-disable-next-line no-await-in-loop + const hits = await query(INTENTS_COLLECTION, c.text, 5); + latencies.push(Date.now() - started); + + const { confidence, score, margin } = classify(hits); + const top = hits[0]?.metadata || {}; + const routed = confidence === 'low' ? null : top.intentId; + + if (routed) answered += 1; + if (routed === c.expect) correct += 1; + else if (routed) wrong.push({ text: c.text, expected: c.expect, got: routed, score: score.toFixed(2), margin: margin.toFixed(2) }); + + // A non-write phrasing that routes to a write intent is the failure that + // matters most — it would open a create form the operator never asked for. + if (top.isWrite && !c.isWrite && confidence === 'high') falseWrite += 1; + } + + latencies.sort((a, b) => a - b); + const p = (q) => latencies[Math.floor(latencies.length * q)] ?? 0; + + console.log('\n=== held-out routing evaluation ==='); + console.log(`cases ${cases.length}`); + console.log(`accuracy ${correct}/${cases.length} ${pct(correct, cases.length)}`); + console.log(`coverage ${answered}/${cases.length} ${pct(answered, cases.length)} (answered at all)`); + console.log(`false writes ${falseWrite} ${falseWrite === 0 ? '✓' : '✗ MUST BE ZERO'}`); + console.log(`latency p50/p95 ${p(0.5)}ms / ${p(0.95)}ms`); + + if (wrong.length) { + console.log('\n--- misroutes ---'); + wrong.forEach((w) => console.log(` "${w.text}"\n expected ${w.expected}, got ${w.got} (score ${w.score}, margin ${w.margin})`)); + } + + const ok = falseWrite === 0 && correct / Math.max(cases.length, 1) >= 0.8; + console.log(`\n${ok ? 'PASS' : 'FAIL'} — threshold: >=80% accuracy and zero false writes\n`); + process.exit(ok ? 0 : 1); +}; + +main().catch((err) => { + console.error('[eval] failed:', err.message); + process.exit(1); +}); diff --git a/services/ai/index.js b/services/ai/index.js new file mode 100644 index 0000000..205f04c --- /dev/null +++ b/services/ai/index.js @@ -0,0 +1,97 @@ +import express from 'express'; +import cors from 'cors'; +import { warmUp } from './embed.js'; +import { INTENTS_COLLECTION, DOCS_COLLECTION, query, health } from './collections.js'; +import { classify } from './confidence.js'; + +// ==============================|| Doormile AI — retrieval sidecar ||============================== // +// +// Two jobs, and neither of them is "produce a number": +// +// POST /route text -> which intent is this? (+ how sure) +// POST /ask text -> which documentation passages answer this? +// +// The console then runs its OWN deterministic intent code to produce every +// figure. Nothing this service returns is ever read by an operator as data. + +const app = express(); +app.use(cors()); +app.use(express.json({ limit: '64kb' })); + +app.get('/health', async (_req, res) => { + try { + const model = await warmUp(); + const store = await health(); + res.json({ ok: true, model, ...store }); + } catch (err) { + res.status(503).json({ ok: false, error: err.message }); + } +}); + +app.post('/route', async (req, res) => { + const text = String(req.body?.text || '').trim(); + if (!text) return res.status(400).json({ error: 'text is required' }); + + try { + const hits = await query(INTENTS_COLLECTION, text, 5); + const { confidence, score, margin } = classify(hits); + + // Distinct intents only — five phrasings of the same intent in the top-5 + // is a strong signal, not five alternatives to offer the operator. + const seen = new Set(); + const alternatives = []; + for (const h of hits) { + const id = h.metadata?.intentId; + if (!id || seen.has(id)) continue; + seen.add(id); + alternatives.push({ intentId: id, score: h.score, example: h.document }); + } + + return res.json({ + intentId: alternatives[0]?.intentId ?? null, + isWrite: Boolean(hits[0]?.metadata?.isWrite), + confidence, + score: Number(score.toFixed(4)), + margin: Number(margin.toFixed(4)), + matchedExample: hits[0]?.document ?? null, + // Only meaningful when confidence is low — that is when the panel should + // offer these as buttons instead of picking one. + alternatives: alternatives.slice(1, 3) + }); + } catch (err) { + return res.status(503).json({ error: err.message }); + } +}); + +app.post('/ask', async (req, res) => { + const text = String(req.body?.text || '').trim(); + if (!text) return res.status(400).json({ error: 'text is required' }); + + try { + const hits = await query(DOCS_COLLECTION, text, 4); + // Passages are returned verbatim with attribution. There is no generation + // step: summarising would need a hosted model (CLAUDE.md §2) and would let + // a paraphrase drift from what the document actually says. + res.json({ + chunks: hits + .filter((h) => h.score > 0.35) + .map((h) => ({ + text: h.document, + source: h.metadata?.source, + heading: h.metadata?.heading, + score: Number(h.score.toFixed(4)) + })) + }); + } catch (err) { + res.status(503).json({ error: err.message }); + } +}); + +const port = Number(process.env.PORT) || 8787; +app.listen(port, () => { + console.log(`[doormile-ai] listening on :${port}`); + // Pay the model load at boot rather than on an operator's first question. + warmUp() + .then((m) => console.log(`[doormile-ai] model ready: ${m.model} (${m.dimensions}d)`)) + .catch((e) => console.error('[doormile-ai] model failed to load:', e.message)); +}); diff --git a/services/ai/package.json b/services/ai/package.json new file mode 100644 index 0000000..6e1e80e --- /dev/null +++ b/services/ai/package.json @@ -0,0 +1,18 @@ +{ + "name": "doormile-ai-sidecar", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Retrieval sidecar for the Doormile AI assistant — semantic intent routing and document Q&A over a local ChromaDB. Deliberately a separate package: the CRA app's dependency tree and webpack/svgr resolutions must not be touched (root CLAUDE.md §4.3).", + "scripts": { + "start": "node index.js", + "seed": "node seed/run.js", + "eval": "node eval.js" + }, + "dependencies": { + "@xenova/transformers": "^2.17.2", + "chromadb": "^1.9.2", + "cors": "^2.8.5", + "express": "^4.19.2" + } +} diff --git a/services/ai/seed/phrasings.json b/services/ai/seed/phrasings.json new file mode 100644 index 0000000..ea8217f --- /dev/null +++ b/services/ai/seed/phrasings.json @@ -0,0 +1,271 @@ +{ + "_comment": "Example phrasings per intent, used to seed the intent_examples collection. These are deliberately OPERATOR language, not the developer language the regex matchers were written against — the whole point is to cover the phrasings nobody thought to write a pattern for. Do not add a phrasing here that the intent cannot actually answer.", + + "totalOrders": [ + "how many orders today", + "how many bookings do we have today", + "order count for today", + "what's today's order volume", + "how many orders came in", + "total orders today", + "how many jobs today", + "how much work came in today", + "give me the order count", + "how many did we get today", + "orders received today", + "number of orders" + ], + + "weekOrders": [ + "how many orders this week", + "orders for the week", + "weekly order count", + "how many orders last week", + "orders this month", + "how many bookings this month", + "order volume this week", + "what did we do last week", + "how many orders in the last 7 days" + ], + + "statusBreakdown": [ + "how many delivered orders today", + "how many cancelled orders", + "how many pending orders", + "how many orders are assigned", + "count of unassigned orders", + "how many orders are out for delivery", + "how many got cancelled today", + "what's still pending", + "how many are waiting to be picked up", + "how many orders are in transit", + "how many did we deliver", + "how many orders got rejected" + ], + + "orderQuery": [ + "delivered orders for Acme this week", + "cancelled orders for Acme today", + "how many orders did rider Suresh deliver today", + "morning batch orders for Acme", + "top 5 tenants by orders this week", + "which tenant has the most orders", + "busiest riders this week", + "pending orders for Acme Foods", + "rank tenants by order volume", + "who delivered the most today" + ], + + "orderTrend": [ + "orders per day this week", + "orders per hour today", + "daily order trend", + "hourly breakdown of orders", + "how are orders spread across the day", + "show me the order trend", + "when do most orders come in", + "orders by day for last week", + "what's our busiest hour" + ], + + "orderRate": [ + "cancellation rate this week", + "what percentage of orders get cancelled", + "delivery success rate", + "what percent were delivered", + "cancellation percentage", + "ratio of cancelled orders", + "how often do orders get cancelled", + "what's our delivery rate" + ], + + "delayedOrders": [ + "which orders are delayed", + "anything running late", + "what's overdue", + "how many orders are late", + "which hubs are experiencing delays", + "anything stuck", + "are we behind on anything", + "show me late deliveries", + "orders past their delivery time", + "what's at risk right now", + "any SLA breaches", + "which orders missed their promise" + ], + + "batchCount": [ + "morning batch orders today", + "how many in the afternoon batch", + "evening batch count", + "orders in the morning wave", + "what's in the afternoon run", + "how many orders in tonight's batch" + ], + + "revenueTotal": [ + "total revenue today", + "how much did we make today", + "revenue this week", + "what were the earnings today", + "total charges for the week", + "how much money came in", + "collections today", + "what's the total value of today's orders" + ], + + "comparisonIntent": [ + "orders today vs yesterday", + "compare this week to last week", + "revenue this week compared to last week", + "how does today compare to yesterday", + "are we up or down on last week", + "this month versus last month" + ], + + "opsSummary": [ + "give me today's operations summary", + "how are we doing today", + "daily overview", + "what's the situation right now", + "summarise today", + "ops snapshot", + "give me the headline numbers", + "how's everything looking", + "today at a glance" + ], + + "orderLookup": [ + "status of order #1234", + "where is order DM-BK-123", + "what happened to order 4821", + "look up booking DM-BK-900", + "check order #55", + "is order 1234 delivered yet", + "who's got order #4821" + ], + + "parcelTrack": [ + "track consignment DM-CN-123", + "where is parcel DM-CN-99", + "track shipment ABC-123", + "scan history for DM-CN-123", + "what's happened to this consignment" + ], + + "riderCounts": [ + "how many riders are active", + "how many riders do we have out", + "rider availability", + "how many are on delivery", + "who's available right now", + "how many riders are free", + "fleet status", + "how many riders are offline" + ], + + "riderLookup": [ + "where is rider Kumar", + "find rider Suresh", + "status of rider Ali", + "look up rider Priya", + "is Kumar online" + ], + + "riderActivity": [ + "how is rider Kumar doing today", + "rider Suresh performance", + "how'd Kumar do today", + "how many did rider Ali complete", + "rider Priya stats", + "how many did Kumar reject", + "how far has rider Suresh travelled", + "activity for rider Kumar" + ], + + "tenantList": ["how many tenants do we have", "list all tenants", "how many clients", "show me our tenants", "tenant count"], + + "tenantCount": [ + "orders for Acme today", + "how many orders did Acme place", + "Acme Foods order count", + "how much work from Beta Kitchens" + ], + + "tenantDetail": [ + "tell me about tenant Acme Foods", + "details for Acme", + "what do we know about Beta Kitchens", + "Acme Foods locations", + "show me tenant Acme" + ], + + "hubStatus": [ + "current hub status", + "how many hubs do we have", + "are all hubs active", + "hub overview", + "which hubs are open" + ], + + "hubLookup": ["status of hub Chennai", "find hub Coimbatore", "look up the Bengaluru hub"], + + "vehicleStatus": [ + "how many vehicles are available", + "vehicle count", + "how many bikes do we have", + "fleet vehicle status", + "what vehicles are free" + ], + + "vehicleLookup": ["find vehicle TN01AB1234", "status of vehicle TN37XY9", "look up vehicle KA05MM1"], + + "tripsheetStatus": [ + "how many tripsheets are dispatched", + "tripsheet count", + "how many trips are running", + "open tripsheets" + ], + + "exceptionStatus": [ + "how many open exceptions", + "any delivery exceptions", + "what's gone wrong", + "exception count", + "failed deliveries" + ], + + "consignmentStatus": ["how many consignments do we have", "consignment count", "total consignments"], + + "customerCount": ["how many customers do we have", "customer count", "how many people have ordered", "total customers"], + + "appUserCount": ["how many app users do we have", "staff login count", "how many console users"], + + "pricingCount": ["how many pricing rules are configured", "pricing rule count", "how many tariffs do we have"], + + "partnerCount": ["how many partners do we have", "partner count", "list our fleet partners"], + + "competitorBranchCount": ["how many competitor branches are tracked", "competitor branch count", "competitive intel count"], + + "carrierPricingCount": ["how many carrier pricing rules", "carrier tariff count", "carrier pricing entries"], + + "createCustomer": [ + "create a customer", + "add a new customer", + "register a client", + "I need to add a customer", + "new customer please", + "set up a customer record", + "add customer Ramesh 9876543210" + ], + + "createOrder": [ + "create an order", + "book a delivery", + "place a new order", + "I need to create a booking", + "new delivery please", + "raise an order", + "add a booking" + ] +} diff --git a/services/ai/seed/run.js b/services/ai/seed/run.js new file mode 100644 index 0000000..cb11cd9 --- /dev/null +++ b/services/ai/seed/run.js @@ -0,0 +1,148 @@ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { INTENTS_COLLECTION, DOCS_COLLECTION, resetCollection } from '../collections.js'; +import { embedMany, MODEL_ID } from '../embed.js'; + +// ==============================|| Seeding ||============================== // +// +// Rebuilds both collections from scratch. Idempotent — safe to re-run any time, +// and it MUST be re-run after changing the embedding model or editing any +// indexed document, or answers drift from the source without any error. + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const REPO = path.resolve(HERE, '../../..'); + +// Write intents get a stricter confidence bar in the console (a semantic +// near-miss must never open a create form), so they are flagged here. +const WRITE_INTENTS = new Set(['createOrder', 'createCustomer']); + +const DOMAIN = { + orders: ['totalOrders', 'weekOrders', 'statusBreakdown', 'orderQuery', 'orderTrend', 'orderRate', 'delayedOrders', 'batchCount', 'revenueTotal', 'comparisonIntent', 'opsSummary', 'orderLookup', 'parcelTrack'], + riders: ['riderCounts', 'riderLookup', 'riderActivity'], + tenants: ['tenantList', 'tenantCount', 'tenantDetail'], + fleet: ['hubStatus', 'hubLookup', 'vehicleStatus', 'vehicleLookup', 'tripsheetStatus', 'exceptionStatus', 'consignmentStatus'], + admin: ['customerCount', 'appUserCount', 'pricingCount', 'partnerCount', 'competitorBranchCount', 'carrierPricingCount'], + write: ['createCustomer', 'createOrder'] +}; + +const domainOf = (intentId) => Object.entries(DOMAIN).find(([, ids]) => ids.includes(intentId))?.[0] || 'other'; + +// ---- intent_examples -------------------------------------------------------- +const seedIntents = async () => { + const raw = JSON.parse(fs.readFileSync(path.join(HERE, 'phrasings.json'), 'utf8')); + const ids = []; + const documents = []; + const metadatas = []; + + for (const [intentId, phrasings] of Object.entries(raw)) { + if (intentId.startsWith('_')) continue; + phrasings.forEach((phrase, i) => { + ids.push(`${intentId}::${String(i).padStart(2, '0')}`); + documents.push(phrase); + metadatas.push({ intentId, domain: domainOf(intentId), isWrite: WRITE_INTENTS.has(intentId) }); + }); + } + + const collection = await resetCollection(INTENTS_COLLECTION); + const embeddings = await embedMany(documents); + await collection.add({ ids, documents, metadatas, embeddings }); + return { intents: new Set(metadatas.map((m) => m.intentId)).size, vectors: ids.length }; +}; + +// ---- console_docs ----------------------------------------------------------- +// +// Split on markdown headings, then hard-wrap long sections. The heading path is +// prepended to every chunk so a retrieved passage carries its own context — +// without it, a chunk reading "Don't do this" is worse than useless. +const CHUNK_CHARS = 800; +const OVERLAP = 100; + +const DOC_SOURCES = [ + 'express-console-api.md', + 'CLAUDE.md', + 'src/pages/api/CLAUDE.md', + 'src/pages/nearle/assistant/CLAUDE.md', + 'src/pages/nearle/assistant/ROADMAP.md', + 'src/pages/nearle/assistant/RAG_PLAN.md', + 'src/pages/nearle/dispatch/CLAUDE.md', + 'src/pages/nearle/orders/CLAUDE.md' +]; + +const chunkMarkdown = (text, source) => { + const out = []; + const lines = text.split('\n'); + let heading = source; + let buffer = []; + + const flush = () => { + const body = buffer.join('\n').trim(); + buffer = []; + if (!body) return; + for (let i = 0; i < body.length; i += CHUNK_CHARS - OVERLAP) { + const slice = body.slice(i, i + CHUNK_CHARS).trim(); + if (slice.length > 60) out.push({ heading, text: `${heading}\n\n${slice}` }); + } + }; + + for (const line of lines) { + if (/^#{1,4}\s/.test(line)) { + flush(); + heading = line.replace(/^#+\s*/, '').trim(); + continue; + } + buffer.push(line); + } + flush(); + return out; +}; + +const seedDocs = async () => { + const ids = []; + const documents = []; + const metadatas = []; + + for (const rel of DOC_SOURCES) { + const abs = path.join(REPO, rel); + if (!fs.existsSync(abs)) { + console.warn(`[seed] skipped missing ${rel}`); + continue; + } + const chunks = chunkMarkdown(fs.readFileSync(abs, 'utf8'), rel); + chunks.forEach((c, i) => { + ids.push(`${rel}::${i}`); + documents.push(c.text); + metadatas.push({ source: rel, heading: c.heading, updatedAt: new Date().toISOString().slice(0, 10) }); + }); + } + + const collection = await resetCollection(DOCS_COLLECTION); + const embeddings = await embedMany(documents); + // Chroma caps how much it will accept in one add; these corpora are small + // but batch anyway so this doesn't become a surprise later. + const BATCH = 200; + for (let i = 0; i < ids.length; i += BATCH) { + // eslint-disable-next-line no-await-in-loop + await collection.add({ + ids: ids.slice(i, i + BATCH), + documents: documents.slice(i, i + BATCH), + metadatas: metadatas.slice(i, i + BATCH), + embeddings: embeddings.slice(i, i + BATCH) + }); + } + return { sources: DOC_SOURCES.length, vectors: ids.length }; +}; + +const main = async () => { + console.log(`[seed] model: ${MODEL_ID}`); + const intents = await seedIntents(); + console.log(`[seed] intent_examples: ${intents.vectors} vectors across ${intents.intents} intents`); + const docs = await seedDocs(); + console.log(`[seed] console_docs: ${docs.vectors} chunks from ${docs.sources} sources`); + console.log('[seed] done'); +}; + +main().catch((err) => { + console.error('[seed] failed:', err.message); + process.exit(1); +}); diff --git a/src/components/nearle_components/PageHeader.js b/src/components/nearle_components/PageHeader.js index 433f486..f155c2f 100644 --- a/src/components/nearle_components/PageHeader.js +++ b/src/components/nearle_components/PageHeader.js @@ -20,7 +20,7 @@ import { StatusDot } from '@astryxdesign/core/StatusDot'; export default function PageHeader({ title, subtitle, live = false, action }) { return ( - + {title} diff --git a/src/components/nearle_components/PageLayout.js b/src/components/nearle_components/PageLayout.js index c8a7b3d..32aea4a 100644 --- a/src/components/nearle_components/PageLayout.js +++ b/src/components/nearle_components/PageLayout.js @@ -60,7 +60,7 @@ const FILL_CLASS = 'dt-page-fill'; // shell (e.g. Create Order's `.co-root` token block). It used to be dropped // silently, which made every page-scoped rule fail to match with no error — // the styles simply never applied. -export function PageShell({ children, gap = 6, fill = false, className }) { +export function PageShell({ children, gap = 4, fill = false, className }) { return ( <> {fill && ( @@ -105,10 +105,10 @@ PageShell.propTypes = { // `max` caps the column count so 3 cards don't each become 400px wide on an // ultrawide monitor; it defaults to the number of children. // --------------------------------------------------------------------------- -export function KpiGrid({ children, minWidth = 240, max }) { +export function KpiGrid({ children, minWidth = 180, gap = 3, max }) { const count = Array.isArray(children) ? children.filter(Boolean).length : 1; return ( - + {children} ); @@ -117,6 +117,7 @@ export function KpiGrid({ children, minWidth = 240, max }) { KpiGrid.propTypes = { children: PropTypes.node, minWidth: PropTypes.number, + gap: PropTypes.number, max: PropTypes.number }; diff --git a/src/components/nearle_components/StatCard.js b/src/components/nearle_components/StatCard.js index 347e915..64d9dd4 100644 --- a/src/components/nearle_components/StatCard.js +++ b/src/components/nearle_components/StatCard.js @@ -7,53 +7,42 @@ import { Center } from '@astryxdesign/core/Center'; import { Text } from '@astryxdesign/core/Text'; import { Skeleton } from '@astryxdesign/core/Skeleton'; -// ==============================|| STAT / KPI CARD (Doormile-style) ||============================== // -// Clean metric card: muted eyebrow label, large tabular value, a single -// soft-tinted rounded tile holding the icon (with a faint matching ring for -// depth). A low resting elevation lifts the tile off the page instead of -// reading as a flat bordered box. No coloured top-stripe, no rainbow — -// colour appears only in the icon tile so a row of cards reads calm and -// corporate. +// ==============================|| STAT / KPI CARD (Doormile-style Modern) ||============================== // +// Modern, space-efficient KPI tile: +// - Left-aligned icon tile directly grouped with the metric stack to eliminate internal dead space. +// - Soft ambient accent gradient tint based on semantic status color. +// - Crisp typography and tight vertical footprint (~56px height) saving ~30% page height. +// - Micro-hover interaction (subtle lift + shadow depth). // // `icon` is a rendered node, e.g. icon={}. -// `color` is a hex accent (defaults to brand red); `caption` is the small -// muted line under the value (e.g. "96% of total"). -// -// Astryx note: the icon tile keeps an inline `style` because the accent is a -// per-call hex from the caller (each KPI tile carries its own semantic status -// colour — see CLAUDE.md's status palette), which Astryx's fixed `variant` -// and semantic `color` enums can't express. Same escape hatch login.js uses -// for the brand gradient panel. +// `color` is a hex accent (defaults to brand black); `caption` is optional supporting text. export default function StatCard({ title, value, icon, color = '#000000', caption, loading = false }) { return ( - - - - - {title} - - {loading ? ( - - ) : ( - - {value} - - )} - {caption && ( - - {caption} - - )} - + + {icon && (
)} + + + {title} + + {loading ? ( + + ) : ( + + {value} + + )} + {caption && ( + + {caption} + + )} + ); diff --git a/src/globalPolish.css b/src/globalPolish.css index 565c244..ead39f4 100644 --- a/src/globalPolish.css +++ b/src/globalPolish.css @@ -52,3 +52,22 @@ body { *::-webkit-scrollbar-corner { background-color: transparent; } + +/* KPI / StatCard sleek interactions & polish */ +.dt-stat-card { + transition: transform 0.18s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.18s cubic-bezier(0.4, 0, 0.2, 1), border-color 0.18s ease !important; +} + +.dt-stat-card:hover { + transform: translateY(-1px); + box-shadow: 0 4px 14px rgba(15, 23, 42, 0.08), 0 1px 3px rgba(15, 23, 42, 0.04) !important; +} + +.dt-stat-card .dt-stat-icon { + transition: transform 0.18s ease; +} + +.dt-stat-card:hover .dt-stat-icon { + transform: scale(1.06); +} + diff --git a/src/pages/api/api.js b/src/pages/api/api.js index 48cea6f..3d3a5d7 100644 --- a/src/pages/api/api.js +++ b/src/pages/api/api.js @@ -46,9 +46,14 @@ import { // right after an assign-miler call — see orders.js's ORDERS_STATUS_TABS // comment) are confirmed; the rest of this mapping is still a best-effort // guess. Unmapped statuses pass through lowercased, which the page's own -// fallback renders as an "unknown" badge rather than crashing. There's no -// confirmed equivalent for arrived/picked/skipped at all, so those tabs will -// show a 0 count until the real enum is confirmed. +// fallback renders as an "unknown" badge rather than crashing, AND the +// Deliveries page now surfaces those rows under an "Other" tab that names the +// unmapped enum — previously such a row was counted nowhere and shown nowhere. +// `picked` now has a confirmed source (Converted_To_Consignment, below). +// `arrived` and `skipped` still have none: the rider actions that produce them +// (POST /miler/bookings/:id/reached, POST /miler/consignments/:id/skip) have no +// booking-status equivalent in the confirmed enum, so those two tabs stay at 0 +// until the backend is confirmed to expose them. // // Miler_Assigned is deliberately kept on 'pending', NOT bumped to 'accepted'. // Assigning a rider is an OPERATOR action (POST /admin/bookings/:id/assign-miler @@ -62,11 +67,20 @@ import { // accepted" in the currently-confirmed enum, so that one maps to 'accepted'. // If the backend turns out to have a distinct status specifically for the // accept action, add it here rather than reusing Miler_Assigned for it. +// Converted_To_Consignment is PICKED, not accepted. doormile-flow.md §5 is +// explicit: `POST /miler/bookings/:bookingid/pickup-complete` "converts the +// booking into a consignment" — so the status is written at the moment the +// rider completes pickup, and the parcel is in their hands. deliveries.js says +// the same thing from the other side ("this order hasn't been picked up yet — +// status can only be updated once it becomes a consignment"). +// +// It sat on 'accepted', which held a picked-up parcel in the Accepted tab and +// left Picked permanently empty — nothing else in the enum maps to it. const BOOKING_STATUS_TO_DELIVERY_STATUS = { pending_pickup: 'pending', miler_assigned: 'pending', pickup_scheduled: 'accepted', - converted_to_consignment: 'accepted', + converted_to_consignment: 'picked', out_for_delivery: 'active', delivered: 'delivered', cancelled: 'cancelled' @@ -135,7 +149,10 @@ export const getRiderPeriodicLogs = async (userid) => { // seeing every hub, unchanged. Fails open (full list) whenever the tenant's // city can't be determined or nothing matches, rather than ever locking an // operator out with an empty picker. -const normCity = (s) => String(s || '').trim().toLowerCase(); +const normCity = (s) => + String(s || '') + .trim() + .toLowerCase(); export const fetchAppLocations = async () => { try { @@ -202,7 +219,16 @@ export const fetchPercentageData = async ({ queryKey }) => { }; } catch (err) { OpenToast(err.response?.data?.message || err.message || 'Failed to load order percentages', 'error', 2000); - return { created: '0', uncoveredOrders: '0', coveredOrders: '0', cancelled: '0', percentage1: '0', percentage2: '0', percentage3: '0', percentage4: '0' }; + return { + created: '0', + uncoveredOrders: '0', + coveredOrders: '0', + cancelled: '0', + percentage1: '0', + percentage2: '0', + percentage3: '0', + percentage4: '0' + }; } }; @@ -327,7 +353,10 @@ export const fetchBatchEfficiency = async ({ batch, tenantId }) => { // jupiter2doormile.md comparison). Falls back to matching the rider's NAME // (also echoed by the solver, see flattenRiders' rider_name) against each // miler's displayname/authname — the only other correlatable field. -const normMilerName = (s) => String(s || '').trim().toLowerCase(); +const normMilerName = (s) => + String(s || '') + .trim() + .toLowerCase(); export const buildMilerLookup = (milers) => { const byUserId = new Map((milers || []).map((m) => [String(m.userid), m])); @@ -348,10 +377,10 @@ export const resolveMilerForOrder = (order, lookup) => { const matchedVia = lookup.byUserId.has(String(riderUserId)) ? 'userid' : lookup.byProfileId.has(String(riderUserId)) - ? 'milerprofileid' - : lookup.byName.has(normMilerName(riderName)) - ? 'name' - : null; + ? 'milerprofileid' + : lookup.byName.has(normMilerName(riderName)) + ? 'name' + : null; const rider = lookup.byUserId.get(String(riderUserId)) ?? lookup.byProfileId.get(String(riderUserId)) ?? lookup.byName.get(normMilerName(riderName)); return rider?.milerprofileid ? { rider, matchedVia } : null; @@ -407,7 +436,11 @@ export const finalCreatedeliveries = async (deliveryData) => { realBookingIds = new Set(realBookings.map((b) => String(b.bookingid))); logger.debug(`finalCreatedeliveries: ${realBookingIds.size} real booking id(s) fetched for validation`); } catch (err) { - logger.error('finalCreatedeliveries: GET /admin/bookings failed — cannot validate booking ids', err.response?.status, err.response?.data || err.message); + logger.error( + 'finalCreatedeliveries: GET /admin/bookings failed — cannot validate booking ids', + err.response?.status, + err.response?.data || err.message + ); } const results = await Promise.allSettled( @@ -464,11 +497,7 @@ export const finalCreatedeliveries = async (deliveryData) => { if (failed.length) { OpenToast(`${failed.length} of ${deliveries.length} order(s) couldn't be assigned — check Orders/Deliveries`, 'warning', 4000); } - const resolvedMilerProfileIds = [ - ...new Set( - results.filter((r) => r.status === 'fulfilled').map((r) => r.value.milerprofileid) - ) - ]; + const resolvedMilerProfileIds = [...new Set(results.filter((r) => r.status === 'fulfilled').map((r) => r.value.milerprofileid))]; return { success: true, assigned: deliveries.length - failed.length, failed: failed.length, resolvedMilerProfileIds }; }; // ==============================|| createAutomationDeliveries (orders) Auto rider Assign ||============================== // @@ -536,7 +565,11 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => { // parameter is documented in express-console-api.md and guessing one risks a // silent 400 or, worse, a silently-ignored filter), so the range is applied // client-side below, after the rows are normalised. - const [, , , , startdate, enddate, rowsPerPage] = queryKey; + // queryKey[11] is an OPT-IN date basis. Four pages share this function + // (deliveries, Dispatch, reports/ordersDetails, reports/profitability) and + // three of them genuinely want "created in this window", so the default is + // unchanged and only the Deliveries page passes 'activity'. + const [, , , , startdate, enddate, rowsPerPage, , , , , dateBasis] = queryKey; // Unlike the 3 joins below (customers/milers/tenants — each individually // guarded so a failed join just degrades a display field, not the whole // page), a failed bookings call is the one thing this function can't @@ -665,14 +698,23 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => { // // A missing/blank bound means "unbounded on that side", which preserves the // old behaviour for any caller that doesn't pass real dates. + // + // 'activity' additionally keeps a row whose LAST UPDATE falls in the window. + // Scoping the Deliveries page purely by creation date meant an order created + // yesterday and picked or delivered today was invisible today — which is + // exactly why every tab past Accepted read 0 while the day's fresh orders + // filled Pending and Accepted. A delivery board has to show what is moving + // now, not only what was booked now. + const dayOf = (value) => { + const t = parseDoormileTimestamp(value); + return t.isValid() ? t.format('YYYY-MM-DD') : null; + }; + const inRange = (row) => { if (!startdate && !enddate) return true; - const t = parseDoormileTimestamp(row.orderdate); - if (!t.isValid()) return false; - const day = t.format('YYYY-MM-DD'); - if (startdate && day < String(startdate)) return false; - if (enddate && day > String(enddate)) return false; - return true; + const days = [dayOf(row.orderdate), dateBasis === 'activity' ? dayOf(row.assigntime) : null].filter(Boolean); + if (!days.length) return false; + return days.some((day) => (!startdate || day >= String(startdate)) && (!enddate || day <= String(enddate))); }; return { @@ -815,9 +857,7 @@ export const getallridersummary = async () => { // silently inflating one of the two new buckets. const active = milers.filter((m) => !['offline', 'blocked'].includes(String(m.availabilitystatus || '').toLowerCase())).length; const available = milers.filter((m) => String(m.availabilitystatus || '').toLowerCase() === 'available').length; - const onDelivery = milers.filter((m) => - ['assigned', 'on_pickup'].includes(String(m.availabilitystatus || '').toLowerCase()) - ).length; + const onDelivery = milers.filter((m) => ['assigned', 'on_pickup'].includes(String(m.availabilitystatus || '').toLowerCase())).length; return { total: milers.length, active, inactive: milers.length - active, available, onDelivery }; } catch (err) { OpenToast(err.response?.data?.message || err.message || 'Failed to load rider summary', 'error', 2000); diff --git a/src/pages/nearle/assistant/CLAUDE.md b/src/pages/nearle/assistant/CLAUDE.md index 9a18d2c..96844d0 100644 --- a/src/pages/nearle/assistant/CLAUDE.md +++ b/src/pages/nearle/assistant/CLAUDE.md @@ -16,6 +16,8 @@ An in-console Q&A assistant that answers operator questions about live data — - `AIWelcome.js` — greeting + suggestion cards (empty-thread state only). - `AIMessage.js` — one turn. User turns are bubbles; assistant turns deliberately are NOT. - `AIComposer.js` — auto-growing textarea, Enter to send, Shift+Enter for newline. + - `AIFlowStep.js` — one dropdown turn of a conversational create (§3.5). + - `AIBulkOrderForm.js` — the one create that stays a form (CSV paste). - `AIParts.js` — Spark / LiveIndicator / TypingIndicator / Metric / StatGrid / StateBlock. - `pageContext.js` — route → context label + suggested questions. - **`DoormileAI.css`** — the panel's stylesheet (same convention as `OrdersRedesign.css`). @@ -25,7 +27,10 @@ An in-console Q&A assistant that answers operator questions about live data — - **Assistant turns must not become bubbles.** The no-bubble treatment is what keeps this reading as part of the dashboard rather than a bolted-on chatbot. - **Never put a React element in message state.** Messages are JSON round-tripped through `localStorage`; elements don't survive it (`$$typeof` is a Symbol and is dropped) and the rehydrated value crashes the next render. Icons are referenced by *key* (`iconKey`) and resolved in `AIParts.js`. Same rule for anything new you add to a message. - **Selectors that style an Astryx Stack need two classes.** `padding={0}` emits a StyleX atomic at the same (0,1,0) specificity as a bare class, so `.dai-header` can lose on stylesheet order. Those rules are written `.dai-root .dai-header`. Don't "simplify" them back to one class. This never applies to `.dai-panel`/`.dai-scrim`, which carry `.dai-root` on the *same* element. +- **The Doormile D is the assistant's identity, and `Spark` owns it.** Header, every reply, the welcome screen, the thinking state and the top-nav trigger all render `assets/images/doormile-mark.png` through that one component, so they can't drift apart. It replaced a white sparkle glyph, which is why the chip lost its gradient: the mark is red on a transparent ground and carries its own circular frame, so a coloured fill behind it fights the logo. The trigger's active state is a tinted surface for the same reason — an image can't be inverted to white the way an icon could. - **`--dai-accent` is the single accent knob.** It resolves to the app accent (black, root CLAUDE.md §6.2). Switching the assistant to Doormile red is one line in `DoormileAI.css`, not a hunt through components. +- **Every page offers every suggestion.** The assistant answers about orders, riders, hubs and the rest regardless of which screen is open, so hiding a question because you're on Dispatch made it look narrower than it is. `getPageContext` appends the whole deduplicated catalog to each route's own list — the page still decides ORDER (its questions lead), not membership. `more` is retired; one flat list means one place a question can be. +- **Off-topic questions point at doormile.com, they don't get invented answers.** `aboutDoormile` is LAST in `INTENTS` so every operational intent gets first refusal, and its trigger is narrow on purpose — "how many doormile orders today" mentions the name but is an orders question. What it says is only what this console demonstrably does; nothing about the company, its coverage, pricing or history is in this app, and doormile.com is where that lives. The no-match state in `AIPanel.js` points there too. - **Every suggestion in `pageContext.js` must actually resolve** against `INTENTS`. A chip that returns "I can't answer that yet" is worse than no chip — check it before adding. --- @@ -36,7 +41,23 @@ This was a deliberate, explicit product decision (not a technical limitation wor Two paths existed: add a new endpoint to `api.doormile.com` to hold the key (rejected — "don't need to create the new endpoints, use the existing ones"), or stay fully client-side with a much richer deterministic matcher (chosen). **Do not silently reach for an LLM/RAG library here** without first getting a decision on where its key would live — that conversation already happened once and the answer was no. -RAG (retrieval-augmented generation over a document/vector store) was also explicitly considered and rejected as the wrong tool: this bot's data isn't unstructured documents, it's structured operational data already reachable through typed API functions. The correct "grounded answer" pattern for that is what's already here — a fixed catalog of `{match, run}` pairs, not a vector search. +### RAG — rejected for the data, later built for the ROUTING + +RAG was first considered and rejected, and half that reasoning still stands: **this bot's data isn't unstructured documents**, it's structured operational data reachable through typed API functions. A vector store is a snapshot; "how many orders today" changes by the minute. **No operational data is ever embedded, and no figure ever comes from retrieval.** + +What was later built (`services/ai/`, `ragRouter.js`) applies retrieval to a different problem — *which question is this?* The regex catalog's weakness was never logic, it was vocabulary: "cancellation" not matching `cancel(led)?`, a bare reply matching nothing, "per day" being silently dropped. Retrieval fixes matching without touching how an answer is produced: + +``` +question → embed → Chroma → intentId + confidence → the SAME run() → live API call +``` + +Why this does not violate the key constraint above: the embedding model (`Xenova/all-MiniLM-L6-v2`) runs **in-process in Node with no API key**. The blocker was "a hosted model needs a secret and we have nowhere to put it" — that doesn't apply. Moving to hosted embeddings, or adding a generation step, re-opens this section and needs its own decision. `/ask` therefore returns documentation passages **verbatim with attribution**, never a paraphrase. + +Three rules that must hold: + +- **The deterministic matcher stays.** It is the fallback when the sidecar is absent, slow, or unsure. `REACT_APP_AI_URL` unset is a supported state — that is what keeps the app deployable exactly as it is today. +- **Slots stay deterministic.** Retrieval picks the intent; `rangeFromWords`/`statusFromWords`/entity resolution still extract the values. +- **Write intents need high confidence.** A semantic near-miss must never open a create form. --- @@ -81,6 +102,8 @@ A few layers sit on top of the plain `{match, run}` loop, all in `intents.js`, a - **Comparisons** — `comparisonIntent` (trigger: "vs"/"versus"/"compare[d] to") runs two `fetchBookingsInRange` calls and reports both counts/totals side by side. Ordered early (right after `tenantList`) since it must win before `totalOrders`/`revenueTotal` would otherwise swallow the question on the bare word "orders"/"revenue". - **Multi-part answers** — `answerMultiPart()` splits on and/,/&, matches each segment independently through the same `INTENTS`, and only combines them if ≥2 segments resolve. A single-segment match falls through to the normal path untouched. - **Follow-up context** — `answerQuestion(text, context)` takes `{ lastIntentId, lastParams }` from the previous turn (tracked in `AIPanel.js`'s state). If the new text is a bare date/range phrase ("what about yesterday?") with no other domain keyword, it re-runs the *same* intent with the date swapped rather than requiring the whole question again. This is pattern-matching on the phrase shape, not real conversational memory — a question that also names a different domain is treated as new, not a follow-up. +- **`GET /admin/bookings/:id/track` is not called.** Its response shape was never confirmed (`express-console-api.md` lists it as written-but-unproven), so it produced a "Tracking" line nobody could rely on and an audit entry that reported an *error* on every order that simply has no trail yet. Removed on explicit direction — don't add it back without a confirmed response shape. `ROADMAP.md` still proposes it; that entry is stale. +- **A pasted booking number is a whole question.** `orderLookup` matches a STRONG reference (`DM-…`, `#1234`) with no keyword around it and answers with the full record — status, rider, recipient, both addresses, service and price, parcels, timestamps, SLA, tracking. A WEAK reference (bare digits) still needs an order/booking/status/where word, or a stray "42" would be read as an order id. Rows are omitted rather than shown as "—", so a blank never reads as "we checked and it's empty" when it means the field isn't on the booking at all. - **Entity lookups** — `riderLookup`/`hubLookup`/`vehicleLookup` require an explicit `LOOKUP_TRIGGER` phrase ("find"/"where is"/"status of"/"search for") before a name, and are ordered ahead of their aggregate counterparts (`riderCounts`/`hubStatus`/`vehicleStatus`) so a named-entity question doesn't get swallowed by the count intent. ### Ordering and cross-domain guards — read before adding an intent @@ -112,22 +135,102 @@ If you add a new intent whose trigger words could plausibly appear in an unrelat --- -### Customer creation writes to `/admin/customers` +### Customer creation writes to `/admin/tenantcustomers` -`executeCreateCustomer` posts to `/admin/customers` — the same resource the Customers page reads — so a created customer appears there straight away. +Settled by evidence, not by reading the docs: -It previously posted to `/admin/tenantcustomers`. That call succeeded but wrote to a **different store with its own id sequence**: a customer created through the bot came back as id 2 while the Customers list ran to id 44, and never showed on the page. Repointing the *page* at `tenantcustomers` was tried first and reverted — it changes what that page means, and its edit dialog would then PATCH a different store by id (404 at best, editing the wrong person at worst). +``` +POST /admin/customers → 405 Method Not Allowed (confirmed live) +``` -Two things to know if this ever needs revisiting: +405 is unambiguous — the route exists and POST is not among its methods. `express-console-api.md` lists `/admin/customers` as GET + PATCH only and the server agrees. It was pointed there briefly on explicit instruction; the live 405 settled it. **Don't try it again.** -- `express-console-api.md` documents `/admin/customers` as **GET + PATCH only**, and describes a customer there as something a booking creates (`POST /admin/expressbooking` — *"creates a Guest customer if unknown"*). The POST was wired on explicit instruction regardless. If the route is absent the server answers 404/405 and the assistant surfaces it verbatim with the endpoint name, so an unsupported route fails loudly rather than silently. -- The sidebar's **Create Customer page** (`clients/createCustomer.js`) still calls `createTenantCustomer`. It and the bot now write to different stores. If the bot's endpoint is correct, that page should follow. +**The consequence, which the assistant states in its success message:** a customer created by the bot does **not** appear on the Customers page, because that page reads `GET /admin/customers`. On that resource a customer comes into existence as a side effect of a booking — `POST /admin/expressbooking` documents `customer_phone` as *"creates a Guest customer if unknown"*. A B2C customer is, by design, someone who has ordered. (That is also why `address`/`city`/`latitude` are empty on every live record there.) + +**Resolved:** the **Customers page now reads `GET /admin/tenantcustomers`** (`customers/customers.js`), so a created customer appears there immediately. + +Its **edit dialog moved with it** — `updateTenantCustomer`, not `updateAdminCustomer`. That part is load-bearing: the two stores have separate id sequences, so PATCHing `/admin/customers/:id` with a tenant-customer id is a 404 at best and **edits a different person** at worst. If you ever repoint the read, repoint the write in the same change. + +The page's accessors read **both** record shapes (`name` or `firstname`+`lastname`, `phone` or `contactno`, four possible id fields) because the tenant-customer response shape has never been captured. A field-name difference costs one column, not a table of blanks. + +Creating the customer *via a booking* was rejected: "add a customer" must never silently dispatch a delivery. + +The sidebar's **Create Customer page** (`clients/createCustomer.js`) uses the same endpoint, so page and bot agree. + +--- + +## 3.5 Conversational writes — `customerFlow.js` / `orderFlow.js` + +Three creates exist: **customer**, **single order**, **bulk orders**. **All three are conversations**, one question per turn — explicit product direction, twice: a form was built first for the customer and replaced, then again for bulk ("don't show it as the form way, it should be like chatting"). There is no create-form component left in this folder; `AICustomerForm`, `AIOrderForm` and `AIBulkOrderForm` were deleted as they became unreachable. + +**The write gate is unchanged and non-negotiable:** the bot gathers, then shows exactly what will be sent, and the mutation fires only when the operator presses the button. `executeCreateCustomer` / `executeCreateOrder` / `executeCreateBulk` are the *only* mutating functions, and nothing calls them from a `match`. + +### The panel drives the conversation, not the router + +`AIPanel.js` intercepts a reply **before `answerQuestion` sees it** whenever a flow is open. This is load-bearing, not a refactor: `answerQuestion` routes by matching text, and a bare answer like `8494948494` matches no intent — the first version of this lost every reply to "I can't answer that one yet." A flow reply must never reach the router. + +Flow state lives in `useState` and is **never persisted**. A half-finished create can't be resurrected in a later session, and `loadHistory` strips `flowStep` on load — a step's `options`/`apply`/`validate` are functions, which JSON drops, so a restored dropdown would render an empty list with nowhere to send an answer. + +### One engine, three flows + +The step-walker is `flowEngine.js`, shared by `orderFlow.js` and `bulkFlow.js`. It was written inside orderFlow and extracted when bulk became a conversation — a second copy would have been a third definition of the same branching rules. `customerFlow.js` predates it and still has its own simpler walker. + +Step entries carry: + +| key | meaning | +|---|---| +| `type: 'select'` | rendered as an Astryx `Selector` by `AIFlowStep.js`. **Use this wherever the Create Order page uses a dropdown** — asking an operator to type a location name invites one the resolver can't match. | +| `type: 'rows'` | rendered as `AIRowsStep.js` — file upload *and* paste in one turn. Offering them together is deliberate: a "file or paste?" question costs a turn and answers nothing the operator hasn't already decided by having a file or not. | +| `type: 'text'` | answered through the composer. | +| `when(draft)` | skipped when false. This is the branching mechanism (existing vs new customer). | +| `options(draft)` | async — locations, customers and tenants are fetched live so a list is never stale or invented. `AIFlowStep` distinguishes loading / empty / failed rather than merging them into one spinner. | +| `validate(raw, option)` | re-asks the same step. Gets the chosen **option**, so a select can reject a record (CityGate on a pickup location) and not just a string. | +| `resolve(raw)` | may fail and re-ask — geocoding. A delivery with no coordinates can never be dispatched, so it's refused here rather than stored. | +| `auto(draft)` | the step answers itself from real data and is only *asked* when that fails, with the reason. Currently just `finalprice`. | + +### Two silent-NaN traps that were live + +- **`tenantid`.** A client login skips the tenant question, but `buildOrderPayload` does `Number(d.tenantid)`. `startOrderFlow` therefore **seeds the draft** from `localStorage.tenantid`. Skipping a question is only safe if something else supplies the value. +- **`finalprice`.** Pricing used to happen in the panel after the flow finished, so a tenant with no pricing row produced `finalprice: NaN`. It is now a real step with `auto`: quoted from that tenant's pricing row and the routed distance where possible, **asked for** where not — never zero, never invented. The confirmation says which of the two it was. + +`validateOrderDraft` runs on the whole draft one last time before a Create button is rendered. The per-step checks are for feedback; this is the gate. + +### Bulk — a conversation, then one long pass + +Same opening as the single order, because they are the same questions: tenant → pickup location → service. Only the last step differs: a whole sheet instead of one recipient. + +**Locating and pricing are NOT a step.** They are a pass over the whole file after the last answer, narrated into a single message that rewrites itself (`pushLive` / `patch` in the panel) rather than pushing a turn per row. Making them a step would mean a question nobody is being asked. + +**Stop stops the address lookups, not the pricing.** Nominatim is the ~1/second bottleneck; pricing is unthrottled and bounded by what was already located. Gating pricing on the same flag meant a Stop mid-lookup left every located row unpriced and therefore unsendable — throwing away exactly the work the operator is told is kept. + +**Root cause beats symptom in `validateBulkRow`.** Coordinates are checked before the price: an unlocatable address is *why* the row has no price, and reporting "Price must be a number" for a bad address sends the operator to fix the wrong column. + +### One row pipeline, two inputs + +A file (`bulkFile.js`) and a paste (`parseBulkRows`) produce the **same row array**, so locating, pricing, review, the chunked submit and the per-row report have one implementation. Adding a third input means producing that array, nothing else. + +**The column map is shared with the page.** `utils/bulkOrderColumns.js` holds the map that used to live inside `multipleOrders.js`; the page imports it now. A sheet that uploads on the page uploads in the bot, permanently — copying it was the alternative and is how five pages once ended up with disagreeing `STATUS_META`. `normalizeHeader` is deliberately *not* star-tolerant (the page derives its missing-required warning from the `*`); only the assistant's `rowFieldForHeader` is, because `Receiver Phone*` and `ReceiverPhone` are the same column. That mismatch shipped a template whose own parser couldn't read its phone or address column. + +`Collect Cash` is **not** a price. It is cash to collect from the recipient; `finalprice` is what the delivery costs. Mapping one onto the other bills the wrong number on every row. + +**Locating is the cost, not parsing.** Nominatim allows ~1 lookup/second, so 200 rows is ~3.7 minutes. Three things make that survivable, and none are optional: +- Sheets carrying `latitude`/`longitude` columns skip the lookup entirely. +- Results are cached by address for the life of the form, so fixing three rows and re-running doesn't re-look-up the other 197. +- **Stop is a ref, never state.** It *was* state, read inside the async loop — captured at call time, never updated — so Stop did nothing and the operator waited out every lookup. + +**A blank price means "quote it", never zero.** `priceBulkRows` fetches the tenant's pricing row once for the whole file (per row would be 200 identical requests) and costs one OSRM call per unpriced row. A row that can't be priced keeps its blank price and carries the reason, so it fails validation and is reported rather than being sent at a number nobody chose. + +**There is no idempotency key on `POST /admin/expressbooking/bulk`.** A timed-out submit is therefore unrecoverable by re-sending — it double-books everything that landed. Three guards: duplicates *within* a file are flagged before submit (reported, never auto-removed: two parcels to one door is legitimate); the submitted row-set fingerprint is recorded **before** the request, because a timeout never reaches a success handler; and the failed rows are downloadable so only they get re-uploaded. + +Over-cap files chunk into batches of `BULK_MAX` (200) and report per row regardless of batch. Nothing is ever silently truncated. + +Assertions for both engines live outside the repo (project convention is lint-only) — 56 for `orderFlow`, 20 for `customerFlow`, covering the branching, the geocode re-ask, the CityGate refusal and the unpriceable path. --- ## 4. What's deliberately out of scope right now -- **Write actions.** The original ask included "if I say create an order, it should create it" — deliberately **not built**. Giving a keyword-matched bot the ability to mutate data (order creation has real validation elsewhere: CityGate pincode checks, delivery-slot windows, the dispatch reconcile-before-commit rule) is a materially bigger risk than read-only Q&A. If this gets built, it needs its own guardrail — the bot proposes what it would submit, the operator explicitly confirms, only then does a real create-order call fire. Don't wire a write action straight from intent match to a mutation call. +- **Deleting or reassigning anything.** Creates are built (§3.5); destructive and reassigning writes are not, and shouldn't be added without the same conversation. Cancelling an order or swapping a rider has downstream effects (FCM to the rider, the dispatch reconcile rule) that a confirm button doesn't cover. - **Open-ended LLM understanding.** See §2. Revisit only with an explicit decision on where the LLM key lives. - **Tenant/role-aware scoping.** Every intent currently queries the same data an unscoped admin session would see — there's no per-login "you only see your own tenant" filter applied inside `intents.js` itself. Needs a decision on how tenant-locked logins should be detected (`localStorage.tenantid`/`roleid`) and whether that's a hard filter or just a default, before it's built. - **Proactive alerts.** Surfacing anomalies unprompted (e.g. "3 hubs inactive") via the notification bell is a different feature from Q&A — it needs a polling/watch mechanism, and the notification panel it would feed is currently static UI scaffolding, not wired to a real alert stream. Not started. diff --git a/src/pages/nearle/assistant/DoormileAI.css b/src/pages/nearle/assistant/DoormileAI.css index 4f533fd..722e2f4 100644 --- a/src/pages/nearle/assistant/DoormileAI.css +++ b/src/pages/nearle/assistant/DoormileAI.css @@ -44,7 +44,6 @@ --dai-duration: 240ms; --dai-ease: cubic-bezier(0.16, 1, 0.3, 1); } - /* -------------------------------------------------------------------------- Scrim — deliberately light. The dashboard underneath must stay readable; this is a layering cue, not a modal blackout. @@ -57,11 +56,9 @@ opacity: 0; transition: opacity var(--dai-duration) var(--dai-ease); } - .dai-scrim[data-open='true'] { opacity: 1; } - /* -------------------------------------------------------------------------- Panel -------------------------------------------------------------------------- */ @@ -87,16 +84,13 @@ transform var(--dai-duration) var(--dai-ease), opacity var(--dai-duration) var(--dai-ease); } - .dai-panel[data-open='true'] { transform: translateX(0); opacity: 1; } - .dai-panel:focus { outline: none; } - /* -------------------------------------------------------------------------- Header -------------------------------------------------------------------------- */ @@ -105,7 +99,6 @@ padding: 14px 12px 12px 14px; border-bottom: 1px solid var(--dai-border); } - .dai-root .dai-title { font-size: 15px; font-weight: 650; @@ -113,13 +106,11 @@ letter-spacing: -0.01em; color: var(--dai-text); } - .dai-root .dai-subtitle { font-size: 12px; line-height: 1.3; color: var(--dai-text-muted); } - /* The AI mark. A soft gradient orb — not a robot face. */ .dai-root .dai-spark { display: inline-flex; @@ -127,27 +118,34 @@ justify-content: center; flex: 0 0 auto; border-radius: 999px; - color: #ffffff; - background: linear-gradient(135deg, var(--dai-ai-from), var(--dai-ai-to)); - box-shadow: 0 0 0 3px var(--dai-ai-glow); + background: var(--dai-surface); + overflow: hidden; +} +/* The mark's own canvas is only 66.8% content — a third of every edge is + transparent padding, measured off the PNG's alpha bounding box (x and y both + 170..853 of 1024, i.e. perfectly centred). Drawing it at 100% therefore + rendered a D two-thirds the size the box implied, which is exactly why it + read as too small. 150% cancels that padding so the D fills its box edge to + edge; the overflow:hidden above clips only transparent pixels. */ +.dai-root .dai-spark img { + width: 150%; + height: 150%; + max-width: none; + object-fit: contain; + display: block; } - .dai-root .dai-spark[data-size='sm'] { - width: 22px; - height: 22px; -} - -.dai-root .dai-spark[data-size='md'] { width: 30px; height: 30px; } - -.dai-root .dai-spark[data-size='lg'] { - width: 44px; - height: 44px; - box-shadow: 0 0 0 6px var(--dai-ai-glow); +.dai-root .dai-spark[data-size='md'] { + width: 40px; + height: 40px; +} +.dai-root .dai-spark[data-size='lg'] { + width: 56px; + height: 56px; } - /* Live indicator — subtle, not a large pill. */ .dai-root .dai-live { display: inline-flex; @@ -161,7 +159,6 @@ background: rgba(16, 185, 129, 0.08); white-space: nowrap; } - .dai-root .dai-live-dot { width: 6px; height: 6px; @@ -169,7 +166,6 @@ background: var(--dai-live); animation: dai-pulse 2.4s ease-in-out infinite; } - @keyframes dai-pulse { 0%, 100% { @@ -181,7 +177,6 @@ transform: scale(0.85); } } - /* Page-context strip — "Orders · Today · All locations" */ .dai-root .dai-context { flex: 0 0 auto; @@ -194,7 +189,120 @@ overflow: hidden; text-overflow: ellipsis; } - +/* -------------------------------------------------------------------------- + Welcome state + -------------------------------------------------------------------------- */ +.dai-root .dai-welcome { + padding: 22px 14px 8px; +} +.dai-root .dai-welcome-greeting { + font-size: 17px; + font-weight: 650; + letter-spacing: -0.01em; + color: var(--dai-text); +} +.dai-root .dai-welcome-lead { + font-size: 13.5px; + line-height: 1.5; + color: var(--dai-text-secondary); +} +.dai-root .dai-welcome-note { + font-size: 12px; + line-height: 1.5; + color: var(--dai-text-muted); +} +.dai-root .dai-section-label { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--dai-text-muted); +} +/* -------------------------------------------------------------------------- + Suggestion cards + -------------------------------------------------------------------------- */ +.dai-suggestion { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 10px 11px; + text-align: left; + font: inherit; + color: var(--dai-text); + background: var(--dai-surface); + border: 1px solid var(--dai-border); + border-radius: 5px; + cursor: pointer; + transition: + background-color 140ms ease, + border-color 140ms ease, + transform 140ms ease; +} +.dai-suggestion:hover { + background: var(--dai-surface-alt); + border-color: var(--dai-border-strong); +} +.dai-suggestion:active { + transform: scale(0.99); +} +.dai-suggestion:focus-visible { + outline: 2px solid var(--dai-accent); + outline-offset: 2px; +} +.dai-root .dai-suggestion-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 26px; + height: 26px; + border-radius: 5px; + background: var(--dai-surface-hover); + color: var(--dai-text-secondary); +} +.dai-root .dai-suggestion-text { + flex: 1 1 auto; + font-size: 13px; + line-height: 1.35; +} +.dai-root .dai-suggestion-arrow { + flex: 0 0 auto; + color: var(--dai-text-muted); + opacity: 0; + transform: translateX(-3px); + transition: + opacity 140ms ease, + transform 140ms ease; +} +.dai-suggestion:hover .dai-suggestion-arrow, +.dai-suggestion:focus-visible .dai-suggestion-arrow { + opacity: 1; + transform: translateX(0); +} +/* Plain text link-button ("View more", "Sources") */ +.dai-link { + font: inherit; + font-size: 12px; + color: var(--dai-text-secondary); + background: none; + border: none; + padding: 2px 0; + cursor: pointer; + align-self: flex-start; +} +.dai-link:hover { + color: var(--dai-text); + text-decoration: underline; +} +.dai-link:focus-visible { + outline: 2px solid var(--dai-accent); + outline-offset: 2px; + border-radius: 5px; +} +/* -------------------------------------------------------------------------- + Conversation + -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- Scroll region -------------------------------------------------------------------------- */ @@ -225,141 +333,24 @@ background: transparent; } -/* -------------------------------------------------------------------------- - Welcome state - -------------------------------------------------------------------------- */ -.dai-root .dai-welcome { - padding: 22px 14px 8px; -} - -.dai-root .dai-welcome-greeting { - font-size: 17px; - font-weight: 650; - letter-spacing: -0.01em; - color: var(--dai-text); -} - -.dai-root .dai-welcome-lead { - font-size: 13.5px; - line-height: 1.5; - color: var(--dai-text-secondary); -} - -.dai-root .dai-welcome-note { - font-size: 12px; - line-height: 1.5; - color: var(--dai-text-muted); -} - -.dai-root .dai-section-label { - font-size: 11px; - font-weight: 600; - letter-spacing: 0.04em; - text-transform: uppercase; - color: var(--dai-text-muted); -} - -/* -------------------------------------------------------------------------- - Suggestion cards - -------------------------------------------------------------------------- */ -.dai-suggestion { - display: flex; - align-items: center; - gap: 10px; - width: 100%; - padding: 10px 11px; - text-align: left; - font: inherit; - color: var(--dai-text); - background: var(--dai-surface); - border: 1px solid var(--dai-border); - border-radius: 5px; - cursor: pointer; - transition: - background-color 140ms ease, - border-color 140ms ease, - transform 140ms ease; -} - -.dai-suggestion:hover { - background: var(--dai-surface-alt); - border-color: var(--dai-border-strong); -} - -.dai-suggestion:active { - transform: scale(0.99); -} - -.dai-suggestion:focus-visible { - outline: 2px solid var(--dai-accent); - outline-offset: 2px; -} - -.dai-root .dai-suggestion-icon { - display: inline-flex; - align-items: center; - justify-content: center; - flex: 0 0 auto; - width: 26px; - height: 26px; - border-radius: 5px; - background: var(--dai-surface-hover); - color: var(--dai-text-secondary); -} - -.dai-root .dai-suggestion-text { +.dai-scroll-wrap { + position: relative; flex: 1 1 auto; - font-size: 13px; - line-height: 1.35; + min-height: 0; + display: flex; + flex-direction: column; } -.dai-root .dai-suggestion-arrow { - flex: 0 0 auto; - color: var(--dai-text-muted); - opacity: 0; - transform: translateX(-3px); - transition: - opacity 140ms ease, - transform 140ms ease; -} - -.dai-suggestion:hover .dai-suggestion-arrow, -.dai-suggestion:focus-visible .dai-suggestion-arrow { - opacity: 1; - transform: translateX(0); -} - -/* Plain text link-button ("View more", "Sources") */ -.dai-link { - font: inherit; - font-size: 12px; - color: var(--dai-text-secondary); - background: none; - border: none; - padding: 2px 0; - cursor: pointer; - align-self: flex-start; -} - -.dai-link:hover { - color: var(--dai-text); - text-decoration: underline; -} - -.dai-link:focus-visible { - outline: 2px solid var(--dai-accent); - outline-offset: 2px; - border-radius: 5px; -} - -/* -------------------------------------------------------------------------- - Conversation - -------------------------------------------------------------------------- */ .dai-root .dai-thread { padding: 16px 14px 8px; } -.dai-root .dai-msg { +/* Only the newest turn animates in. Applying this to every .dai-msg meant + opening a panel with a restored thread started 50 simultaneous opacity + + transform animations, which is a visible hitch before the first scroll. The + base state IS the animation's end state, so a turn that stops being last + simply keeps its finished appearance. */ +.dai-root .dai-thread > .dai-msg:last-child { animation: dai-enter 220ms var(--dai-ease) both; } @@ -374,7 +365,6 @@ } } -/* User — compact, right aligned, brand surface. */ .dai-root .dai-msg-user { max-width: 82%; margin-left: auto; @@ -388,7 +378,6 @@ white-space: pre-wrap; overflow-wrap: anywhere; } - /* Assistant — no bubble. Text sits on the panel surface. */ .dai-root .dai-msg-ai { font-size: 13.5px; @@ -396,36 +385,30 @@ color: var(--dai-text); overflow-wrap: anywhere; } - .dai-root .dai-msg-ai-detail { font-size: 12.5px; line-height: 1.55; color: var(--dai-text-secondary); white-space: pre-line; } - .dai-root .dai-msg-name { font-size: 11.5px; font-weight: 600; color: var(--dai-text-secondary); } - .dai-root .dai-msg-footer { font-size: 11px; color: var(--dai-text-muted); } - /* Copy button — only revealed on hover of the message row. */ .dai-root .dai-msg-actions { opacity: 0; transition: opacity 140ms ease; } - .dai-root .dai-msg-row:hover .dai-msg-actions, .dai-root .dai-msg-row:focus-within .dai-msg-actions { opacity: 1; } - /* -------------------------------------------------------------------------- Structured metrics -------------------------------------------------------------------------- */ @@ -435,14 +418,12 @@ gap: 6px; width: 100%; } - .dai-root .dai-stat { padding: 9px 10px; border: 1px solid var(--dai-border); border-radius: 5px; background: var(--dai-surface-alt); } - .dai-root .dai-stat-value { font-size: 19px; font-weight: 650; @@ -450,12 +431,10 @@ letter-spacing: -0.02em; font-variant-numeric: tabular-nums; } - .dai-root .dai-stat-label { font-size: 11px; color: var(--dai-text-muted); } - /* Headline metric — the "primary number is large" rule. */ .dai-root .dai-metric-value { font-size: 28px; @@ -465,12 +444,10 @@ color: var(--dai-text); font-variant-numeric: tabular-nums; } - .dai-root .dai-metric-label { font-size: 12px; color: var(--dai-text-secondary); } - /* -------------------------------------------------------------------------- Typing / loading -------------------------------------------------------------------------- */ @@ -480,7 +457,6 @@ gap: 4px; height: 18px; } - .dai-root .dai-typing span { width: 5px; height: 5px; @@ -488,15 +464,12 @@ background: var(--dai-text-muted); animation: dai-bounce 1.3s ease-in-out infinite; } - .dai-root .dai-typing span:nth-child(2) { animation-delay: 0.16s; } - .dai-root .dai-typing span:nth-child(3) { animation-delay: 0.32s; } - @keyframes dai-bounce { 0%, 70%, @@ -509,7 +482,6 @@ transform: translateY(-3px); } } - .dai-root .dai-shimmer { height: 9px; border-radius: 999px; @@ -517,7 +489,6 @@ background-size: 400% 100%; animation: dai-shimmer 1.5s ease infinite; } - @keyframes dai-shimmer { from { background-position: 100% 50%; @@ -526,7 +497,6 @@ background-position: 0 50%; } } - /* -------------------------------------------------------------------------- States (error / empty) -------------------------------------------------------------------------- */ @@ -536,19 +506,16 @@ border-radius: 5px; background: var(--dai-surface-alt); } - .dai-root .dai-state-title { font-size: 13px; font-weight: 600; color: var(--dai-text); } - .dai-root .dai-state-body { font-size: 12.5px; line-height: 1.5; color: var(--dai-text-secondary); } - .dai-root .dai-state-icon { display: inline-flex; align-items: center; @@ -558,7 +525,6 @@ border-radius: 5px; flex: 0 0 auto; } - /* -------------------------------------------------------------------------- Jump-to-latest -------------------------------------------------------------------------- */ @@ -581,24 +547,13 @@ cursor: pointer; z-index: 2; } - .dai-jump:hover { background: var(--dai-surface-alt); } - .dai-jump:focus-visible { outline: 2px solid var(--dai-accent); outline-offset: 2px; } - -.dai-scroll-wrap { - position: relative; - flex: 1 1 auto; - min-height: 0; - display: flex; - flex-direction: column; -} - /* -------------------------------------------------------------------------- Composer -------------------------------------------------------------------------- */ @@ -608,7 +563,6 @@ border-top: 1px solid var(--dai-border); background: var(--dai-surface); } - .dai-root .dai-composer { border: 1px solid var(--dai-border-strong); border-radius: 5px; @@ -619,12 +573,10 @@ border-color 140ms ease, box-shadow 140ms ease; } - .dai-root .dai-composer[data-focused='true'] { border-color: var(--dai-accent); box-shadow: 0 0 0 3px rgba(15, 23, 42, 0.06); } - .dai-root .dai-composer textarea { display: block; width: 100%; @@ -641,51 +593,13 @@ max-height: 108px; /* ~5 lines */ overflow-y: auto; } - .dai-root .dai-composer textarea::placeholder { color: var(--dai-text-muted); } - -.dai-send { - display: inline-flex; - align-items: center; - justify-content: center; - flex: 0 0 auto; - width: 30px; - height: 30px; - border: none; - border-radius: 999px; - color: var(--dai-accent-contrast); - background: var(--dai-accent); - cursor: pointer; - transition: - opacity 140ms ease, - transform 140ms ease; -} - -.dai-send:hover:not(:disabled) { - opacity: 0.88; -} - -.dai-send:active:not(:disabled) { - transform: scale(0.93); -} - -.dai-send:disabled { - opacity: 0.28; - cursor: default; -} - -.dai-send:focus-visible { - outline: 2px solid var(--dai-accent); - outline-offset: 2px; -} - .dai-root .dai-hint { font-size: 11px; color: var(--dai-text-muted); } - /* -------------------------------------------------------------------------- Trigger (lives in the app TopNav) -------------------------------------------------------------------------- */ @@ -693,40 +607,44 @@ display: inline-flex; align-items: center; justify-content: center; - width: 32px; - height: 32px; + width: 40px; + height: 40px; padding: 0; border-radius: 5px; border: 1px solid transparent; background: transparent; - color: #4f46e5; cursor: pointer; + overflow: hidden; transition: background-color 140ms ease, border-color 140ms ease, transform 140ms ease; } - -.dai-trigger:hover { - background: rgba(99, 102, 241, 0.09); - border-color: rgba(99, 102, 241, 0.16); +/* 30px box × 1.5 cancels the mark's transparent padding, so the visible D is + 30px inside the 40px button. */ +.dai-trigger-mark { + width: 30px; + height: 30px; + max-width: none; + object-fit: contain; + display: block; + transform: scale(1.5); +} +.dai-trigger:hover { + background: rgba(0, 0, 0, 0.05); + border-color: rgba(0, 0, 0, 0.1); } - .dai-trigger:active { transform: scale(0.94); } - .dai-trigger:focus-visible { outline: 2px solid var(--dai-accent); outline-offset: 2px; } - .dai-trigger[data-active='true'] { - color: #ffffff; - background: linear-gradient(135deg, var(--dai-ai-from), var(--dai-ai-to)); - border-color: transparent; + background: rgba(0, 0, 0, 0.07); + border-color: rgba(0, 0, 0, 0.14); } - /* -------------------------------------------------------------------------- Responsive -------------------------------------------------------------------------- */ @@ -735,24 +653,17 @@ --dai-panel-width: 396px; } } - @media (max-width: 767px) { .dai-root { --dai-inset: 0px; } - .dai-panel { width: 100vw; max-width: 100vw; border-radius: 0; border: none; } - - .dai-msg-user { - max-width: 88%; - } } - /* -------------------------------------------------------------------------- Reduced motion — no slide, no pulse, no shimmer. Opacity only. -------------------------------------------------------------------------- */ @@ -761,11 +672,9 @@ .dai-scrim { transition: opacity 1ms linear; } - .dai-panel { transform: none; } - .dai-msg, .dai-live-dot, .dai-typing span, @@ -777,7 +686,6 @@ transition: none !important; } } - /* -------------------------------------------------------------------------- Write-action confirm card -------------------------------------------------------------------------- */ @@ -787,7 +695,6 @@ border-radius: 5px; background: var(--dai-surface-alt); } - .dai-root .dai-action-icon { display: inline-flex; align-items: center; @@ -799,28 +706,22 @@ color: #4f46e5; background: rgba(99, 102, 241, 0.1); } - .dai-root .dai-action-summary { font-size: 13px; line-height: 1.35; font-weight: 550; color: var(--dai-text); } - .dai-root .dai-action-note { font-size: 12px; color: var(--dai-text-secondary); } - .dai-root .dai-ok { color: #047857; } - .dai-root .dai-err { color: #b91c1c; } - - /* -------------------------------------------------------------------------- Overflow containment -------------------------------------------------------------------------- @@ -838,7 +739,6 @@ min-width: 0; max-width: 100%; } - /* Two fields per row that genuinely share the width. `flex-wrap` is the belt-and-braces part: if a field ever can't compress far enough (a long label, a narrower panel), the row drops to two lines instead of pushing the @@ -850,12 +750,10 @@ width: 100%; min-width: 0; } - .dai-root .dai-form-row > * { flex: 1 1 140px; min-width: 0; } - /* Astryx's TextInput sizes itself to content unless told otherwise. */ .dai-root .dai-form input, .dai-root .dai-form-row input { @@ -863,34 +761,62 @@ min-width: 0; box-sizing: border-box; } - .dai-root .dai-field-err { font-size: 11.5px; line-height: 1.4; color: #b91c1c; } - - /* -------------------------------------------------------------------------- Source footer + full lists -------------------------------------------------------------------------- */ -/* The footer is a baseline-aligned row, not a centred one: the timestamp and - the Sources toggle are both small text and must sit on the same line. */ +/* Timestamp left, Sources toggle hard right, both on ONE line. It was a + wrapping baseline row, and the toggle — a shrinkable flex item with inline + content — gave up its chevron to a second line whenever the panel got + narrow, which read as a stray arrow floating under the text. */ .dai-root .dai-msg-foot { display: flex; - align-items: baseline; - flex-wrap: wrap; - gap: 8px; + align-items: center; + flex-wrap: nowrap; + gap: 10px; width: 100%; min-width: 0; } - +/* The timestamp is the only part allowed to give up space, and it ellipsises + rather than wrapping. */ .dai-root .dai-msg-foot .dai-msg-footer { - flex: 0 1 auto; + flex: 1 1 auto; min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +/* The toggle is one atom: label and chevron cannot be separated. */ +.dai-root .dai-msg-foot .dai-sources { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + gap: 4px; + align-self: center; + white-space: nowrap; + padding: 3px 7px; + margin-right: -7px; + border-radius: 5px; + color: var(--dai-text-muted); + transition: background 140ms ease, color 140ms ease; +} +.dai-root .dai-msg-foot .dai-sources:hover { + background: var(--dai-surface-hover); + color: var(--dai-text); + text-decoration: none; +} +.dai-root .dai-msg-foot .dai-sources svg { + flex: 0 0 auto; + transition: transform 160ms var(--dai-ease); +} +.dai-root .dai-msg-foot .dai-sources[aria-expanded='true'] svg { + transform: rotate(180deg); } - /* A full, readable list instead of "…and 4 more". Scrolls past ~12 rows so a long list can never push the panel's height around. */ .dai-root .dai-list { @@ -901,7 +827,6 @@ background: var(--dai-surface-alt); overflow: hidden; } - .dai-root .dai-list-head { display: flex; align-items: baseline; @@ -915,14 +840,12 @@ text-transform: uppercase; color: var(--dai-text-muted); } - .dai-root .dai-list-body { max-height: 260px; overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; } - .dai-root .dai-list-item { display: flex; flex-wrap: wrap; @@ -934,11 +857,9 @@ color: var(--dai-text); border-top: 1px solid var(--dai-divider, rgba(15, 23, 42, 0.05)); } - .dai-root .dai-list-item:first-child { border-top: none; } - .dai-root .dai-list-index { flex: 0 0 auto; min-width: 18px; @@ -946,7 +867,6 @@ font-variant-numeric: tabular-nums; color: var(--dai-text-muted); } - /* The label must not be crushed to zero width by a long value — that is what made "address" render one letter per line. It sizes to its content and the value takes the remaining space, wrapping onto its own full-width line when @@ -956,7 +876,6 @@ min-width: 0; overflow-wrap: anywhere; } - .dai-root .dai-list-meta { flex: 1 1 auto; min-width: 0; @@ -965,10 +884,36 @@ color: var(--dai-text-secondary); overflow-wrap: anywhere; } - /* A value that wrapped to its own line reads better left-aligned under its label than pinned to the right edge. */ .dai-root .dai-list-item > .dai-list-meta:only-child, .dai-root .dai-list-meta[data-wrapped='true'] { text-align: left; } +/* -------------------------------------------------------------------------- + Documentation answers + -------------------------------------------------------------------------- + Retrieved passages are shown VERBATIM with their source file. They are prose + rather than a figure, so they get a quieter, wider treatment than a metric — + and the source line matters as much as the text, because the operator needs + to know this came from a document, not from live data. + -------------------------------------------------------------------------- */ +.dai-root .dai-doc { + padding: 10px 11px; + border: 1px solid var(--dai-border); + border-left: 2px solid var(--dai-ai-from); + border-radius: 5px; + background: var(--dai-surface-alt); + font-size: 12.5px; + line-height: 1.55; + color: var(--dai-text); + white-space: pre-line; + overflow-wrap: anywhere; + max-height: 320px; + overflow-y: auto; +} +.dai-root .dai-doc-source { + font-size: 11px; + color: var(--dai-text-muted); + font-variant-numeric: tabular-nums; +} diff --git a/src/pages/nearle/assistant/DoormileAI/AICustomerForm.js b/src/pages/nearle/assistant/DoormileAI/AICustomerForm.js deleted file mode 100644 index 0888f29..0000000 --- a/src/pages/nearle/assistant/DoormileAI/AICustomerForm.js +++ /dev/null @@ -1,193 +0,0 @@ -import { useState } from 'react'; -import PropTypes from 'prop-types'; - -import { VStack } from '@astryxdesign/core/VStack'; -import { HStack } from '@astryxdesign/core/HStack'; -import { Text } from '@astryxdesign/core/Text'; -import { Button } from '@astryxdesign/core/Button'; -import { TextInput } from '@astryxdesign/core/TextInput'; - -import AddressAutocomplete from 'components/nearle_components/AddressAutocomplete'; - -// ==============================|| Doormile AI — create-customer form ||============================== // -// -// Mirrors pages/nearle/clients/createCustomer.js field-for-field, including -// the address block, so the assistant asks for the same things the page does. -// -// Two corrections came out of a real GET /admin/customers response: -// -// • NO TENANT. The documented POST /admin/tenantcustomers body is -// { firstname, lastname, phone, email } and a customer record carries no -// tenantid at all. The tenant field this form used to show was speculative -// and has been removed. -// • The record DOES carry address, doorno, landmark, suburb, city, state, -// postcode, latitude and longitude — they exist on the model, contrary to -// the "no address field exists" comments in customers.js and -// createCustomer.js. They come back empty on every live record, and the -// documented create body doesn't list them, so they are sent here on a -// best-effort basis: if the server ignores them nothing breaks, and the -// first real create will settle whether they persist. -// -// The form IS the write gate. Every value that will be sent is visible and -// editable, and nothing is submitted until Create is pressed. - -const PHONE_RE = /^\d{10}$/; - -// Nominatim results are shaped like Google Places by AddressAutocomplete's -// `toPlace`, so this reads the same component types createCustomer.js reads. -const fromPlace = (place) => { - const out = { latitude: place.geometry?.location?.lat?.(), longitude: place.geometry?.location?.lng?.() }; - (place.address_components || []).forEach((c) => { - const types = c.types || []; - if (types.includes('sublocality_level_1') || types.includes('sublocality')) out.suburb = c.long_name; - if (types.includes('locality')) out.city = c.long_name; - if (types.includes('administrative_area_level_1')) out.state = c.long_name; - if (types.includes('postal_code')) out.postcode = c.long_name; - }); - return out; -}; - -const AICustomerForm = ({ initial, status, error, onSubmit, onCancel }) => { - const [values, setValues] = useState({ - firstname: initial?.firstname || '', - lastname: initial?.lastname || '', - phone: initial?.phone || '', - email: initial?.email || '', - address: '', - doorno: '', - landmark: '', - suburb: '', - city: '', - state: '', - postcode: '', - latitude: undefined, - longitude: undefined - }); - const [touched, setTouched] = useState({}); - - const set = (key) => (v) => setValues((prev) => ({ ...prev, [key]: v })); - const blur = (key) => () => setTouched((prev) => ({ ...prev, [key]: true })); - - const errors = { - firstname: values.firstname.trim() ? null : 'Required', - phone: PHONE_RE.test(values.phone.trim()) ? null : 'Enter exactly 10 digits' - }; - const isValid = !errors.firstname && !errors.phone; - const isDone = status === 'done'; - const isBusy = status === 'running'; - const isLocked = isDone || status === 'cancelled' || status === 'stale'; - - if (isLocked) { - return ( - - - {[values.firstname, values.lastname].filter(Boolean).join(' ')} · {values.phone} - - - {isDone ? '✓ Created' : status === 'cancelled' ? 'Cancelled — nothing was sent.' : 'Expired — ask again to recreate this.'} - - - ); - } - - return ( - - - - - - {touched.firstname && errors.firstname && First name — {errors.firstname}} - - - {touched.phone && errors.phone && Mobile — {errors.phone}} - - - - {/* Address search fills suburb / city / state / postcode and the - coordinates in one go, the same way the create-customer page does. */} - - setValues((prev) => ({ ...prev, address: place.formatted_address || prev.address, ...fromPlace(place) })) - } - disabled={isBusy} - /> - - - - - - - - - - - - - - - - - - Name and mobile are required. The create endpoint documents only name, phone and email — the address is sent too, but may not be - stored. - - - {status === 'failed' && error && {error}} - - - {showSources && } @@ -100,25 +109,75 @@ const AssistantMessage = ({ message, onCopy, onAsk, onSubmitForm, onCancelAction localStorage in a later session is marked 'stale' by the panel and renders read-only, so a write can never fire from a resurrected message. */} - {message.form?.kind === 'createOrder' && ( - onSubmitForm(message, values)} + {/* A conversational step that needs a dropdown rather than typing. */} + {message.liveStop && ( + +