Skip to content

Latest commit

 

History

History
315 lines (254 loc) · 13 KB

File metadata and controls

315 lines (254 loc) · 13 KB

Contributing to mtchat

Thanks for picking this up. mtchat is a small, opinionated project — Deno + Hono on the server, React in the browser, deterministic Q&A retrieval. The plan lives in ROADMAP.md. This file is the practical guide for working in the codebase.

Quick start

git clone https://github.com/datasketch/mtchat.git
cd mtchat
deno task dev

The server boots on http://localhost:8519 (a deliberately uncommon port — won't collide with React/Vite/Hono defaults). Hot reload is on via --watch. The first run downloads the npm transitive dependencies (@datasketch/monkeytab, @assistant-ui/react, and their deps via Radix); subsequent runs are instant.

Open in another terminal:

curl http://localhost:8519/api/qa
curl -X POST http://localhost:8519/api/chat \
  -H "content-type: application/json" \
  -d '{"query":"What is mtchat?"}'

What's already built (Phase 0)

Before you write a line of code, run the project once and skim these files in order. They're small.

File What's there Why it matters
deno.json Tasks, imports map, JSX config The imports map already declares @datasketch/monkeytab, @assistant-ui/react, react, react-dom, and esbuild — you don't need to add anything to start Phase 1
main.tsx Hono app, route mounting, store init, server bind Single entry point. Top-level await store.replaceAll(seedEntries) runs once at boot
lib/store.ts QAStore interface + InMemoryQAStore Phase 4 swaps in JsonFileQAStore and SqliteQAStore behind the same interface — don't break the interface
lib/retrieval.ts Jaccard token-overlap match() with threshold Used by /api/chat. Tunable via threshold and topN options. Phase 6 may replace it with FTS5 or embeddings
lib/seed.ts 10 demo Q&A entries First-run UX. Replace freely once persistence lands
routes/api/qa.ts CRUD endpoints Thin wrappers around the store
routes/api/chat.ts Chat endpoint Calls match(), returns { answer, confidence, source, matched?, suggestions? }
views/Layout.tsx HTML shell, top nav, inline base CSS Server-rendered with hono/jsx precompile. Both pages wrap in this
views/AdminShell.tsx /admin placeholder + <div id="admin-root"> slot Phase 1 mounts the React MonkeyTable into #admin-root
views/ChatShell.tsx /chat placeholder + <div id="chat-root"> slot Phase 2 mounts the assistant-ui Thread into #chat-root

Library guidance

@datasketch/monkeytab — admin table (Phase 1)

The admin page uses MonkeyTab as the editable Q&A grid. It's an embeddable React component that handles cell editing, sorting, filtering, virtualization, and keyboard navigation out of the box. You configure it with columns and rows props.

Install: already in deno.json imports map as npm:@datasketch/monkeytab@^0.2.0. No npm install step — Deno fetches it on first import.

Docs: https://monkeytab.app/docs — read the Get started guide and Reference / overview before you write client/admin.tsx.

Pattern for Phase 1 (client/admin.tsx sketch):

import { createRoot } from 'react-dom/client';
import { MonkeyTable } from '@datasketch/monkeytab';
import { useEffect, useState } from 'react';

interface QAEntry {
  id: string;
  question: string;
  answer: string;
  tags: string[];
  createdAt: string;
  updatedAt: string;
}

function App() {
  const [rows, setRows] = useState<QAEntry[]>([]);

  // Initial fetch
  useEffect(() => {
    fetch('/api/qa')
      .then((r) => r.json())
      .then((data) => setRows(data.entries));
  }, []);

  return (
    <MonkeyTable
      columns={[
        { id: 'question', label: 'Question', type: 'Text', options: { multiline: true } },
        { id: 'answer', label: 'Answer', type: 'Text', options: { multiline: true } },
        {
          id: 'tags',
          label: 'Tags',
          type: 'MultiSelect',
          options: {
            options: [], // populated dynamically from existing tags
          },
        },
        { id: 'updatedAt', label: 'Updated', type: 'Date', editable: false },
      ]}
      rows={rows}
      onCellChange={async (rowId, fieldId, newValue) => {
        // PATCH the single field that changed
        const res = await fetch(`/api/qa/${rowId}`, {
          method: 'PATCH',
          headers: { 'content-type': 'application/json' },
          body: JSON.stringify({ [fieldId]: newValue }),
        });
        const { entry } = await res.json();
        setRows((prev) => prev.map((r) => (r.id === rowId ? entry : r)));
      }}
      height='100%'
    />
  );
}

const root = createRoot(document.getElementById('admin-root')!);
root.render(<App />);

Notes:

  • Use onCellChange (per-cell granular update via PATCH) rather than onChange (full row diff) so the API stays simple.
  • The tags MultiSelect needs its options populated from the union of existing tags across rows. Compute it once on initial fetch.
  • @datasketch/monkeytab ships its own minimal CSS — import it from the package, or override with your own styles.
  • Read the Per-column control section of the docs for editable, width, sortable, align per column.

@assistant-ui/react — chat UI (Phase 2)

Headless React primitives for AI chat: <Thread>, <Composer>, <Message>, etc. You compose them yourself, you bring your own runtime adapter to call your backend.

Install: already in deno.json imports map as npm:@assistant-ui/react@^0.7.0. The Radix UI deps are pulled in transitively.

Docs: https://www.assistant-ui.com/docs — read Getting started and Concepts / Runtimes before you write client/chat.tsx. The "BYO Backend" runtime adapter pattern is what we want.

