mirror of
https://github.com/netbirdio/explain.git
synced 2026-05-22 18:44:28 -07:00
Init
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
.idea/
|
||||
@@ -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
|
||||
Generated
+113
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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[];
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user