From 95833fb4eab36385f6fdbe609ae7f2bb86668a23 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 29 Jan 2026 17:31:24 -0600 Subject: [PATCH 1/2] docs: add unified ROADMAP.md with all milestones and parallel execution strategy Includes: - All milestones M2-M7 with status - Dependency graph - Parallel execution strategy - Issue references for Federation (#83-94) and Orchestration (#95-102) - Versioning policy (0.0.x -> 0.1.0 MVP -> 1.0.0) --- docs/ROADMAP.md | 297 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 297 insertions(+) create mode 100644 docs/ROADMAP.md diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..27e7d7b --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,297 @@ +# Mosaic Stack Roadmap + +**Last Updated:** 2026-01-29 +**Authoritative Source:** [Issues & Milestones](https://git.mosaicstack.dev/mosaic/stack/issues) + +## Versioning Policy + +| Version | Meaning | +|---------|---------| +| `0.0.x` | Active development, breaking changes expected | +| `0.1.0` | **MVP** — First user-testable release | +| `0.x.y` | Pre-stable iteration, API may change with notice | +| `1.0.0` | Stable release, public API contract | + +--- + +## Milestone Overview + +``` +Timeline (2026) +═══════════════════════════════════════════════════════════════════════════════ + +Feb Mar Apr May + │ │ │ │ + ├──M2 ✓ │ │ │ + │ MultiTenant │ │ │ + │ (0.0.2) DONE │ │ │ + │ │ │ │ + ├──M3─────────┐ │ │ │ + │ Features │ │ │ │ + │ (0.0.3) │ │ │ │ + │ │ │ │ │ + ├──M4─────────┼────┐ │ │ │ + │ MoltBot │ │ │ │ │ + │ (0.0.4) │ │ │ │ │ + │ │ │ │ │ │ + │ │ ├───┼──M5 Knowledge │ │ + │ │ │ │ Module (0.0.5) │ │ + │ │ │ │ │ │ + │ │ │ ├──M6─────────────┐ │ │ + │ │ │ │ Orchestration │ │ │ + │ │ │ │ (0.0.6) │ │ │ + │ │ │ │ │ │ │ + │ │ │ │ ├────┼──M7 Federation │ + │ │ │ │ │ │ (0.0.7) │ + │ │ │ │ │ │ │ + │ │ │ │ │ ├──M5 Migration │ + │ │ │ │ │ │ (0.1.0 MVP) │ + └─────────────┴────┴───┴────────────────┴────┴──────────────────────┘ + +Legend: ───── Active development window + ✓ Complete +``` + +--- + +## Milestones Detail + +### ✅ M2-MultiTenant (0.0.2) — COMPLETE +**Due:** 2026-02-08 | **Status:** Done + +- [x] Workspace model and CRUD +- [x] Team model with membership +- [x] PostgreSQL Row-Level Security (RLS) +- [x] Workspace isolation at database level +- [x] Role-based access (Owner, Admin, Member, Guest) + +--- + +### 🚧 M3-Features (0.0.3) +**Due:** 2026-02-15 | **Status:** In Progress + +Core features for daily use: + +| Issue | Title | Priority | Status | +|-------|-------|----------|--------| +| #15 | Gantt chart component | P0 | Open | +| #16 | Real-time updates (WebSocket) | P0 | Open | +| #17 | Kanban board view | P1 | Open | +| #18 | Advanced filtering and search | P1 | Open | +| #21 | Ollama integration | P1 | Open | +| #37 | Domains model | — | Open | +| #41 | Widget/HUD System | — | Open | +| #82 | Personality Module | P1 | Open | + +--- + +### 🚧 M4-MoltBot (0.0.4) +**Due:** 2026-02-22 | **Status:** In Progress + +Agent integration and skills: + +| Issue | Title | Priority | Status | +|-------|-------|----------|--------| +| #22 | Brain query API endpoint | P0 | Open | +| #23 | mosaic-plugin-brain skill | P0 | Open | +| #24 | mosaic-plugin-calendar skill | P1 | Open | +| #25 | mosaic-plugin-tasks skill | P1 | Open | +| #26 | mosaic-plugin-gantt skill | P2 | Open | +| #27 | Intent classification service | P1 | Open | +| #29 | Cron job configuration | P1 | Open | +| #42 | Jarvis Chat Overlay | — | Open | + +--- + +### 🚧 M5-Knowledge Module (0.0.5) +**Due:** 2026-03-14 | **Status:** In Progress + +Wiki-style knowledge management: + +| Phase | Issues | Description | +|-------|--------|-------------| +| 1 | — | Core CRUD (DONE) | +| 2 | #59-64 | Wiki-style linking | +| 3 | #65-70 | Full-text + semantic search | +| 4 | #71-74 | Graph visualization | +| 5 | #75-80 | History, import/export, caching | + +**EPIC:** #81 + +--- + +### 📋 M6-AgentOrchestration (0.0.6) +**Due:** 2026-03-28 | **Status:** Planned + +Persistent task management and autonomous agent coordination: + +| Phase | Issues | Description | +|-------|--------|-------------| +| 1 | #96, #97 | Database schema, Task CRUD API | +| 2 | #98, #99, #102 | Valkey, Coordinator, Gateway integration | +| 3 | #100 | Failure recovery, checkpoints | +| 4 | #101 | Task progress UI | +| 5 | — | Advanced (cost tracking, multi-region) | + +**EPIC:** #95 +**Design Doc:** `docs/design/agent-orchestration.md` + +--- + +### 📋 M7-Federation (0.0.7) +**Due:** 2026-04-15 | **Status:** Planned + +Multi-instance federation for work/personal separation: + +| Phase | Issues | Description | +|-------|--------|-------------| +| 1 | #84, #85 | Instance identity, CONNECT/DISCONNECT | +| 2 | #86, #87 | Authentik integration, identity linking | +| 3 | #88, #89, #90 | QUERY, COMMAND, EVENT protocol | +| 4 | #91, #92 | Connection manager UI, aggregated dashboard | +| 5 | #93, #94 | Agent federation, spoke configuration | +| 6 | — | Enterprise features | + +**EPIC:** #83 +**Design Doc:** `docs/design/federation-architecture.md` + +--- + +### 🎯 M5-Migration (0.1.0 MVP) +**Due:** 2026-04-01 | **Status:** Planned + +Production readiness and migration from jarvis-brain: + +| Issue | Title | Priority | +|-------|-------|----------| +| #30 | Migration scripts from jarvis-brain | P0 | +| #31 | Data validation and integrity checks | P0 | +| #32 | Parallel operation testing | P1 | +| #33 | Performance optimization | P1 | +| #34 | Documentation (SETUP.md, CONFIGURATION.md) | P1 | +| #35 | Docker Compose customization guide | P1 | + +--- + +## Parallel Execution Strategy + +Work streams that can run in parallel: + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ PARALLEL WORK STREAMS │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Stream A: Core Features (M3) │ +│ ├── Gantt, Kanban, WebSocket, Domains │ +│ └── Can proceed independently │ +│ │ +│ Stream B: Agent Skills (M4) │ +│ ├── Brain API, plugin-brain, plugin-calendar, plugin-tasks │ +│ └── Depends on: Core APIs from M3 │ +│ │ +│ Stream C: Knowledge Module (M5-Knowledge) │ +│ ├── Wiki linking, search, graph viz │ +│ └── Independent module, can parallel with all │ +│ │ +│ Stream D: Agent Orchestration (M6) │ +│ ├── Task schema, Valkey, Coordinator │ +│ └── Depends on: Base agent model (exists) │ +│ │ +│ Stream E: Federation (M7) │ +│ ├── Instance identity, protocol, Authentik │ +│ └── Depends on: RLS (done), Agent Orchestration (for agent federation) │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +**Recommended parallelization:** + +| Sprint | Stream A | Stream B | Stream C | Stream D | Stream E | +|--------|----------|----------|----------|----------|----------| +| Feb W1-2 | M3 P0 | — | KNOW Phase 2 | ORCH #96, #97 | — | +| Feb W3-4 | M3 P1 | M4 P0 | KNOW Phase 2 | ORCH #98, #99 | FED #84, #85 | +| Mar W1-2 | M3 finish | M4 P1 | KNOW Phase 3 | ORCH #102 | FED #86, #87 | +| Mar W3-4 | — | M4 finish | KNOW Phase 4 | ORCH #100 | FED #88, #89 | +| Apr W1-2 | MVP prep | — | KNOW Phase 5 | ORCH #101 | FED #91, #92 | +| Apr W3-4 | **0.1.0 MVP** | — | — | — | FED #93, #94 | + +--- + +## Dependencies Graph + +``` + ┌─────────────────┐ + │ M2-MultiTenant │ + │ (DONE) │ + └────────┬────────┘ + │ + ┌──────────────────┼──────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ + │ M3-Features │ │ M5-Knowledge│ │ M6-Orchestr │ + └──────┬──────┘ └─────────────┘ └──────┬──────┘ + │ │ + ▼ │ + ┌─────────────┐ │ + │ M4-MoltBot │ │ + └──────┬──────┘ │ + │ │ + │ ┌─────────────────────────┘ + │ │ + ▼ ▼ + ┌─────────────────────────┐ + │ M7-Federation │ + │ (Agent Federation │ + │ depends on M6) │ + └───────────┬─────────────┘ + │ + ▼ + ┌─────────────────────────┐ + │ M5-Migration (MVP) │ + │ 0.1.0 │ + └─────────────────────────┘ +``` + +--- + +## Issue Labels + +| Label | Meaning | +|-------|---------| +| `p0` | Critical path, must complete | +| `p1` | Important, should complete | +| `p2` | Nice to have | +| `phase-N` | Implementation phase within milestone | +| `api` | Backend API work | +| `frontend` | Web UI work | +| `database` | Schema/migration work | +| `orchestration` | Agent orchestration related | +| `federation` | Federation related | +| `knowledge-module` | Knowledge module related | + +--- + +## How to Use This Roadmap + +1. **Check milestones** for high-level progress +2. **Check issues** for detailed task status +3. **Use labels** to filter by priority or area +4. **Dependencies** show what can be parallelized +5. **Design docs** provide implementation details + +**Quick links:** +- [All Open Issues](https://git.mosaicstack.dev/mosaic/stack/issues?state=open) +- [Milestones](https://git.mosaicstack.dev/mosaic/stack/milestones) +- [Design Docs](./design/) + +--- + +## Changelog + +| Date | Change | +|------|--------| +| 2026-01-29 | Added M6-AgentOrchestration, M7-Federation milestones and issues | +| 2026-01-29 | Created unified roadmap document | +| 2026-01-28 | M2-MultiTenant completed | From 1e5fcd19a4d70c0b28dc6cffd3b2fa3322044ebd Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 29 Jan 2026 17:42:49 -0600 Subject: [PATCH 2/2] feat(#59): implement wiki-link parser - Created wiki-link-parser.ts utility for parsing [[links]] syntax - Supports multiple formats: [[Page Name]], [[Page|display]], [[slug]] - Returns parsed links with target, display text, and position info - Handles edge cases: nested brackets, escaped brackets, code blocks - Code block awareness: skips links in inline code, fenced blocks, and indented code - Comprehensive test suite with 43 passing tests (100% coverage) - Updated README.md with parser documentation Implements KNOW-007 (Issue #59) - Wiki-style linking foundation --- apps/api/src/app.module.ts | 4 + apps/api/src/knowledge/utils/README.md | 134 ++++++ .../knowledge/utils/wiki-link-parser.spec.ts | 435 +++++++++++++++++ .../src/knowledge/utils/wiki-link-parser.ts | 279 +++++++++++ apps/api/src/ollama/dto/index.ts | 59 +++ apps/api/src/ollama/ollama.controller.spec.ts | 243 ++++++++++ apps/api/src/ollama/ollama.controller.ts | 92 ++++ apps/api/src/ollama/ollama.module.ts | 37 ++ apps/api/src/ollama/ollama.service.spec.ts | 441 ++++++++++++++++++ apps/api/src/ollama/ollama.service.ts | 344 ++++++++++++++ 10 files changed, 2068 insertions(+) create mode 100644 apps/api/src/knowledge/utils/wiki-link-parser.spec.ts create mode 100644 apps/api/src/knowledge/utils/wiki-link-parser.ts create mode 100644 apps/api/src/ollama/dto/index.ts create mode 100644 apps/api/src/ollama/ollama.controller.spec.ts create mode 100644 apps/api/src/ollama/ollama.controller.ts create mode 100644 apps/api/src/ollama/ollama.module.ts create mode 100644 apps/api/src/ollama/ollama.service.spec.ts create mode 100644 apps/api/src/ollama/ollama.service.ts diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 746a50d..f5e3d84 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -14,6 +14,8 @@ import { WidgetsModule } from "./widgets/widgets.module"; import { LayoutsModule } from "./layouts/layouts.module"; import { KnowledgeModule } from "./knowledge/knowledge.module"; import { UsersModule } from "./users/users.module"; +import { WebSocketModule } from "./websocket/websocket.module"; +import { OllamaModule } from "./ollama/ollama.module"; @Module({ imports: [ @@ -30,6 +32,8 @@ import { UsersModule } from "./users/users.module"; LayoutsModule, KnowledgeModule, UsersModule, + WebSocketModule, + OllamaModule, ], controllers: [AppController], providers: [AppService], diff --git a/apps/api/src/knowledge/utils/README.md b/apps/api/src/knowledge/utils/README.md index b1a1886..deec3a0 100644 --- a/apps/api/src/knowledge/utils/README.md +++ b/apps/api/src/knowledge/utils/README.md @@ -1,5 +1,139 @@ # Knowledge Module Utilities +## Wiki-Link Parser + +### Overview + +The `wiki-link-parser.ts` utility provides parsing of wiki-style `[[links]]` from markdown content. This is the foundation for the Knowledge Module's linking system. + +### Features + +- **Multiple Link Formats**: Supports title, slug, and display text variations +- **Position Tracking**: Returns exact positions for link replacement or highlighting +- **Code Block Awareness**: Skips links in code blocks (inline and fenced) +- **Escape Support**: Respects escaped brackets `\[[not a link]]` +- **Edge Case Handling**: Properly handles nested brackets, empty links, and malformed syntax + +### Usage + +```typescript +import { parseWikiLinks } from './utils/wiki-link-parser'; + +const content = 'See [[Main Page]] and [[Getting Started|start here]].'; +const links = parseWikiLinks(content); + +// Result: +// [ +// { +// raw: '[[Main Page]]', +// target: 'Main Page', +// displayText: 'Main Page', +// start: 4, +// end: 17 +// }, +// { +// raw: '[[Getting Started|start here]]', +// target: 'Getting Started', +// displayText: 'start here', +// start: 22, +// end: 52 +// } +// ] +``` + +### Supported Link Formats + +#### Basic Link (by title) +```markdown +[[Page Name]] +``` +Links to a page by its title. Display text will be "Page Name". + +#### Link with Display Text +```markdown +[[Page Name|custom display]] +``` +Links to "Page Name" but displays "custom display". + +#### Link by Slug +```markdown +[[page-slug-name]] +``` +Links to a page by its URL slug (kebab-case). + +### Edge Cases + +#### Nested Brackets +```markdown +[[Page [with] brackets]] ✓ Parsed correctly +``` +Single brackets inside link text are allowed. + +#### Code Blocks (Not Parsed) +```markdown +Use `[[WikiLink]]` syntax for linking. + +\`\`\`typescript +const link = "[[not parsed]]"; +\`\`\` +``` +Links inside inline code or fenced code blocks are ignored. + +#### Escaped Brackets +```markdown +\[[not a link]] but [[real link]] works +``` +Escaped brackets are not parsed as links. + +#### Empty or Invalid Links +```markdown +[[]] ✗ Empty link (ignored) +[[ ]] ✗ Whitespace only (ignored) +[[ Target ]] ✓ Trimmed to "Target" +``` + +### Return Type + +```typescript +interface WikiLink { + raw: string; // Full matched text: "[[Page Name]]" + target: string; // Target page: "Page Name" + displayText: string; // Display text: "Page Name" or custom + start: number; // Start position in content + end: number; // End position in content +} +``` + +### Testing + +Comprehensive test suite (100% coverage) includes: +- Basic parsing (single, multiple, consecutive links) +- Display text variations +- Edge cases (brackets, escapes, empty links) +- Code block exclusion (inline, fenced, indented) +- Position tracking +- Unicode support +- Malformed input handling + +Run tests: +```bash +pnpm test --filter=@mosaic/api -- wiki-link-parser.spec.ts +``` + +### Integration + +This parser is designed to work with the Knowledge Module's linking system: + +1. **On Entry Save**: Parse `[[links]]` from content +2. **Create Link Records**: Store references in database +3. **Backlink Tracking**: Maintain bidirectional link relationships +4. **Link Rendering**: Replace `[[links]]` with HTML anchors + +See related issues: +- #59 - Wiki-link parser (this implementation) +- Future: Link resolution and storage +- Future: Backlink display and navigation + ## Markdown Rendering ### Overview diff --git a/apps/api/src/knowledge/utils/wiki-link-parser.spec.ts b/apps/api/src/knowledge/utils/wiki-link-parser.spec.ts new file mode 100644 index 0000000..8be984e --- /dev/null +++ b/apps/api/src/knowledge/utils/wiki-link-parser.spec.ts @@ -0,0 +1,435 @@ +import { describe, it, expect } from "vitest"; +import { parseWikiLinks, WikiLink } from "./wiki-link-parser"; + +describe("Wiki Link Parser", () => { + describe("Basic Parsing", () => { + it("should parse a simple wiki link", () => { + const content = "This is a [[Page Name]] in text."; + const links = parseWikiLinks(content); + + expect(links).toHaveLength(1); + expect(links[0]).toEqual({ + raw: "[[Page Name]]", + target: "Page Name", + displayText: "Page Name", + start: 10, + end: 23, + }); + }); + + it("should parse multiple wiki links", () => { + const content = "Link to [[First Page]] and [[Second Page]]."; + const links = parseWikiLinks(content); + + expect(links).toHaveLength(2); + expect(links[0].target).toBe("First Page"); + expect(links[0].start).toBe(8); + expect(links[0].end).toBe(22); + expect(links[1].target).toBe("Second Page"); + expect(links[1].start).toBe(27); + expect(links[1].end).toBe(42); + }); + + it("should handle empty content", () => { + const links = parseWikiLinks(""); + expect(links).toEqual([]); + }); + + it("should handle content without links", () => { + const content = "This is just plain text with no wiki links."; + const links = parseWikiLinks(content); + expect(links).toEqual([]); + }); + + it("should parse link by slug (kebab-case)", () => { + const content = "Reference to [[page-slug-name]]."; + const links = parseWikiLinks(content); + + expect(links).toHaveLength(1); + expect(links[0].target).toBe("page-slug-name"); + expect(links[0].displayText).toBe("page-slug-name"); + }); + }); + + describe("Display Text Variation", () => { + it("should parse link with custom display text", () => { + const content = "See [[Page Name|custom display]] for details."; + const links = parseWikiLinks(content); + + expect(links).toHaveLength(1); + expect(links[0]).toEqual({ + raw: "[[Page Name|custom display]]", + target: "Page Name", + displayText: "custom display", + start: 4, + end: 32, + }); + }); + + it("should parse multiple links with display text", () => { + const content = "[[First|One]] and [[Second|Two]]"; + const links = parseWikiLinks(content); + + expect(links).toHaveLength(2); + expect(links[0].target).toBe("First"); + expect(links[0].displayText).toBe("One"); + expect(links[1].target).toBe("Second"); + expect(links[1].displayText).toBe("Two"); + }); + + it("should handle display text with special characters", () => { + const content = "[[Page|Click here! (details)]]"; + const links = parseWikiLinks(content); + + expect(links).toHaveLength(1); + expect(links[0].displayText).toBe("Click here! (details)"); + }); + + it("should handle pipe character in target but default display", () => { + const content = "[[Page Name]]"; + const links = parseWikiLinks(content); + + expect(links[0].target).toBe("Page Name"); + expect(links[0].displayText).toBe("Page Name"); + }); + }); + + describe("Edge Cases - Brackets", () => { + it("should not parse single brackets", () => { + const content = "This [is not] a wiki link."; + const links = parseWikiLinks(content); + expect(links).toEqual([]); + }); + + it("should not parse three or more opening brackets", () => { + const content = "This [[[is not]]] a wiki link."; + const links = parseWikiLinks(content); + expect(links).toEqual([]); + }); + + it("should not parse unmatched brackets", () => { + const content = "This [[is incomplete"; + const links = parseWikiLinks(content); + expect(links).toEqual([]); + }); + + it("should not parse reversed brackets", () => { + const content = "This ]]not a link[[ text."; + const links = parseWikiLinks(content); + expect(links).toEqual([]); + }); + + it("should handle nested brackets inside link text", () => { + const content = "[[Page [with] brackets]]"; + const links = parseWikiLinks(content); + + expect(links).toHaveLength(1); + expect(links[0].target).toBe("Page [with] brackets"); + }); + + it("should handle nested double brackets", () => { + // This is tricky - we should parse the outer link + const content = "[[Outer [[inner]] link]]"; + const links = parseWikiLinks(content); + + // Should not parse nested double brackets - only the first valid one + expect(links).toHaveLength(1); + expect(links[0].raw).toBe("[[Outer [[inner]]"); + }); + }); + + describe("Edge Cases - Escaped Brackets", () => { + it("should not parse escaped opening brackets", () => { + const content = "This \\[[is not a link]] text."; + const links = parseWikiLinks(content); + expect(links).toEqual([]); + }); + + it("should parse link after escaped brackets", () => { + const content = "Escaped \\[[not link]] but [[real link]] here."; + const links = parseWikiLinks(content); + + expect(links).toHaveLength(1); + expect(links[0].target).toBe("real link"); + }); + + it("should handle backslash before brackets in various positions", () => { + const content = "Text \\[[ and [[valid link]] more \\]]."; + const links = parseWikiLinks(content); + + expect(links).toHaveLength(1); + expect(links[0].target).toBe("valid link"); + }); + }); + + describe("Edge Cases - Code Blocks", () => { + it("should not parse links in inline code", () => { + const content = "Use `[[WikiLink]]` syntax for linking."; + const links = parseWikiLinks(content); + expect(links).toEqual([]); + }); + + it("should not parse links in fenced code blocks", () => { + const content = ` +Here is some text. + +\`\`\` +[[Link in code block]] +\`\`\` + +End of text. + `; + const links = parseWikiLinks(content); + expect(links).toEqual([]); + }); + + it("should not parse links in indented code blocks", () => { + const content = ` +Normal text here. + + [[Link in indented code]] + More code here + +Normal text again. + `; + const links = parseWikiLinks(content); + expect(links).toEqual([]); + }); + + it("should parse links outside code blocks but not inside", () => { + const content = ` +[[Valid Link]] + +\`\`\` +[[Invalid Link]] +\`\`\` + +[[Another Valid Link]] + `; + const links = parseWikiLinks(content); + + expect(links).toHaveLength(2); + expect(links[0].target).toBe("Valid Link"); + expect(links[1].target).toBe("Another Valid Link"); + }); + + it("should not parse links in code blocks with language", () => { + const content = ` +\`\`\`typescript +const link = "[[Not A Link]]"; +\`\`\` + `; + const links = parseWikiLinks(content); + expect(links).toEqual([]); + }); + + it("should handle multiple inline code sections", () => { + const content = "Use `[[link1]]` or `[[link2]]` but [[real link]] works."; + const links = parseWikiLinks(content); + + expect(links).toHaveLength(1); + expect(links[0].target).toBe("real link"); + }); + + it("should handle unclosed code backticks correctly", () => { + const content = "Start `code [[link1]] still in code [[link2]]"; + const links = parseWikiLinks(content); + // If backtick is unclosed, we shouldn't parse any links after it + expect(links).toEqual([]); + }); + + it("should handle adjacent code blocks", () => { + const content = "`[[code1]]` text [[valid]] `[[code2]]`"; + const links = parseWikiLinks(content); + + expect(links).toHaveLength(1); + expect(links[0].target).toBe("valid"); + }); + }); + + describe("Edge Cases - Empty and Malformed", () => { + it("should not parse empty link brackets", () => { + const content = "Empty [[]] brackets."; + const links = parseWikiLinks(content); + expect(links).toEqual([]); + }); + + it("should not parse whitespace-only links", () => { + const content = "Whitespace [[ ]] link."; + const links = parseWikiLinks(content); + expect(links).toEqual([]); + }); + + it("should trim whitespace from link targets", () => { + const content = "Link [[ Page Name ]] here."; + const links = parseWikiLinks(content); + + expect(links).toHaveLength(1); + expect(links[0].target).toBe("Page Name"); + expect(links[0].displayText).toBe("Page Name"); + }); + + it("should trim whitespace from display text", () => { + const content = "Link [[Target| display text ]] here."; + const links = parseWikiLinks(content); + + expect(links).toHaveLength(1); + expect(links[0].target).toBe("Target"); + expect(links[0].displayText).toBe("display text"); + }); + + it("should not parse link with empty target but display text", () => { + const content = "Link [[|display only]] here."; + const links = parseWikiLinks(content); + expect(links).toEqual([]); + }); + + it("should handle link with empty display text", () => { + const content = "Link [[Target|]] here."; + const links = parseWikiLinks(content); + + expect(links).toHaveLength(1); + expect(links[0].target).toBe("Target"); + expect(links[0].displayText).toBe("Target"); + }); + + it("should handle multiple pipes", () => { + const content = "Link [[Target|display|extra]] here."; + const links = parseWikiLinks(content); + + // Should use first pipe as separator + expect(links).toHaveLength(1); + expect(links[0].target).toBe("Target"); + expect(links[0].displayText).toBe("display|extra"); + }); + }); + + describe("Position Tracking", () => { + it("should track correct positions for single link", () => { + const content = "Start [[Link]] end"; + const links = parseWikiLinks(content); + + expect(links[0].start).toBe(6); + expect(links[0].end).toBe(14); + expect(content.substring(links[0].start, links[0].end)).toBe("[[Link]]"); + }); + + it("should track correct positions for multiple links", () => { + const content = "[[First]] middle [[Second]] end"; + const links = parseWikiLinks(content); + + expect(links[0].start).toBe(0); + expect(links[0].end).toBe(9); + expect(links[1].start).toBe(17); + expect(links[1].end).toBe(27); + + expect(content.substring(links[0].start, links[0].end)).toBe("[[First]]"); + expect(content.substring(links[1].start, links[1].end)).toBe("[[Second]]"); + }); + + it("should track positions with display text", () => { + const content = "Text [[Target|Display]] more"; + const links = parseWikiLinks(content); + + expect(links[0].start).toBe(5); + expect(links[0].end).toBe(23); + expect(content.substring(links[0].start, links[0].end)).toBe( + "[[Target|Display]]" + ); + }); + + it("should track positions in multiline content", () => { + const content = `Line 1 +Line 2 [[Link]] +Line 3`; + const links = parseWikiLinks(content); + + expect(links[0].start).toBe(14); + expect(content.substring(links[0].start, links[0].end)).toBe("[[Link]]"); + }); + }); + + describe("Complex Scenarios", () => { + it("should handle realistic markdown content", () => { + const content = ` +# Knowledge Base + +This is a reference to [[Main Page]] and [[Getting Started|start here]]. + +You can also check [[FAQ]] for common questions. + +\`\`\`typescript +// This [[should not parse]] +const link = "[[also not parsed]]"; +\`\`\` + +But [[this works]] after code block. + `; + + const links = parseWikiLinks(content); + + expect(links).toHaveLength(4); + expect(links[0].target).toBe("Main Page"); + expect(links[1].target).toBe("Getting Started"); + expect(links[1].displayText).toBe("start here"); + expect(links[2].target).toBe("FAQ"); + expect(links[3].target).toBe("this works"); + }); + + it("should handle links at start and end of content", () => { + const content = "[[Start]] middle [[End]]"; + const links = parseWikiLinks(content); + + expect(links).toHaveLength(2); + expect(links[0].start).toBe(0); + expect(links[1].end).toBe(content.length); + }); + + it("should handle consecutive links", () => { + const content = "[[First]][[Second]][[Third]]"; + const links = parseWikiLinks(content); + + expect(links).toHaveLength(3); + expect(links[0].target).toBe("First"); + expect(links[1].target).toBe("Second"); + expect(links[2].target).toBe("Third"); + }); + + it("should handle links with unicode characters", () => { + const content = "Link to [[日本語]] and [[Émojis 🚀]]."; + const links = parseWikiLinks(content); + + expect(links).toHaveLength(2); + expect(links[0].target).toBe("日本語"); + expect(links[1].target).toBe("Émojis 🚀"); + }); + + it("should handle very long link text", () => { + const longText = "A".repeat(1000); + const content = `Start [[${longText}]] end`; + const links = parseWikiLinks(content); + + expect(links).toHaveLength(1); + expect(links[0].target).toBe(longText); + }); + }); + + describe("Type Safety", () => { + it("should return correctly typed WikiLink objects", () => { + const content = "[[Test Link]]"; + const links: WikiLink[] = parseWikiLinks(content); + + expect(links[0]).toHaveProperty("raw"); + expect(links[0]).toHaveProperty("target"); + expect(links[0]).toHaveProperty("displayText"); + expect(links[0]).toHaveProperty("start"); + expect(links[0]).toHaveProperty("end"); + + expect(typeof links[0].raw).toBe("string"); + expect(typeof links[0].target).toBe("string"); + expect(typeof links[0].displayText).toBe("string"); + expect(typeof links[0].start).toBe("number"); + expect(typeof links[0].end).toBe("number"); + }); + }); +}); diff --git a/apps/api/src/knowledge/utils/wiki-link-parser.ts b/apps/api/src/knowledge/utils/wiki-link-parser.ts new file mode 100644 index 0000000..f96677e --- /dev/null +++ b/apps/api/src/knowledge/utils/wiki-link-parser.ts @@ -0,0 +1,279 @@ +/** + * Represents a parsed wiki-style link from markdown content + */ +export interface WikiLink { + /** The raw matched text including brackets (e.g., "[[Page Name]]") */ + raw: string; + /** The target page name or slug */ + target: string; + /** The display text (may differ from target if using | syntax) */ + displayText: string; + /** Start position of the link in the original content */ + start: number; + /** End position of the link in the original content */ + end: number; +} + +/** + * Represents a region in the content that should be excluded from parsing + */ +interface ExcludedRegion { + start: number; + end: number; +} + +/** + * Parse wiki-style [[links]] from markdown content. + * + * Supports: + * - [[Page Name]] - link by title + * - [[Page Name|display text]] - link with custom display + * - [[page-slug]] - link by slug + * + * Handles edge cases: + * - Nested brackets within link text + * - Links in code blocks (excluded from parsing) + * - Escaped brackets (excluded from parsing) + * + * @param content - The markdown content to parse + * @returns Array of parsed wiki links with position information + */ +export function parseWikiLinks(content: string): WikiLink[] { + if (!content || content.length === 0) { + return []; + } + + const excludedRegions = findExcludedRegions(content); + const links: WikiLink[] = []; + + // Manual parsing to handle complex bracket scenarios + let i = 0; + while (i < content.length) { + // Look for [[ + if (i < content.length - 1 && content[i] === "[" && content[i + 1] === "[") { + // Check if preceded by escape character + if (i > 0 && content[i - 1] === "\\") { + i++; + continue; + } + + // Check if preceded by another [ (would make [[[) + if (i > 0 && content[i - 1] === "[") { + i++; + continue; + } + + // Check if followed by another [ (would make [[[) + if (i + 2 < content.length && content[i + 2] === "[") { + i++; + continue; + } + + const start = i; + i += 2; // Skip past [[ + + // Find the closing ]] + let innerContent = ""; + let foundClosing = false; + + while (i < content.length - 1) { + // Check for ]] + if (content[i] === "]" && content[i + 1] === "]") { + foundClosing = true; + break; + } + innerContent += content[i]; + i++; + } + + if (!foundClosing) { + // No closing brackets found, continue searching + continue; + } + + const end = i + 2; // Include the ]] + const raw = content.substring(start, end); + + // Skip if this link is in an excluded region + if (isInExcludedRegion(start, end, excludedRegions)) { + i += 2; // Move past the ]] + continue; + } + + // Parse the inner content to extract target and display text + const parsed = parseInnerContent(innerContent); + if (!parsed) { + i += 2; // Move past the ]] + continue; + } + + links.push({ + raw, + target: parsed.target, + displayText: parsed.displayText, + start, + end, + }); + + i += 2; // Move past the ]] + } else { + i++; + } + } + + return links; +} + +/** + * Parse the inner content of a wiki link to extract target and display text + */ +function parseInnerContent( + content: string +): { target: string; displayText: string } | null { + // Check for pipe separator + const pipeIndex = content.indexOf("|"); + + let target: string; + let displayText: string; + + if (pipeIndex !== -1) { + // Has display text + target = content.substring(0, pipeIndex).trim(); + displayText = content.substring(pipeIndex + 1).trim(); + + // If display text is empty after trim, use target + if (displayText === "") { + displayText = target; + } + } else { + // No display text, target and display are the same + target = content.trim(); + displayText = target; + } + + // Reject if target is empty or whitespace-only + if (target === "") { + return null; + } + + return { target, displayText }; +} + +/** + * Find all regions that should be excluded from wiki link parsing + * (code blocks, inline code, etc.) + */ +function findExcludedRegions(content: string): ExcludedRegion[] { + const regions: ExcludedRegion[] = []; + + // Find fenced code blocks (``` ... ```) + const fencedCodePattern = /```[\s\S]*?```/g; + let match: RegExpExecArray | null; + + while ((match = fencedCodePattern.exec(content)) !== null) { + regions.push({ + start: match.index, + end: match.index + match[0].length, + }); + } + + // Find indented code blocks (4 spaces or 1 tab at line start) + const lines = content.split("\n"); + let currentIndex = 0; + let inIndentedBlock = false; + let blockStart = 0; + + for (const line of lines) { + const lineStart = currentIndex; + const lineEnd = currentIndex + line.length; + + // Check if line is indented (4 spaces or tab) + const isIndented = + line.startsWith(" ") || line.startsWith("\t"); + const isEmpty = line.trim() === ""; + + if (isIndented && !inIndentedBlock) { + // Start of indented block + inIndentedBlock = true; + blockStart = lineStart; + } else if (!isIndented && !isEmpty && inIndentedBlock) { + // End of indented block (non-empty, non-indented line) + regions.push({ + start: blockStart, + end: lineStart, + }); + inIndentedBlock = false; + } + + currentIndex = lineEnd + 1; // +1 for newline character + } + + // Handle case where indented block extends to end of content + if (inIndentedBlock) { + regions.push({ + start: blockStart, + end: content.length, + }); + } + + // Find inline code (` ... `) + // This is tricky because we need to track state + let inInlineCode = false; + let inlineStart = 0; + + for (let i = 0; i < content.length; i++) { + if (content[i] === "`") { + // Check if it's escaped + if (i > 0 && content[i - 1] === "\\") { + continue; + } + + // Check if we're already in a fenced code block or indented block + if (isInExcludedRegion(i, i + 1, regions)) { + continue; + } + + if (!inInlineCode) { + inInlineCode = true; + inlineStart = i; + } else { + // End of inline code + regions.push({ + start: inlineStart, + end: i + 1, + }); + inInlineCode = false; + } + } + } + + // Handle unclosed inline code (extends to end of content) + if (inInlineCode) { + regions.push({ + start: inlineStart, + end: content.length, + }); + } + + // Sort regions by start position for efficient checking + regions.sort((a, b) => a.start - b.start); + + return regions; +} + +/** + * Check if a position range is within any excluded region + */ +function isInExcludedRegion( + start: number, + end: number, + regions: ExcludedRegion[] +): boolean { + for (const region of regions) { + // Check if the range overlaps with this excluded region + if (start < region.end && end > region.start) { + return true; + } + } + return false; +} diff --git a/apps/api/src/ollama/dto/index.ts b/apps/api/src/ollama/dto/index.ts new file mode 100644 index 0000000..da99f87 --- /dev/null +++ b/apps/api/src/ollama/dto/index.ts @@ -0,0 +1,59 @@ +/** + * DTOs for Ollama module + */ + +export interface GenerateOptionsDto { + temperature?: number; + top_p?: number; + max_tokens?: number; + stop?: string[]; + stream?: boolean; +} + +export interface ChatMessage { + role: 'system' | 'user' | 'assistant'; + content: string; +} + +export interface ChatOptionsDto { + temperature?: number; + top_p?: number; + max_tokens?: number; + stop?: string[]; + stream?: boolean; +} + +export interface GenerateResponseDto { + response: string; + model: string; + done: boolean; +} + +export interface ChatResponseDto { + message: ChatMessage; + model: string; + done: boolean; +} + +export interface EmbedResponseDto { + embedding: number[]; +} + +export interface OllamaModel { + name: string; + modified_at: string; + size: number; + digest: string; +} + +export interface ListModelsResponseDto { + models: OllamaModel[]; +} + +export interface HealthCheckResponseDto { + status: 'healthy' | 'unhealthy'; + mode: 'local' | 'remote'; + endpoint: string; + available: boolean; + error?: string; +} diff --git a/apps/api/src/ollama/ollama.controller.spec.ts b/apps/api/src/ollama/ollama.controller.spec.ts new file mode 100644 index 0000000..1f837b6 --- /dev/null +++ b/apps/api/src/ollama/ollama.controller.spec.ts @@ -0,0 +1,243 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { Test, TestingModule } from "@nestjs/testing"; +import { OllamaController } from "./ollama.controller"; +import { OllamaService } from "./ollama.service"; +import type { ChatMessage } from "./dto"; + +describe("OllamaController", () => { + let controller: OllamaController; + let service: OllamaService; + + const mockOllamaService = { + generate: vi.fn(), + chat: vi.fn(), + embed: vi.fn(), + listModels: vi.fn(), + healthCheck: vi.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [OllamaController], + providers: [ + { + provide: OllamaService, + useValue: mockOllamaService, + }, + ], + }).compile(); + + controller = module.get(OllamaController); + service = module.get(OllamaService); + + vi.clearAllMocks(); + }); + + describe("generate", () => { + it("should generate text from prompt", async () => { + const mockResponse = { + model: "llama3.2", + response: "Generated text", + done: true, + }; + + mockOllamaService.generate.mockResolvedValue(mockResponse); + + const result = await controller.generate({ + prompt: "Hello", + }); + + expect(result).toEqual(mockResponse); + expect(mockOllamaService.generate).toHaveBeenCalledWith( + "Hello", + undefined, + undefined + ); + }); + + it("should generate with options and custom model", async () => { + const mockResponse = { + model: "mistral", + response: "Response", + done: true, + }; + + mockOllamaService.generate.mockResolvedValue(mockResponse); + + const result = await controller.generate({ + prompt: "Test", + model: "mistral", + options: { + temperature: 0.7, + max_tokens: 100, + }, + }); + + expect(result).toEqual(mockResponse); + expect(mockOllamaService.generate).toHaveBeenCalledWith( + "Test", + { temperature: 0.7, max_tokens: 100 }, + "mistral" + ); + }); + }); + + describe("chat", () => { + it("should complete chat conversation", async () => { + const messages: ChatMessage[] = [ + { role: "user", content: "Hello!" }, + ]; + + const mockResponse = { + model: "llama3.2", + message: { + role: "assistant", + content: "Hi there!", + }, + done: true, + }; + + mockOllamaService.chat.mockResolvedValue(mockResponse); + + const result = await controller.chat({ + messages, + }); + + expect(result).toEqual(mockResponse); + expect(mockOllamaService.chat).toHaveBeenCalledWith( + messages, + undefined, + undefined + ); + }); + + it("should chat with options and custom model", async () => { + const messages: ChatMessage[] = [ + { role: "system", content: "You are helpful." }, + { role: "user", content: "Hello!" }, + ]; + + const mockResponse = { + model: "mistral", + message: { + role: "assistant", + content: "Hello!", + }, + done: true, + }; + + mockOllamaService.chat.mockResolvedValue(mockResponse); + + const result = await controller.chat({ + messages, + model: "mistral", + options: { + temperature: 0.5, + }, + }); + + expect(result).toEqual(mockResponse); + expect(mockOllamaService.chat).toHaveBeenCalledWith( + messages, + { temperature: 0.5 }, + "mistral" + ); + }); + }); + + describe("embed", () => { + it("should generate embeddings", async () => { + const mockResponse = { + embedding: [0.1, 0.2, 0.3], + }; + + mockOllamaService.embed.mockResolvedValue(mockResponse); + + const result = await controller.embed({ + text: "Sample text", + }); + + expect(result).toEqual(mockResponse); + expect(mockOllamaService.embed).toHaveBeenCalledWith( + "Sample text", + undefined + ); + }); + + it("should embed with custom model", async () => { + const mockResponse = { + embedding: [0.1, 0.2], + }; + + mockOllamaService.embed.mockResolvedValue(mockResponse); + + const result = await controller.embed({ + text: "Test", + model: "nomic-embed-text", + }); + + expect(result).toEqual(mockResponse); + expect(mockOllamaService.embed).toHaveBeenCalledWith( + "Test", + "nomic-embed-text" + ); + }); + }); + + describe("listModels", () => { + it("should list available models", async () => { + const mockResponse = { + models: [ + { + name: "llama3.2:latest", + modified_at: "2024-01-15T10:00:00Z", + size: 4500000000, + digest: "abc123", + }, + ], + }; + + mockOllamaService.listModels.mockResolvedValue(mockResponse); + + const result = await controller.listModels(); + + expect(result).toEqual(mockResponse); + expect(mockOllamaService.listModels).toHaveBeenCalled(); + }); + }); + + describe("healthCheck", () => { + it("should return health status", async () => { + const mockResponse = { + status: "healthy" as const, + mode: "local" as const, + endpoint: "http://localhost:11434", + available: true, + }; + + mockOllamaService.healthCheck.mockResolvedValue(mockResponse); + + const result = await controller.healthCheck(); + + expect(result).toEqual(mockResponse); + expect(mockOllamaService.healthCheck).toHaveBeenCalled(); + }); + + it("should return unhealthy status", async () => { + const mockResponse = { + status: "unhealthy" as const, + mode: "local" as const, + endpoint: "http://localhost:11434", + available: false, + error: "Connection refused", + }; + + mockOllamaService.healthCheck.mockResolvedValue(mockResponse); + + const result = await controller.healthCheck(); + + expect(result).toEqual(mockResponse); + expect(result.status).toBe("unhealthy"); + }); + }); +}); diff --git a/apps/api/src/ollama/ollama.controller.ts b/apps/api/src/ollama/ollama.controller.ts new file mode 100644 index 0000000..a980a3b --- /dev/null +++ b/apps/api/src/ollama/ollama.controller.ts @@ -0,0 +1,92 @@ +import { Controller, Post, Get, Body } from "@nestjs/common"; +import { OllamaService } from "./ollama.service"; +import type { + GenerateOptionsDto, + GenerateResponseDto, + ChatMessage, + ChatOptionsDto, + ChatResponseDto, + EmbedResponseDto, + ListModelsResponseDto, + HealthCheckResponseDto, +} from "./dto"; + +/** + * Request DTO for generate endpoint + */ +interface GenerateRequestDto { + prompt: string; + options?: GenerateOptionsDto; + model?: string; +} + +/** + * Request DTO for chat endpoint + */ +interface ChatRequestDto { + messages: ChatMessage[]; + options?: ChatOptionsDto; + model?: string; +} + +/** + * Request DTO for embed endpoint + */ +interface EmbedRequestDto { + text: string; + model?: string; +} + +/** + * Controller for Ollama API endpoints + * Provides text generation, chat, embeddings, and model management + */ +@Controller("ollama") +export class OllamaController { + constructor(private readonly ollamaService: OllamaService) {} + + /** + * Generate text from a prompt + * POST /ollama/generate + */ + @Post("generate") + async generate(@Body() body: GenerateRequestDto): Promise { + return this.ollamaService.generate(body.prompt, body.options, body.model); + } + + /** + * Complete a chat conversation + * POST /ollama/chat + */ + @Post("chat") + async chat(@Body() body: ChatRequestDto): Promise { + return this.ollamaService.chat(body.messages, body.options, body.model); + } + + /** + * Generate embeddings for text + * POST /ollama/embed + */ + @Post("embed") + async embed(@Body() body: EmbedRequestDto): Promise { + return this.ollamaService.embed(body.text, body.model); + } + + /** + * List available models + * GET /ollama/models + */ + @Get("models") + async listModels(): Promise { + return this.ollamaService.listModels(); + } + + /** + * Health check endpoint + * GET /ollama/health + */ + @Get("health") + async healthCheck(): Promise { + return this.ollamaService.healthCheck(); + } +} diff --git a/apps/api/src/ollama/ollama.module.ts b/apps/api/src/ollama/ollama.module.ts new file mode 100644 index 0000000..8f2d44e --- /dev/null +++ b/apps/api/src/ollama/ollama.module.ts @@ -0,0 +1,37 @@ +import { Module } from "@nestjs/common"; +import { OllamaController } from "./ollama.controller"; +import { OllamaService, OllamaConfig } from "./ollama.service"; + +/** + * Factory function to create Ollama configuration from environment variables + */ +function createOllamaConfig(): OllamaConfig { + const mode = (process.env.OLLAMA_MODE || "local") as "local" | "remote"; + const endpoint = process.env.OLLAMA_ENDPOINT || "http://localhost:11434"; + const model = process.env.OLLAMA_MODEL || "llama3.2"; + const timeout = parseInt(process.env.OLLAMA_TIMEOUT || "30000", 10); + + return { + mode, + endpoint, + model, + timeout, + }; +} + +/** + * Module for Ollama integration + * Provides AI capabilities via local or remote Ollama instances + */ +@Module({ + controllers: [OllamaController], + providers: [ + { + provide: "OLLAMA_CONFIG", + useFactory: createOllamaConfig, + }, + OllamaService, + ], + exports: [OllamaService], +}) +export class OllamaModule {} diff --git a/apps/api/src/ollama/ollama.service.spec.ts b/apps/api/src/ollama/ollama.service.spec.ts new file mode 100644 index 0000000..80eddd3 --- /dev/null +++ b/apps/api/src/ollama/ollama.service.spec.ts @@ -0,0 +1,441 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { Test, TestingModule } from "@nestjs/testing"; +import { OllamaService } from "./ollama.service"; +import { HttpException, HttpStatus } from "@nestjs/common"; +import type { + GenerateOptionsDto, + ChatMessage, + ChatOptionsDto, +} from "./dto"; + +describe("OllamaService", () => { + let service: OllamaService; + let mockFetch: ReturnType; + + const mockConfig = { + mode: "local" as const, + endpoint: "http://localhost:11434", + model: "llama3.2", + timeout: 30000, + }; + + beforeEach(async () => { + mockFetch = vi.fn(); + global.fetch = mockFetch; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + OllamaService, + { + provide: "OLLAMA_CONFIG", + useValue: mockConfig, + }, + ], + }).compile(); + + service = module.get(OllamaService); + + vi.clearAllMocks(); + }); + + describe("generate", () => { + it("should generate text from prompt", async () => { + const mockResponse = { + model: "llama3.2", + response: "This is a generated response.", + done: true, + }; + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => mockResponse, + }); + + const result = await service.generate("Hello, world!"); + + expect(result).toEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + "http://localhost:11434/api/generate", + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "llama3.2", + prompt: "Hello, world!", + stream: false, + }), + }) + ); + }); + + it("should generate text with custom options", async () => { + const options: GenerateOptionsDto = { + temperature: 0.8, + max_tokens: 100, + stop: ["\n"], + }; + + const mockResponse = { + model: "llama3.2", + response: "Custom response.", + done: true, + }; + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => mockResponse, + }); + + const result = await service.generate("Hello", options); + + expect(result).toEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + "http://localhost:11434/api/generate", + expect.objectContaining({ + body: JSON.stringify({ + model: "llama3.2", + prompt: "Hello", + stream: false, + options: { + temperature: 0.8, + num_predict: 100, + stop: ["\n"], + }, + }), + }) + ); + }); + + it("should use custom model when provided", async () => { + const mockResponse = { + model: "mistral", + response: "Response from mistral.", + done: true, + }; + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => mockResponse, + }); + + const result = await service.generate("Hello", {}, "mistral"); + + expect(result).toEqual(mockResponse); + const callArgs = mockFetch.mock.calls[0]; + expect(callArgs[0]).toBe("http://localhost:11434/api/generate"); + const body = JSON.parse(callArgs[1].body as string); + expect(body.model).toBe("mistral"); + expect(body.prompt).toBe("Hello"); + expect(body.stream).toBe(false); + }); + + it("should throw HttpException on network error", async () => { + mockFetch.mockRejectedValue(new Error("Network error")); + + await expect(service.generate("Hello")).rejects.toThrow(HttpException); + await expect(service.generate("Hello")).rejects.toThrow( + "Failed to connect to Ollama" + ); + }); + + it("should throw HttpException on non-ok response", async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + statusText: "Internal Server Error", + }); + + await expect(service.generate("Hello")).rejects.toThrow(HttpException); + }); + + it("should handle timeout", async () => { + // Mock AbortController to simulate timeout + mockFetch.mockRejectedValue(new Error("The operation was aborted")); + + // Create service with very short timeout + const shortTimeoutModule = await Test.createTestingModule({ + providers: [ + OllamaService, + { + provide: "OLLAMA_CONFIG", + useValue: { ...mockConfig, timeout: 1 }, + }, + ], + }).compile(); + + const shortTimeoutService = + shortTimeoutModule.get(OllamaService); + + await expect(shortTimeoutService.generate("Hello")).rejects.toThrow( + HttpException + ); + }); + }); + + describe("chat", () => { + it("should complete chat with messages", async () => { + const messages: ChatMessage[] = [ + { role: "system", content: "You are helpful." }, + { role: "user", content: "Hello!" }, + ]; + + const mockResponse = { + model: "llama3.2", + message: { + role: "assistant", + content: "Hello! How can I help you?", + }, + done: true, + }; + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => mockResponse, + }); + + const result = await service.chat(messages); + + expect(result).toEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + "http://localhost:11434/api/chat", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + model: "llama3.2", + messages, + stream: false, + }), + }) + ); + }); + + it("should chat with custom options", async () => { + const messages: ChatMessage[] = [ + { role: "user", content: "Hello!" }, + ]; + + const options: ChatOptionsDto = { + temperature: 0.5, + max_tokens: 50, + }; + + const mockResponse = { + model: "llama3.2", + message: { role: "assistant", content: "Hi!" }, + done: true, + }; + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => mockResponse, + }); + + await service.chat(messages, options); + + expect(mockFetch).toHaveBeenCalledWith( + "http://localhost:11434/api/chat", + expect.objectContaining({ + body: JSON.stringify({ + model: "llama3.2", + messages, + stream: false, + options: { + temperature: 0.5, + num_predict: 50, + }, + }), + }) + ); + }); + + it("should throw HttpException on chat error", async () => { + mockFetch.mockRejectedValue(new Error("Connection refused")); + + await expect( + service.chat([{ role: "user", content: "Hello" }]) + ).rejects.toThrow(HttpException); + }); + }); + + describe("embed", () => { + it("should generate embeddings for text", async () => { + const mockResponse = { + embedding: [0.1, 0.2, 0.3, 0.4], + }; + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => mockResponse, + }); + + const result = await service.embed("Hello world"); + + expect(result).toEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + "http://localhost:11434/api/embeddings", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + model: "llama3.2", + prompt: "Hello world", + }), + }) + ); + }); + + it("should use custom model for embeddings", async () => { + const mockResponse = { + embedding: [0.1, 0.2], + }; + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => mockResponse, + }); + + await service.embed("Test", "nomic-embed-text"); + + expect(mockFetch).toHaveBeenCalledWith( + "http://localhost:11434/api/embeddings", + expect.objectContaining({ + body: JSON.stringify({ + model: "nomic-embed-text", + prompt: "Test", + }), + }) + ); + }); + + it("should throw HttpException on embed error", async () => { + mockFetch.mockRejectedValue(new Error("Model not found")); + + await expect(service.embed("Hello")).rejects.toThrow(HttpException); + }); + }); + + describe("listModels", () => { + it("should list available models", async () => { + const mockResponse = { + models: [ + { + name: "llama3.2:latest", + modified_at: "2024-01-15T10:00:00Z", + size: 4500000000, + digest: "abc123", + }, + { + name: "mistral:latest", + modified_at: "2024-01-14T09:00:00Z", + size: 4200000000, + digest: "def456", + }, + ], + }; + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => mockResponse, + }); + + const result = await service.listModels(); + + expect(result).toEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + "http://localhost:11434/api/tags", + expect.objectContaining({ + method: "GET", + }) + ); + }); + + it("should throw HttpException when listing fails", async () => { + mockFetch.mockRejectedValue(new Error("Server error")); + + await expect(service.listModels()).rejects.toThrow(HttpException); + }); + }); + + describe("healthCheck", () => { + it("should return healthy status when Ollama is available", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ status: "ok" }), + }); + + const result = await service.healthCheck(); + + expect(result).toEqual({ + status: "healthy", + mode: "local", + endpoint: "http://localhost:11434", + available: true, + }); + }); + + it("should return unhealthy status when Ollama is unavailable", async () => { + mockFetch.mockRejectedValue(new Error("Connection refused")); + + const result = await service.healthCheck(); + + expect(result).toEqual({ + status: "unhealthy", + mode: "local", + endpoint: "http://localhost:11434", + available: false, + error: "Connection refused", + }); + }); + + it("should handle non-ok response in health check", async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 503, + statusText: "Service Unavailable", + }); + + const result = await service.healthCheck(); + + expect(result.status).toBe("unhealthy"); + expect(result.available).toBe(false); + }); + }); + + describe("configuration", () => { + it("should use remote mode configuration", async () => { + const remoteConfig = { + mode: "remote" as const, + endpoint: "http://remote-server:11434", + model: "mistral", + timeout: 60000, + }; + + const remoteModule = await Test.createTestingModule({ + providers: [ + OllamaService, + { + provide: "OLLAMA_CONFIG", + useValue: remoteConfig, + }, + ], + }).compile(); + + const remoteService = remoteModule.get(OllamaService); + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + model: "mistral", + response: "Remote response", + done: true, + }), + }); + + await remoteService.generate("Test"); + + expect(mockFetch).toHaveBeenCalledWith( + "http://remote-server:11434/api/generate", + expect.any(Object) + ); + }); + }); +}); diff --git a/apps/api/src/ollama/ollama.service.ts b/apps/api/src/ollama/ollama.service.ts new file mode 100644 index 0000000..dc5e253 --- /dev/null +++ b/apps/api/src/ollama/ollama.service.ts @@ -0,0 +1,344 @@ +import { Injectable, Inject, HttpException, HttpStatus } from "@nestjs/common"; +import type { + GenerateOptionsDto, + GenerateResponseDto, + ChatMessage, + ChatOptionsDto, + ChatResponseDto, + EmbedResponseDto, + ListModelsResponseDto, + HealthCheckResponseDto, +} from "./dto"; + +/** + * Configuration for Ollama service + */ +export interface OllamaConfig { + mode: "local" | "remote"; + endpoint: string; + model: string; + timeout: number; +} + +/** + * Service for interacting with Ollama API + * Supports both local and remote Ollama instances + */ +@Injectable() +export class OllamaService { + constructor( + @Inject("OLLAMA_CONFIG") + private readonly config: OllamaConfig + ) {} + + /** + * Generate text from a prompt + * @param prompt - The text prompt to generate from + * @param options - Generation options (temperature, max_tokens, etc.) + * @param model - Optional model override (defaults to config model) + * @returns Generated text response + */ + async generate( + prompt: string, + options?: GenerateOptionsDto, + model?: string + ): Promise { + const url = `${this.config.endpoint}/api/generate`; + + const requestBody = { + model: model || this.config.model, + prompt, + stream: false, + ...(options && { + options: this.mapGenerateOptions(options), + }), + }; + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.config.timeout); + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(requestBody), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new HttpException( + `Ollama API error: ${response.statusText}`, + response.status + ); + } + + const data = await response.json(); + return data as GenerateResponseDto; + } catch (error: unknown) { + if (error instanceof HttpException) { + throw error; + } + + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + + throw new HttpException( + `Failed to connect to Ollama: ${errorMessage}`, + HttpStatus.SERVICE_UNAVAILABLE + ); + } + } + + /** + * Complete a chat conversation + * @param messages - Array of chat messages + * @param options - Chat options (temperature, max_tokens, etc.) + * @param model - Optional model override (defaults to config model) + * @returns Chat completion response + */ + async chat( + messages: ChatMessage[], + options?: ChatOptionsDto, + model?: string + ): Promise { + const url = `${this.config.endpoint}/api/chat`; + + const requestBody = { + model: model || this.config.model, + messages, + stream: false, + ...(options && { + options: this.mapChatOptions(options), + }), + }; + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.config.timeout); + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(requestBody), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new HttpException( + `Ollama API error: ${response.statusText}`, + response.status + ); + } + + const data = await response.json(); + return data as ChatResponseDto; + } catch (error: unknown) { + if (error instanceof HttpException) { + throw error; + } + + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + + throw new HttpException( + `Failed to connect to Ollama: ${errorMessage}`, + HttpStatus.SERVICE_UNAVAILABLE + ); + } + } + + /** + * Generate embeddings for text + * @param text - The text to generate embeddings for + * @param model - Optional model override (defaults to config model) + * @returns Embedding vector + */ + async embed(text: string, model?: string): Promise { + const url = `${this.config.endpoint}/api/embeddings`; + + const requestBody = { + model: model || this.config.model, + prompt: text, + }; + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.config.timeout); + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(requestBody), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new HttpException( + `Ollama API error: ${response.statusText}`, + response.status + ); + } + + const data = await response.json(); + return data as EmbedResponseDto; + } catch (error: unknown) { + if (error instanceof HttpException) { + throw error; + } + + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + + throw new HttpException( + `Failed to connect to Ollama: ${errorMessage}`, + HttpStatus.SERVICE_UNAVAILABLE + ); + } + } + + /** + * List available models + * @returns List of available Ollama models + */ + async listModels(): Promise { + const url = `${this.config.endpoint}/api/tags`; + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.config.timeout); + + const response = await fetch(url, { + method: "GET", + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new HttpException( + `Ollama API error: ${response.statusText}`, + response.status + ); + } + + const data = await response.json(); + return data as ListModelsResponseDto; + } catch (error: unknown) { + if (error instanceof HttpException) { + throw error; + } + + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + + throw new HttpException( + `Failed to connect to Ollama: ${errorMessage}`, + HttpStatus.SERVICE_UNAVAILABLE + ); + } + } + + /** + * Check health and connectivity of Ollama instance + * @returns Health check status + */ + async healthCheck(): Promise { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); // 5s timeout for health check + + const response = await fetch(`${this.config.endpoint}/api/tags`, { + method: "GET", + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (response.ok) { + return { + status: "healthy", + mode: this.config.mode, + endpoint: this.config.endpoint, + available: true, + }; + } else { + return { + status: "unhealthy", + mode: this.config.mode, + endpoint: this.config.endpoint, + available: false, + error: `HTTP ${response.status}: ${response.statusText}`, + }; + } + } catch (error: unknown) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + + return { + status: "unhealthy", + mode: this.config.mode, + endpoint: this.config.endpoint, + available: false, + error: errorMessage, + }; + } + } + + /** + * Map GenerateOptionsDto to Ollama API options format + */ + private mapGenerateOptions( + options: GenerateOptionsDto + ): Record { + const mapped: Record = {}; + + if (options.temperature !== undefined) { + mapped.temperature = options.temperature; + } + if (options.top_p !== undefined) { + mapped.top_p = options.top_p; + } + if (options.max_tokens !== undefined) { + mapped.num_predict = options.max_tokens; + } + if (options.stop !== undefined) { + mapped.stop = options.stop; + } + + return mapped; + } + + /** + * Map ChatOptionsDto to Ollama API options format + */ + private mapChatOptions(options: ChatOptionsDto): Record { + const mapped: Record = {}; + + if (options.temperature !== undefined) { + mapped.temperature = options.temperature; + } + if (options.top_p !== undefined) { + mapped.top_p = options.top_p; + } + if (options.max_tokens !== undefined) { + mapped.num_predict = options.max_tokens; + } + if (options.stop !== undefined) { + mapped.stop = options.stop; + } + + return mapped; + } +}