Pattern for Phase 2 (client/chat.tsx sketch):

import { createRoot } from 'react-dom/client';
import {
  AssistantRuntimeProvider,
  type ChatModelAdapter,
  useLocalRuntime,
} from '@assistant-ui/react';
import { Thread } from '@assistant-ui/react';

const MtchatModelAdapter: ChatModelAdapter = {
  async run({ messages, abortSignal }) {
    const lastUser = messages.findLast((m) => m.role === 'user');
    const query = lastUser?.content
      .filter((p) => p.type === 'text')
      .map((p) => p.text)
      .join(' ') ?? '';

    const res = await fetch('/api/chat', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({
        query,
        history: messages.slice(0, -1).map((m) => ({
          role: m.role,
          content: m.content
            .filter((p) => p.type === 'text')
            .map((p) => p.text)
            .join(' '),
        })),
      }),
      signal: abortSignal,
    });

    const data = await res.json();
    return {
      content: [{ type: 'text', text: data.answer }],
    };
  },
};

function App() {
  const runtime = useLocalRuntime(MtchatModelAdapter);
  return (
    <AssistantRuntimeProvider runtime={runtime}>
      <Thread />
    </AssistantRuntimeProvider>
  );
}

const root = createRoot(document.getElementById('chat-root')!);
root.render(<App />);

Notes:

  • Phase 2 doesn't need streaming — the deterministic backend returns the answer in one shot. Phase 3 (LLM rephrasing) is when streaming becomes useful, and assistant-ui supports it via async iterables in the runtime adapter.
  • When data.source === 'suggestions', surface the data.suggestions as clickable chips below the message. Look at data.matched to optionally show "Matched: original question" footer like a citation.
  • assistant-ui's Thread component has primitive sub-components you can compose to control the look — don't just drop the default Thread and call it done. Style it to match the rest of mtchat.
  • Use the useLocalRuntime hook (manages messages in React state) for v1. The other runtime types are for streaming-from-backend setups we don't need yet.

esbuild — bundling client code (Phase 1)

client/admin.tsx and client/chat.tsx are React files that need to be bundled into single ES modules the browser can load. Use esbuild via deno.land/x/esbuild (already in the imports map).

Pattern (scripts/build-client.ts):

import * as esbuild from 'esbuild';

await esbuild.build({
  entryPoints: ['client/admin.tsx', 'client/chat.tsx'],
  bundle: true,
  format: 'esm',
  outdir: 'static',
  jsx: 'automatic',
  jsxImportSource: 'react',
  define: { 'process.env.NODE_ENV': '"production"' },
  loader: { '.tsx': 'tsx', '.ts': 'ts' },
});

esbuild.stop();

Add tasks to deno.json:

"build:client": "deno run --allow-read --allow-write --allow-env --allow-run --allow-net scripts/build-client.ts",
"dev": "deno task build:client && deno run --allow-net --allow-read --allow-env --watch main.tsx"

Then update views/AdminShell.tsx and views/ChatShell.tsx to load the bundles:

<script type='module' src='/static/admin.js'></script>;

And add a route in main.tsx to serve files from the static/ directory:

app.get('/static/:file', async (c) => {
  const file = c.req.param('file');
  try {
    const content = await Deno.readFile(`./static/${file}`);
    const ext = file.split('.').pop();
    const type = ext === 'js' ? 'application/javascript' : 'text/plain';
    return new Response(content, { headers: { 'content-type': type } });
  } catch {
    return c.notFound();
  }
});

(There's a placeholder route stub in main.tsx that you'll replace.)

Conventions

  • Port: 8519. Hardcoded in main.tsx as the default. Override with PORT=... env var.
  • Format: deno fmt — config in deno.json (single quotes, 100-char lines, 2-space indent, semicolons).
  • Lint: deno lint.
  • Imports: use the imports map (hono, @datasketch/monkeytab, etc.), not full URLs in source files.
  • Types: every function gets a return type. Every interface lives next to its primary user.
  • Errors: API endpoints return { error: 'snake_case_code', detail?: string } with appropriate HTTP status. See routes/api/qa.ts for the pattern.
  • No mocks in tests: use the real InMemoryQAStore, not a stub. The interface is small enough that mocking buys nothing.
  • One purpose per file: lib/store.ts is storage, lib/retrieval.ts is matching, lib/seed.ts is data. Don't bundle.
  • JSX: server JSX uses hono/jsx (precompile). Browser JSX uses React's react-jsx automatic runtime via esbuild.

Running tests

deno task test

Phase 0 doesn't ship tests yet. The first tests should land alongside Phase 2 — tests/retrieval.test.ts for the matching logic, tests/store.test.ts for the CRUD invariants. Use @std/assert (already in the imports map).

Submitting changes

Standard GitHub flow:

  1. Fork, branch, commit, PR.
  2. Run deno fmt and deno lint before pushing.
  3. Run deno task test if your change touches anything tested.
  4. Run deno task dev and smoke-test the routes you touched.
  5. PR description should explain why, not just what.

For larger changes (anything that touches the QAStore interface, the API surface, or pulls in new dependencies), open an issue first to discuss.

Picking up the roadmap

ROADMAP.md is the source of truth for what to build next. Phase 0 is done. Phase 1 (Admin page with @datasketch/monkeytab) is the next thing. The "Done when" criteria at the bottom of each phase tell you when you've actually finished it — don't claim a phase is done until those bullets all hold.

If you have questions about a phase that the roadmap doesn't answer, that's a sign the roadmap needs updating — open an issue.