This commit is contained in:
braginini
2026-03-13 17:40:57 +01:00
commit 11917c70c4
16 changed files with 1866 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
*.tsbuildinfo
.idea/
+402
View File
@@ -0,0 +1,402 @@
# netbird-explain
AI-powered "Explain" assistant for React apps. Users click on UI elements and get contextual AI explanations via a chat panel.
The package has two entry points:
- `netbird-explain/client` — React components (provider, chat panel, floating button)
- `netbird-explain/server` — Node.js handler that proxies requests to Anthropic or OpenAI
No CSS framework required — the package is fully self-contained with inline styles and CSS custom properties.
## Installation
```bash
npm install netbird-explain
```
Peer dependencies: `react >=18`, `react-dom >=18`.
For local development as a workspace package, add it to your `package.json`:
```json
{
"dependencies": {
"netbird-explain": "file:./packages/netbird-explain"
}
}
```
If you're using Next.js, add to `next.config.js`:
```js
module.exports = {
transpilePackages: ["netbird-explain"],
};
```
---
## Client
### Setup
Wrap your app with `AIAssistantProvider`:
```tsx
import { AIAssistantProvider } from "netbird-explain/client";
export default function App({ children }) {
return (
<AIAssistantProvider
endpoint="http://localhost:3080/api/ai/chat"
apiKey="your-api-key"
>
{children}
</AIAssistantProvider>
);
}
```
This renders the floating action button and chat panel automatically. No CSS imports are needed — the provider injects all required styles via CSS custom properties.
### Props
| Prop | Type | Required | Description |
| ---------- | ----------- | -------- | ----------------------------------- |
| `endpoint` | `string` | Yes | URL of the AI chat API |
| `apiKey` | `string` | No | Bearer token sent with each request |
| `children` | `ReactNode` | Yes | Your application |
### Marking elements as explainable
Add the `data-nb-explain` attribute to any element you want users to be able to click on in explain mode:
```tsx
<div data-nb-explain>
<Label>Name</Label>
<Input value={name} onChange={setName} />
</div>
```
When clicked, the library extracts a label from the element (first `<label>`, heading, or text content) and sends it as the query.
You can also pass a custom label directly:
```tsx
<div data-nb-explain="Database connection string">...</div>
```
### Documentation URLs
Attach docs to an element or its parent so the AI can reference them:
```tsx
<div data-nb-explain-docs='["https://docs.example.com/resources"]'>
<div data-nb-explain>...</div>
</div>
```
### Excluding elements
Use `data-nb-explain-ignore` to prevent an element from being selectable in explain mode:
```tsx
<button data-nb-explain-ignore onClick={enterExplainMode}>
Explain
</button>
```
### Setting page/modal context
Use the `useAIAssistant` hook to provide context that gets included in every query:
```tsx
import { useAIAssistant } from "netbird-explain/client";
function MyModal() {
const { setExplainContext, clearExplainContext } = useAIAssistant();
useEffect(() => {
setExplainContext({
modalName: "Add Resource",
pageName: "Networks",
docsUrls: ["https://docs.example.com/networks"],
});
return () => clearExplainContext();
}, []);
return <div data-nb-explain>...</div>;
}
```
This produces queries like: `Explain "Name" on Add Resource modal in Networks`.
### Hook API
`useAIAssistant()` returns:
| Method / Property | Description |
| ------------------------ | ------------------------------------------ |
| `openChat(query?)` | Open the chat panel, optionally with a query |
| `closeChat()` | Close the chat panel |
| `isChatOpen` | Whether the chat panel is open |
| `explainMode` | Whether explain mode is active |
| `enterExplainMode()` | Activate explain mode (click-to-explain) |
| `exitExplainMode()` | Deactivate explain mode |
| `setExplainContext(ctx)` | Set page/modal context for queries |
| `clearExplainContext()` | Clear the context |
### Theming
The package ships with a dark theme out of the box. All visual properties are controlled via CSS custom properties (`--nb-explain-*`) injected into `:root` by the provider. Override any of them in your own CSS to match your app's look and feel.
#### How it works
1. `AIAssistantProvider` injects a `<style>` tag with default values for all `--nb-explain-*` variables.
2. Components use inline styles that reference these variables (e.g., `background: var(--nb-explain-bg)`).
3. Your CSS can override any variable — later declarations on `:root` or more specific selectors win.
#### Overriding in CSS
Add a stylesheet or `<style>` block **after** the provider mounts (or use higher specificity):
```css
:root {
/* Change to a light theme */
--nb-explain-bg: #ffffff;
--nb-explain-bg-subtle: rgba(0, 0, 0, 0.04);
--nb-explain-bg-hover: rgba(0, 0, 0, 0.06);
--nb-explain-border: rgba(0, 0, 0, 0.12);
--nb-explain-text: #1a1a1a;
--nb-explain-text-muted: #6b7280;
--nb-explain-text-dim: #9ca3af;
--nb-explain-accent: #2563eb;
--nb-explain-accent-hover: #3b82f6;
--nb-explain-user-bg: #2563eb;
--nb-explain-user-text: #ffffff;
}
```
#### Full variable reference
| Variable | Default | Description |
| --------------------- | -------------------------------- | ---------------------------------- |
| `--nb-explain-bg` | `#0a0a0f` | Chat panel background |
| `--nb-explain-bg-subtle` | `rgba(255,255,255,0.06)` | Input field & assistant message bg |
| `--nb-explain-bg-hover` | `rgba(255,255,255,0.08)` | Hover state background |
| `--nb-explain-border` | `rgba(255,255,255,0.1)` | Border color |
| `--nb-explain-text` | `#f0f0f5` | Primary text color |
| `--nb-explain-text-muted` | `#9ca3af` | Secondary text color |
| `--nb-explain-text-dim` | `#6b7280` | Placeholder / tertiary text |
| `--nb-explain-accent` | `#eab308` | Accent color (buttons, icons) |
| `--nb-explain-accent-hover` | `#facc15` | Accent hover state |
| `--nb-explain-accent-glow` | `rgba(234,179,8,0.15)` | Accent glow (avatar backgrounds) |
| `--nb-explain-user-bg` | `#4f46e5` | User message bubble background |
| `--nb-explain-user-text` | `#ffffff` | User message text color |
| `--nb-explain-user-glow` | `rgba(79,70,229,0.25)` | User avatar glow |
| `--nb-explain-radius` | `12px` | Panel border radius |
| `--nb-explain-radius-sm` | `8px` | Message bubble border radius |
| `--nb-explain-radius-xs` | `6px` | Button border radius |
| `--nb-explain-font` | system font stack | Font family for all components |
| `--nb-explain-shadow` | large drop shadow | Chat panel box shadow |
| `--nb-explain-banner-bg` | `rgba(234,179,8,0.92)` | Explain mode banner background |
| `--nb-explain-banner-text` | `#000000` | Explain mode banner text |
| `--nb-explain-error-text` | `#f87171` | Error message text |
### Data attributes
| Attribute | Description |
| -------------------- | ------------------------------------------------------------------ |
| `data-nb-explain` | Marks element as explainable. Value can be a custom label or boolean. |
| `data-nb-explain-docs` | JSON array of documentation URLs for context. |
| `data-nb-explain-ignore` | Element is non-interactive during explain mode. |
---
## Server
The server module provides a framework-agnostic handler that proxies chat requests to Anthropic or OpenAI.
### With Express
```ts
import express from "express";
import { createAssistant } from "netbird-explain/server";
const assistant = createAssistant({
provider: "anthropic",
apiKey: process.env.ANTHROPIC_API_KEY!,
model: "claude-sonnet-4-20250514",
systemPrompt: "You are a helpful assistant for MyApp.",
});
const app = express();
app.use(express.json());
app.post("/api/ai/chat", assistant.handler({ apiKey: "your-api-key" }));
app.listen(3080);
```
### With plain Node.js HTTP
```ts
import http from "http";
import { createAssistant } from "netbird-explain/server";
const assistant = createAssistant({
provider: "openai",
apiKey: process.env.OPENAI_API_KEY!,
model: "gpt-4o",
});
const handle = assistant.handler({ apiKey: "your-api-key" });
http.createServer(handle).listen(3080);
```
### Programmatic usage (no HTTP)
```ts
const assistant = createAssistant({
provider: "anthropic",
apiKey: process.env.ANTHROPIC_API_KEY!,
});
const { reply } = await assistant.chat({
messages: [{ role: "user", content: "What is a network resource?" }],
});
```
### `createAssistant(config)`
| Option | Type | Required | Default |
| -------------- | ----------------------------- | -------- | ------------------------ |
| `provider` | `"anthropic"` \| `"openai"` | Yes | — |
| `apiKey` | `string` | Yes | — |
| `model` | `string` | No | Provider default |
| `systemPrompt` | `string` | No | Generic assistant prompt |
### `assistant.handler(opts?)`
Returns a `(req, res) => Promise<void>` handler compatible with Express, plain `http`, and similar frameworks.
| Option | Type | Description |
| -------- | -------- | -------------------------------------------------------------- |
| `apiKey` | `string` | If set, requires `Authorization: Bearer <key>` on requests |
### API contract
**Request** `POST /api/ai/chat`
```json
{
"messages": [
{ "role": "context", "content": "Docs: https://..." },
{ "role": "user", "content": "Explain network routes" }
]
}
```
**Response**
```json
{
"reply": "Network routes allow you to..."
}
```
**Error codes:** `400` (bad request), `401` (unauthorized), `502` (LLM error).
---
## Standalone dev server
The `server/` directory in the dashboard repo contains a ready-to-run Express server for local development.
```bash
cd server
cp .env .env.local # edit with your LLM API key
npm install
node index.js
```
Environment variables:
| Variable | Default | Description |
| ------------------- | -------------------------- | ---------------------- |
| `PORT` | `3080` | Server port |
| `API_KEY` | `nb-ai-dev-key-change-me` | Bearer token for auth |
| `LLM_PROVIDER` | `anthropic` | `anthropic` or `openai` |
| `ANTHROPIC_API_KEY` | — | Anthropic API key |
| `ANTHROPIC_MODEL` | `claude-sonnet-4-20250514` | Model ID |
| `OPENAI_API_KEY` | — | OpenAI API key |
| `OPENAI_MODEL` | `gpt-4o` | Model ID |
| `SYSTEM_PROMPT` | Generic NetBird prompt | System prompt for the LLM |
---
## Full integration example
```tsx
// layout.tsx — wrap app with provider
import { AIAssistantProvider } from "netbird-explain/client";
export default function Layout({ children }) {
return (
<AIAssistantProvider
endpoint={process.env.NEXT_PUBLIC_AI_SERVER_URL || "http://localhost:3080/api/ai/chat"}
apiKey={process.env.NEXT_PUBLIC_AI_API_KEY || "nb-ai-dev-key-change-me"}
>
{children}
</AIAssistantProvider>
);
}
```
```tsx
// MyModal.tsx — add explain support to a modal
import { useAIAssistant } from "netbird-explain/client";
import { Sparkles } from "lucide-react";
function MyModal() {
const { setExplainContext, clearExplainContext, explainMode, enterExplainMode, exitExplainMode } =
useAIAssistant();
useEffect(() => {
setExplainContext({
modalName: "Add Resource",
pageName: "Networks",
docsUrls: ["https://docs.netbird.io/manage/networks"],
});
return () => clearExplainContext();
}, []);
return (
<div data-nb-explain>
<button
data-nb-explain-ignore
onClick={() => (explainMode ? exitExplainMode() : enterExplainMode())}
>
<Sparkles size={13} />
{explainMode ? "Click an element..." : "Explain"}
</button>
<div data-nb-explain>
<label>Name</label>
<input placeholder="e.g., Postgres Database" />
</div>
<div data-nb-explain>
<label>Address</label>
<input placeholder="e.g., 10.0.0.1" />
</div>
</div>
);
}
```
## License
BSD-3-Clause
+113
View File
@@ -0,0 +1,113 @@
{
"name": "netbird-explain",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "netbird-explain",
"version": "0.1.0",
"license": "BSD-3-Clause",
"dependencies": {
"lucide-react": ">=0.300.0"
},
"devDependencies": {
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"typescript": "^5.0.0"
},
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.28",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/lucide-react": {
"version": "0.577.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz",
"integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.4"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT",
"peer": true
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}
+45
View File
@@ -0,0 +1,45 @@
{
"name": "netbird-explain",
"version": "0.1.0",
"description": "Full-stack AI assistant library with React frontend components and Node.js backend handler",
"license": "BSD-3-Clause",
"main": "./dist/client/index.js",
"types": "./dist/client/index.d.ts",
"exports": {
"./client": {
"types": "./dist/client/index.d.ts",
"default": "./dist/client/index.js"
},
"./server": {
"types": "./dist/server/index.d.ts",
"default": "./dist/server/index.js"
}
},
"typesVersions": {
"*": {
"client": ["./dist/client/index.d.ts"],
"server": ["./dist/server/index.d.ts"]
}
},
"files": ["dist", "src"],
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
},
"dependencies": {
"lucide-react": ">=0.300.0"
},
"repository": {
"type": "git",
"url": "https://github.com/netbirdio/explain.git"
},
"devDependencies": {
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"typescript": "^5.0.0"
}
}
+288
View File
@@ -0,0 +1,288 @@
"use client";
import React, {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import type { ExplainContext } from "../types";
import AIChatBot from "./AIChatBot";
import AIFloatingButton from "./AIFloatingButton";
import * as S from "./styles";
type AIAssistantContextType = {
openChat: (selectedText?: string) => void;
closeChat: () => void;
isChatOpen: boolean;
explainMode: boolean;
enterExplainMode: () => void;
exitExplainMode: () => void;
setExplainContext: (ctx: ExplainContext) => void;
clearExplainContext: () => void;
};
const AIAssistantContext = createContext<AIAssistantContextType>({
openChat: () => {},
closeChat: () => {},
isChatOpen: false,
explainMode: false,
enterExplainMode: () => {},
exitExplainMode: () => {},
setExplainContext: () => {},
clearExplainContext: () => {},
});
export const useAIAssistant = () => useContext(AIAssistantContext);
type AIAssistantProviderProps = {
endpoint: string;
apiKey?: string;
children: React.ReactNode;
};
/**
* Find the closest ancestor (or self) with a data-nb-explain attribute.
* Returns null if nothing is explainable.
*/
function findExplainable(el: HTMLElement): HTMLElement | null {
return el.closest("[data-nb-explain]") as HTMLElement | null;
}
/**
* Extract a short label from an explainable element by looking for
* a label, heading, or first bit of text content.
*/
function extractLabel(el: HTMLElement): string {
const label = el.querySelector("label") as HTMLElement | null;
if (label?.innerText?.trim()) return label.innerText.trim();
const heading = el.querySelector("h1, h2, h3, h4") as HTMLElement | null;
if (heading?.innerText?.trim()) return heading.innerText.trim();
const text = el.innerText?.trim();
if (text && text.length <= 80) return text;
if (text) return text.slice(0, 80) + "...";
return "this element";
}
/**
* Look for data-nb-explain-docs on the element or its ancestors.
* Returns an array of documentation URLs, or an empty array.
*/
function findExplainDocs(el: HTMLElement): string[] {
const withDocs = el.closest("[data-nb-explain-docs]") as HTMLElement | null;
if (!withDocs) return [];
const raw = withDocs.getAttribute("data-nb-explain-docs") || "";
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) return parsed;
} catch {
// If not valid JSON, treat as a single URL
if (raw.trim()) return [raw.trim()];
}
return [];
}
function buildQuery(
label: string,
ctx: ExplainContext | null,
elementDocs: string[],
): string {
let userMessage = `Explain "${label}"`;
if (ctx) {
if (ctx.modalName) userMessage += ` on ${ctx.modalName} modal`;
if (ctx.pageName) userMessage += ` in ${ctx.pageName}`;
}
// Merge docs from context and element attribute
const allDocs = [
...(ctx?.docsUrls || []),
...elementDocs,
];
// Deduplicate
const uniqueDocs = [...new Set(allDocs)];
const parts = [userMessage];
if (uniqueDocs.length > 0) {
parts.push(`Docs: ${uniqueDocs.join(", ")}`);
}
return parts.join("\n");
}
export default function AIAssistantProvider({
endpoint,
apiKey,
children,
}: AIAssistantProviderProps) {
const [isChatOpen, setIsChatOpen] = useState(false);
const [initialQuery, setInitialQuery] = useState("");
const [explainMode, setExplainMode] = useState(false);
const [hoveredEl, setHoveredEl] = useState<HTMLElement | null>(null);
const [explainCtx, setExplainCtx] = useState<ExplainContext | null>(null);
const openChat = useCallback((selectedText?: string) => {
setInitialQuery(selectedText || "");
setIsChatOpen(true);
setExplainMode(false);
setHoveredEl(null);
}, []);
const closeChat = useCallback(() => {
setIsChatOpen(false);
setInitialQuery("");
}, []);
const enterExplainMode = useCallback(() => {
setExplainMode(true);
}, []);
const exitExplainMode = useCallback(() => {
setExplainMode(false);
setHoveredEl(null);
}, []);
const setExplainContext = useCallback((ctx: ExplainContext) => {
setExplainCtx(ctx);
}, []);
const clearExplainContext = useCallback(() => {
setExplainCtx(null);
}, []);
// Explain mode: highlight explainable elements on hover, open chat on click
useEffect(() => {
if (!explainMode) return;
const handleMouseOver = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (
target.closest("[data-nb-explain-ignore]") ||
target.closest("[data-nb-explain-banner]")
)
return;
const explainable = findExplainable(target);
setHoveredEl(explainable);
};
const handleMouseOut = () => {
setHoveredEl(null);
};
const handleClick = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (
target.closest("[data-nb-explain-ignore]") ||
target.closest("[data-nb-explain-banner]")
)
return;
e.preventDefault();
e.stopPropagation();
const explainable = findExplainable(target);
if (!explainable) return;
const attrValue = explainable.getAttribute("data-nb-explain") || "";
const label =
attrValue && attrValue !== "true"
? attrValue
: extractLabel(explainable);
const elementDocs = findExplainDocs(explainable);
const query = buildQuery(label, explainCtx, elementDocs);
openChat(query);
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
exitExplainMode();
}
};
document.addEventListener("mouseover", handleMouseOver, true);
document.addEventListener("mouseout", handleMouseOut, true);
document.addEventListener("click", handleClick, true);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mouseover", handleMouseOver, true);
document.removeEventListener("mouseout", handleMouseOut, true);
document.removeEventListener("click", handleClick, true);
document.removeEventListener("keydown", handleKeyDown);
};
}, [explainMode, explainCtx, openChat, exitExplainMode]);
// Apply/remove highlight on hovered explainable element via CSS class
useEffect(() => {
if (!hoveredEl) return;
hoveredEl.setAttribute("data-nb-explain-highlight", "");
return () => {
hoveredEl.removeAttribute("data-nb-explain-highlight");
};
}, [hoveredEl]);
// Force-remove all highlights when leaving explain mode
useEffect(() => {
if (!explainMode) {
document.querySelectorAll("[data-nb-explain-highlight]").forEach((el) => {
el.removeAttribute("data-nb-explain-highlight");
});
}
}, [explainMode]);
return (
<AIAssistantContext.Provider
value={{
openChat,
closeChat,
isChatOpen,
explainMode,
enterExplainMode,
exitExplainMode,
setExplainContext,
clearExplainContext,
}}
>
{/* Inject CSS custom properties, animations, and highlight styles */}
<style>{S.CSS_VARS + S.ANIMATIONS + S.HIGHLIGHT_STYLES}</style>
{children}
{/* Explain mode banner */}
{explainMode && (
<div data-nb-explain-banner style={S.banner}>
<span>Click on a highlighted element to explain it</span>
<button
onClick={() => exitExplainMode()}
style={S.bannerCancel}
>
Cancel
</button>
</div>
)}
<AIFloatingButton
isOpen={isChatOpen}
onClick={() => {
if (isChatOpen) {
closeChat();
} else {
openChat();
}
}}
/>
<AIChatBot
open={isChatOpen}
onClose={closeChat}
initialQuery={initialQuery}
endpoint={endpoint}
apiKey={apiKey}
/>
</AIAssistantContext.Provider>
);
}
+309
View File
@@ -0,0 +1,309 @@
"use client";
import {
Bot,
MessageCircleQuestion,
Send,
Sparkles,
User,
X,
} from "lucide-react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import type { Message } from "../types";
import * as S from "./styles";
type Props = {
open: boolean;
onClose: () => void;
initialQuery: string;
endpoint: string;
apiKey?: string;
};
async function fetchAIResponse(
endpoint: string,
apiKey: string | undefined,
messages: Message[],
): Promise<string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (apiKey) {
headers["Authorization"] = `Bearer ${apiKey}`;
}
const response = await fetch(endpoint, {
method: "POST",
headers,
body: JSON.stringify({
messages: messages.map((m) => ({
role: m.role,
content: m.content,
})),
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`${response.status}: ${text}`);
}
const data = await response.json();
return data.reply;
}
export default function AIChatBot({
open,
onClose,
initialQuery,
endpoint,
apiKey,
}: Props) {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [isTyping, setIsTyping] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const hasProcessedInitialQuery = useRef(false);
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, []);
useEffect(() => {
scrollToBottom();
}, [messages, isTyping, scrollToBottom]);
const getAIResponse = useCallback(
async (allMessages: Message[]) => {
setIsTyping(true);
try {
const reply = await fetchAIResponse(endpoint, apiKey, allMessages);
const response: Message = {
id: Date.now().toString(),
role: "assistant",
content: reply,
};
setMessages((prev) => [...prev, response]);
} catch (err) {
const errorMsg =
err instanceof Error ? err.message : "Unknown error";
const response: Message = {
id: Date.now().toString(),
role: "assistant",
content: `Sorry, I couldn't get a response. Error: ${errorMsg}`,
};
setMessages((prev) => [...prev, response]);
} finally {
setIsTyping(false);
}
},
[endpoint, apiKey],
);
// Handle initial query from explain mode
useEffect(() => {
if (open && initialQuery && !hasProcessedInitialQuery.current) {
hasProcessedInitialQuery.current = true;
const lines = initialQuery.split("\n");
const userMessage = lines[0];
const docsLine = lines.find((l) => l.startsWith("Docs: "));
const msgs: Message[] = [];
if (docsLine) {
msgs.push({
id: Date.now().toString() + "-ctx",
role: "context",
content: docsLine,
});
}
msgs.push({
id: Date.now().toString(),
role: "user",
content: userMessage,
});
setMessages(msgs);
getAIResponse(msgs);
}
}, [open, initialQuery, getAIResponse]);
// Reset when closed
useEffect(() => {
if (!open) {
hasProcessedInitialQuery.current = false;
setMessages([]);
setInput("");
setIsTyping(false);
}
}, [open]);
// Focus input when opened
useEffect(() => {
if (open) {
setTimeout(() => inputRef.current?.focus(), 200);
}
}, [open]);
const sendMessage = useCallback(() => {
const text = input.trim();
if (!text || isTyping) return;
const userMsg: Message = {
id: Date.now().toString(),
role: "user",
content: text,
};
const updatedMessages = [...messages, userMsg];
setMessages(updatedMessages);
setInput("");
getAIResponse(updatedMessages);
}, [input, isTyping, messages, getAIResponse]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};
if (!open) return null;
const active = !!input.trim() && !isTyping;
return (
<div style={S.chatPanel}>
{/* Header */}
<div style={S.chatHeader}>
<div style={S.chatHeaderLeft}>
<div style={S.chatHeaderIcon}>
<Sparkles size={15} style={{ color: "var(--nb-explain-accent)" }} />
</div>
<div>
<h3 style={S.chatHeaderTitle}>AI Assistant</h3>
<span style={S.chatHeaderSubtitle}>Ask anything</span>
</div>
</div>
<button
onClick={onClose}
style={S.chatCloseBtn}
onMouseEnter={(e) => {
e.currentTarget.style.color = "var(--nb-explain-text)";
e.currentTarget.style.background = "var(--nb-explain-bg-hover)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = "var(--nb-explain-text-dim)";
e.currentTarget.style.background = "none";
}}
>
<X size={16} />
</button>
</div>
{/* Messages */}
<div style={S.messagesArea}>
{messages.length === 0 && !isTyping && (
<div style={S.emptyState}>
<MessageCircleQuestion
size={40}
style={{ color: "var(--nb-explain-text-dim)" }}
/>
<div>
<p style={S.emptyStateTitle}>How can I help?</p>
<p style={S.emptyStateHint}>
Use the Explain button to click on any element, or ask a
question below.
</p>
</div>
</div>
)}
{messages.map((msg) =>
msg.role === "context" ? (
<div key={msg.id} style={S.contextBadge}>
<div style={S.contextBadgeInner}>
<Sparkles
size={10}
style={{ color: "var(--nb-explain-accent)", opacity: 0.6 }}
/>
{msg.content}
</div>
</div>
) : (
<div key={msg.id} style={S.messageRow(msg.role === "user")}>
{msg.role === "assistant" && (
<div style={S.messageAvatar(false)}>
<Bot size={13} style={{ color: "var(--nb-explain-accent)" }} />
</div>
)}
<div style={S.messageBubble(msg.role === "user")}>
{msg.content.split("\n").map((line, i) => (
<React.Fragment key={i}>
{line
.split(/(\*\*[^*]+\*\*)/)
.map((part, j) =>
part.startsWith("**") && part.endsWith("**") ? (
<strong key={j} style={S.messageBold}>
{part.slice(2, -2)}
</strong>
) : (
<React.Fragment key={j}>{part}</React.Fragment>
),
)}
{i < msg.content.split("\n").length - 1 && <br />}
</React.Fragment>
))}
</div>
{msg.role === "user" && (
<div style={S.messageAvatar(true)}>
<User size={13} style={{ color: "var(--nb-explain-user-text)" }} />
</div>
)}
</div>
),
)}
{isTyping && (
<div style={S.typingRow}>
<div style={S.messageAvatar(false)}>
<Bot size={13} style={{ color: "var(--nb-explain-accent)" }} />
</div>
<div style={S.typingBubble}>
<span className="nb-explain-dot-1" style={S.typingDot} />
<span className="nb-explain-dot-2" style={S.typingDot} />
<span className="nb-explain-dot-3" style={S.typingDot} />
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div style={S.inputArea}>
<div style={S.inputRow}>
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask a follow-up question..."
style={S.inputField}
/>
<button
onClick={sendMessage}
disabled={!active}
style={S.sendBtn(active)}
>
<Send size={15} />
</button>
</div>
<p style={S.inputFooter}>AI-powered assistant</p>
</div>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
"use client";
import { MessageCircleQuestion, X } from "lucide-react";
import React from "react";
import * as S from "./styles";
type Props = {
isOpen: boolean;
onClick: () => void;
};
export default function AIFloatingButton({ isOpen, onClick }: Props) {
return (
<button
onClick={onClick}
data-nb-explain-ignore
style={S.fab(isOpen)}
onMouseEnter={(e) => {
e.currentTarget.style.transform = "scale(1.05)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = "scale(1)";
}}
onMouseDown={(e) => {
e.currentTarget.style.transform = "scale(0.95)";
}}
onMouseUp={(e) => {
e.currentTarget.style.transform = "scale(1.05)";
}}
>
{isOpen ? <X size={20} /> : <MessageCircleQuestion size={22} />}
</button>
);
}
+4
View File
@@ -0,0 +1,4 @@
export { default as AIAssistantProvider, useAIAssistant } from "./AIAssistantProvider";
export { default as AIChatBot } from "./AIChatBot";
export { default as AIFloatingButton } from "./AIFloatingButton";
export type { Message, ExplainContext } from "../types";
+353
View File
@@ -0,0 +1,353 @@
/**
* All styles for netbird-explain components.
* Uses inline styles + CSS custom properties so the package
* works without any external CSS framework.
*
* Consumers can override via CSS custom properties:
* --nb-explain-bg: panel background
* --nb-explain-bg-subtle: input/message background
* --nb-explain-border: border color
* --nb-explain-text: primary text
* --nb-explain-text-muted: secondary text
* --nb-explain-text-dim: tertiary/placeholder text
* --nb-explain-accent: accent color (yellow)
* --nb-explain-accent-hover: accent hover
* --nb-explain-user-bg: user message background
* --nb-explain-user-text: user message text
* --nb-explain-radius: border radius
* --nb-explain-font: font family
*/
export const CSS_VARS = `
:root {
--nb-explain-bg: #0a0a0f;
--nb-explain-bg-subtle: rgba(255,255,255,0.06);
--nb-explain-bg-hover: rgba(255,255,255,0.08);
--nb-explain-border: rgba(255,255,255,0.1);
--nb-explain-text: #f0f0f5;
--nb-explain-text-muted: #9ca3af;
--nb-explain-text-dim: #6b7280;
--nb-explain-accent: #eab308;
--nb-explain-accent-hover: #facc15;
--nb-explain-accent-glow: rgba(234,179,8,0.15);
--nb-explain-user-bg: #4f46e5;
--nb-explain-user-text: #ffffff;
--nb-explain-user-glow: rgba(79,70,229,0.25);
--nb-explain-radius: 12px;
--nb-explain-radius-sm: 8px;
--nb-explain-radius-xs: 6px;
--nb-explain-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--nb-explain-shadow: 0 25px 50px -12px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.05);
--nb-explain-banner-bg: rgba(234,179,8,0.92);
--nb-explain-banner-text: #000000;
--nb-explain-error-text: #f87171;
}
`;
// --- Chat Panel ---
export const chatPanel: React.CSSProperties = {
position: "fixed",
bottom: 80,
right: 20,
zIndex: 9998,
width: 420,
height: 600,
display: "flex",
flexDirection: "column",
borderRadius: "var(--nb-explain-radius)",
border: "1px solid var(--nb-explain-border)",
background: "var(--nb-explain-bg)",
boxShadow: "var(--nb-explain-shadow)",
fontFamily: "var(--nb-explain-font)",
overflow: "hidden",
animation: "nb-explain-slide-up 0.2s ease-out",
};
export const chatHeader: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "12px 16px",
borderBottom: "1px solid var(--nb-explain-border)",
};
export const chatHeaderLeft: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 10,
};
export const chatHeaderIcon: React.CSSProperties = {
width: 28,
height: 28,
borderRadius: "var(--nb-explain-radius-sm)",
background: "var(--nb-explain-accent-glow)",
display: "flex",
alignItems: "center",
justifyContent: "center",
};
export const chatHeaderTitle: React.CSSProperties = {
fontSize: 14,
fontWeight: 600,
color: "var(--nb-explain-text)",
lineHeight: 1,
margin: 0,
};
export const chatHeaderSubtitle: React.CSSProperties = {
fontSize: 11,
color: "var(--nb-explain-text-dim)",
};
export const chatCloseBtn: React.CSSProperties = {
padding: 6,
borderRadius: "var(--nb-explain-radius-xs)",
color: "var(--nb-explain-text-dim)",
background: "none",
border: "none",
cursor: "pointer",
display: "flex",
alignItems: "center",
transition: "color 0.15s, background 0.15s",
};
// --- Messages ---
export const messagesArea: React.CSSProperties = {
flex: 1,
overflowY: "auto",
padding: "16px",
display: "flex",
flexDirection: "column",
gap: 16,
};
export const emptyState: React.CSSProperties = {
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
height: "100%",
textAlign: "center",
gap: 12,
opacity: 0.5,
};
export const emptyStateTitle: React.CSSProperties = {
fontSize: 14,
color: "var(--nb-explain-text-muted)",
fontWeight: 500,
margin: 0,
};
export const emptyStateHint: React.CSSProperties = {
fontSize: 12,
color: "var(--nb-explain-text-dim)",
marginTop: 4,
};
// Context badge
export const contextBadge: React.CSSProperties = {
display: "flex",
justifyContent: "center",
};
export const contextBadgeInner: React.CSSProperties = {
fontSize: 11,
color: "var(--nb-explain-text-dim)",
background: "var(--nb-explain-bg-subtle)",
borderRadius: 20,
padding: "4px 12px",
display: "flex",
alignItems: "center",
gap: 6,
};
// Message row
export const messageRow = (isUser: boolean): React.CSSProperties => ({
display: "flex",
gap: 10,
justifyContent: isUser ? "flex-end" : "flex-start",
});
export const messageAvatar = (isUser: boolean): React.CSSProperties => ({
width: 24,
height: 24,
borderRadius: "var(--nb-explain-radius-xs)",
background: isUser ? "var(--nb-explain-user-glow)" : "var(--nb-explain-accent-glow)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
marginTop: 2,
});
export const messageBubble = (isUser: boolean): React.CSSProperties => ({
maxWidth: "85%",
borderRadius: "var(--nb-explain-radius-sm)",
padding: "8px 12px",
fontSize: 14,
lineHeight: 1.6,
background: isUser ? "var(--nb-explain-user-bg)" : "var(--nb-explain-bg-subtle)",
color: isUser ? "var(--nb-explain-user-text)" : "var(--nb-explain-text)",
wordBreak: "break-word",
});
export const messageBold: React.CSSProperties = {
fontWeight: 600,
color: "var(--nb-explain-text)",
};
// Typing indicator
export const typingRow: React.CSSProperties = {
display: "flex",
gap: 10,
};
export const typingBubble: React.CSSProperties = {
background: "var(--nb-explain-bg-subtle)",
borderRadius: "var(--nb-explain-radius-sm)",
padding: "12px 16px",
display: "flex",
gap: 6,
};
export const typingDot: React.CSSProperties = {
width: 6,
height: 6,
borderRadius: "50%",
background: "var(--nb-explain-text-dim)",
};
// --- Input area ---
export const inputArea: React.CSSProperties = {
padding: "12px 16px",
borderTop: "1px solid var(--nb-explain-border)",
};
export const inputRow: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 8,
background: "var(--nb-explain-bg-subtle)",
borderRadius: "var(--nb-explain-radius-sm)",
padding: "8px 12px",
};
export const inputField: React.CSSProperties = {
flex: 1,
background: "none",
border: "none",
outline: "none",
fontSize: 14,
color: "var(--nb-explain-text)",
fontFamily: "var(--nb-explain-font)",
};
export const sendBtn = (active: boolean): React.CSSProperties => ({
padding: 6,
borderRadius: "var(--nb-explain-radius-xs)",
background: "none",
border: "none",
cursor: active ? "pointer" : "not-allowed",
color: active ? "var(--nb-explain-accent)" : "var(--nb-explain-text-dim)",
display: "flex",
alignItems: "center",
transition: "color 0.15s",
opacity: active ? 1 : 0.5,
});
export const inputFooter: React.CSSProperties = {
fontSize: 10,
color: "var(--nb-explain-text-dim)",
textAlign: "center",
marginTop: 6,
};
// --- Floating button ---
export const fab = (isOpen: boolean): React.CSSProperties => ({
position: "fixed",
bottom: 20,
right: 20,
zIndex: 9997,
width: 48,
height: 48,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
border: "none",
cursor: "pointer",
transition: "transform 0.2s, box-shadow 0.2s",
boxShadow: "0 4px 20px rgba(0,0,0,0.3)",
background: isOpen
? "var(--nb-explain-bg-hover)"
: "linear-gradient(135deg, var(--nb-explain-accent), #f97316)",
color: isOpen ? "var(--nb-explain-text-muted)" : "#fff",
});
// --- Banner ---
export const banner: React.CSSProperties = {
position: "fixed",
top: 12,
left: "50%",
transform: "translateX(-50%)",
zIndex: 9996,
background: "var(--nb-explain-banner-bg)",
color: "var(--nb-explain-banner-text)",
fontSize: 14,
fontWeight: 500,
padding: "6px 16px",
borderRadius: 20,
boxShadow: "0 4px 20px rgba(234,179,8,0.3)",
display: "flex",
alignItems: "center",
gap: 8,
fontFamily: "var(--nb-explain-font)",
animation: "nb-explain-fade-in 0.2s ease-out",
};
export const bannerCancel: React.CSSProperties = {
marginLeft: 4,
color: "rgba(0,0,0,0.5)",
background: "none",
border: "none",
cursor: "pointer",
textDecoration: "underline",
fontSize: 12,
fontFamily: "var(--nb-explain-font)",
};
// --- Animations (injected as <style>) ---
export const ANIMATIONS = `
@keyframes nb-explain-slide-up {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes nb-explain-fade-in {
from { opacity: 0; transform: translateX(-50%) translateY(-4px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
@keyframes nb-explain-bounce {
0%, 80%, 100% { transform: translateY(0); }
40% { transform: translateY(-4px); }
}
.nb-explain-dot-1 { animation: nb-explain-bounce 1.2s infinite; animation-delay: 0ms; }
.nb-explain-dot-2 { animation: nb-explain-bounce 1.2s infinite; animation-delay: 150ms; }
.nb-explain-dot-3 { animation: nb-explain-bounce 1.2s infinite; animation-delay: 300ms; }
`;
export const HIGHLIGHT_STYLES = `
[data-nb-explain-highlight] {
outline: 2px solid rgba(234, 179, 8, 0.7) !important;
border-radius: 6px;
cursor: help !important;
transition: outline 0.1s ease;
}
`;
+158
View File
@@ -0,0 +1,158 @@
import type { Message } from "../types";
import { AnthropicProvider } from "./providers/anthropic";
import { OpenAIProvider } from "./providers/openai";
import type { LLMProvider } from "./providers/types";
export type AssistantConfig = {
provider: "anthropic" | "openai";
apiKey: string;
model?: string;
systemPrompt?: string;
};
type HandlerOptions = {
apiKey?: string;
};
type ChatRequest = {
messages: Message[];
};
type ChatResponse = {
reply: string;
};
interface IncomingRequest {
headers: Record<string, string | string[] | undefined> | { get(name: string): string | null };
body?: unknown;
method?: string;
on?(event: string, callback: (...args: unknown[]) => void): unknown;
}
interface OutgoingResponse {
status?(code: number): Pick<OutgoingResponse, "json" | "end">;
statusCode?: number;
json?(data: unknown): void;
end?(data?: string): void;
setHeader?(name: string, value: string): void;
writeHead?(statusCode: number, headers?: Record<string, string>): void;
}
function getHeader(req: IncomingRequest, name: string): string | null {
if (typeof req.headers === "object" && req.headers !== null) {
if ("get" in req.headers && typeof req.headers.get === "function") {
return req.headers.get(name);
}
const headers = req.headers as Record<string, string | string[] | undefined>;
const value = headers[name] ?? headers[name.toLowerCase()];
if (Array.isArray(value)) return value[0] ?? null;
return value ?? null;
}
return null;
}
function sendJson(res: OutgoingResponse, statusCode: number, data: unknown): void {
if (typeof res.status === "function" && typeof res.json === "function") {
const chained = res.status(statusCode);
if (chained && typeof chained.json === "function") {
chained.json(data);
return;
}
}
if (typeof res.writeHead === "function" && typeof res.end === "function") {
res.writeHead(statusCode, { "Content-Type": "application/json" });
res.end(JSON.stringify(data));
return;
}
if (typeof res.end === "function") {
if (res.statusCode !== undefined) res.statusCode = statusCode;
res.end(JSON.stringify(data));
}
}
async function parseBody(req: IncomingRequest): Promise<unknown> {
if (req.body !== undefined && req.body !== null) {
return req.body;
}
if (typeof req.on === "function") {
const onFn = req.on.bind(req);
return new Promise((resolve, reject) => {
const chunks: string[] = [];
onFn("data", (chunk: unknown) => chunks.push(String(chunk)));
onFn("end", () => {
try {
resolve(JSON.parse(chunks.join("")));
} catch {
reject(new Error("Invalid JSON body"));
}
});
onFn("error", reject);
});
}
throw new Error("Unable to parse request body");
}
function createProvider(config: AssistantConfig): LLMProvider {
switch (config.provider) {
case "anthropic":
return new AnthropicProvider({ apiKey: config.apiKey, model: config.model });
case "openai":
return new OpenAIProvider({ apiKey: config.apiKey, model: config.model });
default:
throw new Error(`Unknown provider: ${config.provider}`);
}
}
export function createAssistant(config: AssistantConfig) {
const provider = createProvider(config);
async function chat(req: ChatRequest): Promise<ChatResponse> {
if (!req.messages || !Array.isArray(req.messages) || req.messages.length === 0) {
throw new Error("messages array is required and must not be empty");
}
const reply = await provider.chat(req.messages, config.systemPrompt);
return { reply };
}
function handler(opts?: HandlerOptions) {
return async (req: IncomingRequest, res: OutgoingResponse): Promise<void> => {
try {
if (opts?.apiKey) {
const authHeader = getHeader(req, "Authorization") || getHeader(req, "authorization");
const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
if (token !== opts.apiKey) {
sendJson(res, 401, { error: "Unauthorized" });
return;
}
}
let body: unknown;
try {
body = await parseBody(req);
} catch {
sendJson(res, 400, { error: "Invalid request body" });
return;
}
const { messages } = body as { messages?: Message[] };
if (!messages || !Array.isArray(messages) || messages.length === 0) {
sendJson(res, 400, { error: "messages array is required and must not be empty" });
return;
}
try {
const result = await chat({ messages });
sendJson(res, 200, result);
} catch (err) {
const message = err instanceof Error ? err.message : "LLM request failed";
sendJson(res, 502, { error: message });
}
} catch (err) {
const message = err instanceof Error ? err.message : "Internal server error";
sendJson(res, 500, { error: message });
}
};
}
return { chat, handler };
}
+5
View File
@@ -0,0 +1,5 @@
export { createAssistant } from "./handler";
export type { AssistantConfig } from "./handler";
export { AnthropicProvider } from "./providers/anthropic";
export { OpenAIProvider } from "./providers/openai";
export type { LLMProvider, ProviderConfig } from "./providers/types";
+53
View File
@@ -0,0 +1,53 @@
import type { Message } from "../../types";
import type { LLMProvider, ProviderConfig } from "./types";
export class AnthropicProvider implements LLMProvider {
private apiKey: string;
private model: string;
constructor(config: ProviderConfig) {
this.apiKey = config.apiKey;
this.model = config.model || "claude-sonnet-4-20250514";
}
async chat(messages: Message[], systemPrompt?: string): Promise<string> {
const anthropicMessages = messages.map((m) => ({
role: m.role === "context" || m.role === "system" ? ("user" as const) : m.role,
content: m.role === "context" ? `[Context]: ${m.content}` : m.content,
}));
const body: Record<string, unknown> = {
model: this.model,
max_tokens: 4096,
messages: anthropicMessages,
};
if (systemPrompt) {
body.system = systemPrompt;
}
const response = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": this.apiKey,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify(body),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Anthropic API error ${response.status}: ${text}`);
}
const data = await response.json();
const textBlock = data.content?.find(
(block: { type: string }) => block.type === "text",
);
if (!textBlock) {
throw new Error("No text content in Anthropic response");
}
return textBlock.text;
}
}
+55
View File
@@ -0,0 +1,55 @@
import type { Message } from "../../types";
import type { LLMProvider, ProviderConfig } from "./types";
export class OpenAIProvider implements LLMProvider {
private apiKey: string;
private model: string;
constructor(config: ProviderConfig) {
this.apiKey = config.apiKey;
this.model = config.model || "gpt-4o";
}
async chat(messages: Message[], systemPrompt?: string): Promise<string> {
const openaiMessages: Array<{ role: string; content: string }> = [];
if (systemPrompt) {
openaiMessages.push({ role: "system", content: systemPrompt });
}
for (const m of messages) {
if (m.role === "context") {
openaiMessages.push({ role: "user", content: `[Context]: ${m.content}` });
} else if (m.role === "system") {
openaiMessages.push({ role: "user", content: m.content });
} else {
openaiMessages.push({ role: m.role, content: m.content });
}
}
const response = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
model: this.model,
messages: openaiMessages,
max_tokens: 4096,
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`OpenAI API error ${response.status}: ${text}`);
}
const data = await response.json();
const choice = data.choices?.[0];
if (!choice?.message?.content) {
throw new Error("No content in OpenAI response");
}
return choice.message.content;
}
}
+10
View File
@@ -0,0 +1,10 @@
import type { Message } from "../../types";
export interface LLMProvider {
chat(messages: Message[], systemPrompt?: string): Promise<string>;
}
export type ProviderConfig = {
apiKey: string;
model?: string;
};
+11
View File
@@ -0,0 +1,11 @@
export type Message = {
id?: string;
role: "user" | "assistant" | "context" | "system";
content: string;
};
export type ExplainContext = {
modalName?: string;
pageName?: string;
docsUrls?: string[];
};
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}