Merge feature/know-007-wiki-link-parser (#59) into develop
Implements wiki-link parser for Knowledge Module: - Parses [[links]] syntax from markdown - Supports Page Name, display text, and slug formats - 43 tests with 100% coverage
This commit is contained in:
@@ -14,6 +14,8 @@ import { WidgetsModule } from "./widgets/widgets.module";
|
|||||||
import { LayoutsModule } from "./layouts/layouts.module";
|
import { LayoutsModule } from "./layouts/layouts.module";
|
||||||
import { KnowledgeModule } from "./knowledge/knowledge.module";
|
import { KnowledgeModule } from "./knowledge/knowledge.module";
|
||||||
import { UsersModule } from "./users/users.module";
|
import { UsersModule } from "./users/users.module";
|
||||||
|
import { WebSocketModule } from "./websocket/websocket.module";
|
||||||
|
import { OllamaModule } from "./ollama/ollama.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -30,6 +32,8 @@ import { UsersModule } from "./users/users.module";
|
|||||||
LayoutsModule,
|
LayoutsModule,
|
||||||
KnowledgeModule,
|
KnowledgeModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
|
WebSocketModule,
|
||||||
|
OllamaModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
|
|||||||
@@ -1,5 +1,139 @@
|
|||||||
# Knowledge Module Utilities
|
# 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
|
## Markdown Rendering
|
||||||
|
|
||||||
### Overview
|
### Overview
|
||||||
|
|||||||
435
apps/api/src/knowledge/utils/wiki-link-parser.spec.ts
Normal file
435
apps/api/src/knowledge/utils/wiki-link-parser.spec.ts
Normal file
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
279
apps/api/src/knowledge/utils/wiki-link-parser.ts
Normal file
279
apps/api/src/knowledge/utils/wiki-link-parser.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
59
apps/api/src/ollama/dto/index.ts
Normal file
59
apps/api/src/ollama/dto/index.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
243
apps/api/src/ollama/ollama.controller.spec.ts
Normal file
243
apps/api/src/ollama/ollama.controller.spec.ts
Normal file
@@ -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>(OllamaController);
|
||||||
|
service = module.get<OllamaService>(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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
92
apps/api/src/ollama/ollama.controller.ts
Normal file
92
apps/api/src/ollama/ollama.controller.ts
Normal file
@@ -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<GenerateResponseDto> {
|
||||||
|
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<ChatResponseDto> {
|
||||||
|
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<EmbedResponseDto> {
|
||||||
|
return this.ollamaService.embed(body.text, body.model);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List available models
|
||||||
|
* GET /ollama/models
|
||||||
|
*/
|
||||||
|
@Get("models")
|
||||||
|
async listModels(): Promise<ListModelsResponseDto> {
|
||||||
|
return this.ollamaService.listModels();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Health check endpoint
|
||||||
|
* GET /ollama/health
|
||||||
|
*/
|
||||||
|
@Get("health")
|
||||||
|
async healthCheck(): Promise<HealthCheckResponseDto> {
|
||||||
|
return this.ollamaService.healthCheck();
|
||||||
|
}
|
||||||
|
}
|
||||||
37
apps/api/src/ollama/ollama.module.ts
Normal file
37
apps/api/src/ollama/ollama.module.ts
Normal file
@@ -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 {}
|
||||||
441
apps/api/src/ollama/ollama.service.spec.ts
Normal file
441
apps/api/src/ollama/ollama.service.spec.ts
Normal file
@@ -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<typeof vi.fn>;
|
||||||
|
|
||||||
|
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>(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>(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>(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)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
344
apps/api/src/ollama/ollama.service.ts
Normal file
344
apps/api/src/ollama/ollama.service.ts
Normal file
@@ -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<GenerateResponseDto> {
|
||||||
|
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<ChatResponseDto> {
|
||||||
|
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<EmbedResponseDto> {
|
||||||
|
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<ListModelsResponseDto> {
|
||||||
|
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<HealthCheckResponseDto> {
|
||||||
|
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<string, unknown> {
|
||||||
|
const mapped: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
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<string, unknown> {
|
||||||
|
const mapped: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
297
docs/ROADMAP.md
Normal file
297
docs/ROADMAP.md
Normal file
@@ -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 |
|
||||||
Reference in New Issue
Block a user