mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-07 07:55:16 +02:00
chore: add and improve documentation
This commit is contained in:
parent
96f8aaf325
commit
8b820c6d8a
23 changed files with 395 additions and 54 deletions
19
docs/configurations/api-keys.mdx
Normal file
19
docs/configurations/api-keys.mdx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
---
|
||||
title: "API Keys and Service Keys"
|
||||
description: "You can create API Keys to trigger Dograh Voice Agents and Service Keys to use with Inference Providers"
|
||||
---
|
||||
|
||||
The option to create the Keys are in https://app.dograh.com/api-keys if you are using hosted version, or http://localhost:3010/api-keys if you are using the self hosted version.
|
||||
|
||||
### API Keys
|
||||
API keys can be used to trigger a voice agent from an external system, like n8n or programatically from your other workflows. In order to generate that, you can go to `/api-keys` and create a new key.
|
||||
|
||||

|
||||
|
||||
Please note that you must copy and keep the API key secretly, since this is the only time that you would be able to copy it. If you lose it, you can always delete that, if its not being used anywhere, and create a new API key.
|
||||
|
||||
### Service Keys
|
||||
Service Keys are the keys which you generate to be used in [Model Configurations](inference-providers). In order to generate that, you can go to `/api-keys` and create a new key.
|
||||
|
||||

|
||||
|
||||
|
|
@ -5,13 +5,13 @@ description: "Dograh ships with its own inferencing engine, which is hosted at h
|
|||
|
||||
## Configure Inference Provider
|
||||
|
||||
You can go to `https://app.dograh.com/service-configurations` if you are on hosted version of Dograh or go to `http://localhost:3010/service-configurations` if you are running Dograh locally.
|
||||
You can go to `https://app.dograh.com/model-configurations` if you are on hosted version of Dograh or go to `http://localhost:3010/model-configurations` if you are running Dograh locally.
|
||||
|
||||
You can see the configuration for the inference provider in the following screenshot.
|
||||
|
||||

|
||||

|
||||
|
||||
You can select the provider from the dropdown and configure the API key, model, etc.
|
||||
You can select the provider from the dropdown and configure the API key, model, etc. You can see [API Keys](api-keys) documentation for instructions on how to create Service Keys to be used in Model Configuration.
|
||||
|
||||
## Next Steps
|
||||
|
||||
|
|
|
|||
|
|
@ -29,12 +29,32 @@
|
|||
{
|
||||
"group": "Configurations",
|
||||
"pages": [
|
||||
"configurations/inference-providers"
|
||||
"configurations/inference-providers",
|
||||
"configurations/api-keys"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Voice Agent Builder",
|
||||
"pages": [
|
||||
"voice-agent/introduction",
|
||||
"voice-agent/template-variables",
|
||||
{
|
||||
"group": "Nodes",
|
||||
"pages": [
|
||||
"voice-agent/start-call",
|
||||
"voice-agent/end-call",
|
||||
"voice-agent/agent",
|
||||
"voice-agent/global",
|
||||
"voice-agent/api-trigger",
|
||||
"voice-agent/webhook"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Deployment",
|
||||
"pages": [
|
||||
"deployment/introduction",
|
||||
"deployment/docker",
|
||||
"deployment/custom-domain",
|
||||
"deployment/heroku"
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ description: "Open-source alternative to Vapi - build voice AI agents with full
|
|||
|
||||
## Setting up
|
||||
|
||||
Get the platform up and running using Docker with a single command on your local computer. If you are looking to deploy the platform on a server, please check the [Deployment](deployment) section.
|
||||
Get the platform up and running using Docker with a single command on your local computer. If you are looking to deploy the platform on a server, please check the [Deployment](deployment/introduction) section.
|
||||
|
||||
<Note>We collect anonymous usage data to improve the product. You can opt out by setting the `ENABLE_TELEMETRY` to `false` in the below command.</Note>
|
||||
|
||||
|
|
|
|||
BIN
docs/images/api-keys.png
Normal file
BIN
docs/images/api-keys.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 150 KiB |
BIN
docs/images/create-a-voice-agent.png
Normal file
BIN
docs/images/create-a-voice-agent.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 189 KiB |
BIN
docs/images/global-node.png
Normal file
BIN
docs/images/global-node.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 252 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 90 KiB |
BIN
docs/images/service-keys.png
Normal file
BIN
docs/images/service-keys.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 166 KiB |
6
docs/voice-agent/agent.mdx
Normal file
6
docs/voice-agent/agent.mdx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
title: "Agent Node"
|
||||
description: "Agent node contains the prompts that drives the conversation with the Voice Agent"
|
||||
---
|
||||
|
||||
The Edges connected with Agent Nodes are pathways that the LLMs can take depending on how the conversation has been going so far.
|
||||
23
docs/voice-agent/api-trigger.mdx
Normal file
23
docs/voice-agent/api-trigger.mdx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
---
|
||||
title: "API Trigger"
|
||||
description: "API Trigger helps you trigger your Voice Agent using external systems, like N8n or Zapier"
|
||||
---
|
||||
|
||||
### API Payload
|
||||
The API Trigger is a POST request, which requires an [API Key](configurations/api-keys). It expects a valid JSON with `phone_number` and `initial_context`.
|
||||
|
||||
### Initial Context
|
||||
`initial_context` is a valid JSON object, which contains any contextual information that you would want your voice agent to access. You can refer to these values in your prompts using **Handle Bars**, which are values enclosed in `{{` and `}}`.
|
||||
|
||||
Example: If you have below JSON as your `initial_context`
|
||||
```
|
||||
{
|
||||
"initial_context": {
|
||||
"user": {
|
||||
"name": "John"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
you can refer to the user name in your prompts as `{{initial_context.user.name}}`.
|
||||
7
docs/voice-agent/end-call.mdx
Normal file
7
docs/voice-agent/end-call.mdx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
title: "End Call"
|
||||
description: "You can use End Call node to configure how the Agent says its final message right before the call is terminated"
|
||||
---
|
||||
<Note>
|
||||
You should have only one End Call node per Voice Agent.
|
||||
</Note>
|
||||
12
docs/voice-agent/global.mdx
Normal file
12
docs/voice-agent/global.mdx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
---
|
||||
title: "Global Node"
|
||||
description: "Global Node contain the common prompt that is appended to the prompt of every node, in which `Add Global Prompt` is turned on."
|
||||
---
|
||||
|
||||
<Note>
|
||||
You should have only one Global node per Voice Agent.
|
||||
</Note>
|
||||
|
||||

|
||||
|
||||
This node typically contains common instructions, that the Voice Agent should always follow, like tone of the conversation, any objection handling etc.
|
||||
10
docs/voice-agent/introduction.mdx
Normal file
10
docs/voice-agent/introduction.mdx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
title: "Voice Agent Builder"
|
||||
description: "Dograh provides UI components to build a voice Agent. The voice agent can be created by going to https://app.dograh.com/workflow and then Creating a new Agent."
|
||||
---
|
||||
|
||||

|
||||
|
||||
We provide an Agent which quickly helps you get started by creating a voice agent with default prompts and pathways. You can provide inputs like whether you need an "Inbound" or "Outbound" voice agent. You can also provide your use case, and description of what the voice agent should be doing. Your inputs will be sent to an LLM to generate the voice agent, so the better you can describe your use case, the better the starting agent will be created for you.
|
||||
|
||||
Once you create your Voice Agent using our Agent builder, you would be taken to the Agent, where you would have an option to test the agent using "Web Call" or "Phone Call". You can also modify the prompts of the Agent to suit it to your use case better.
|
||||
7
docs/voice-agent/start-call.mdx
Normal file
7
docs/voice-agent/start-call.mdx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
title: "Start Call"
|
||||
description: "You can use Start Call node to Start the call and configure how Agent greets the user when the conversation starts."
|
||||
---
|
||||
<Note>
|
||||
You should have only one Start Call node per Voice Agent.
|
||||
</Note>
|
||||
28
docs/voice-agent/template-variables.mdx
Normal file
28
docs/voice-agent/template-variables.mdx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
title: "Template Variables"
|
||||
description: "You can use Template Variables in your prompts for your Agent nodes, or when constructing the payload for the Webhook Node"
|
||||
---
|
||||
|
||||
### Template Rendering
|
||||
You can reference template variables which is passed as `initial_context` either using the API Trigger or when uploading a Sheet for a campaign. You can also use any extracted variable as `gathered_context`
|
||||
|
||||
The template rendering can take nested values.
|
||||
|
||||
Example: If the initial context is
|
||||
|
||||
```
|
||||
{
|
||||
"initial_context": {
|
||||
"user": {
|
||||
"name": "John"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You can write your prompt to access the user's name as below
|
||||
|
||||
Prompt: `You are Alice, who is talking to {{initial_context.user.name}}.`
|
||||
|
||||
### Nodes
|
||||
Dograh Voice Agents are composed of various nodes. These nodes can provide instructions to the voice agent, help you setup a trigger where you can trigger the voice agent to call someone, or help you setup a webhook, where you can update the results of the call in your CRM or trigger a downstream workflow in n8n. In the next steps, we will be documenting the nodes that you can use in building the voice agent.
|
||||
33
docs/voice-agent/webhook.mdx
Normal file
33
docs/voice-agent/webhook.mdx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
---
|
||||
title: "Webhook"
|
||||
description: "Webhook node allow you to sync the result of Voice Agent runs to your external systems, like CRM or to other workflow orchestrator like Zapier or n8n."
|
||||
---
|
||||
|
||||
<Note>
|
||||
You can have multiple Webhook Nodes for a single Voice Agent, if you want to sync the result of the call at multiple places.
|
||||
</Note>
|
||||
|
||||
### Creating the Webhook Payload
|
||||
The payload can contain a valid JSON, and you can reference variables while constructing that payload. You can reference below variables while constructing the payload.
|
||||
|
||||
- `{{workflow_run_id}}` Unique ID of the Agent run
|
||||
- `{{workflow_id}}` ID of the Agent
|
||||
- `{{workflow_name}}` Name of the workflow
|
||||
- `{{initial_context.*}}` Initial context variables
|
||||
- `{{gathered_context.*}}` Extracted variables
|
||||
- `{{cost_info.call_duration_seconds}}` Call duration
|
||||
- `{{recording_url}}` Call recording URL
|
||||
- `{{transcript_url}}` Transcript URL
|
||||
|
||||
An example of the payload is given below
|
||||
|
||||
```
|
||||
{
|
||||
"call_id": "{{workflow_run_id}}",
|
||||
"first_name": "{{initial_context.first_name}}",
|
||||
"rsvp": "{{gathered_context.rsvp}}",
|
||||
"duration": "{{cost_info.call_duration_seconds}}",
|
||||
"recording_url": "{{recording_url}}",
|
||||
"transcript_url": "{{transcript_url}}"
|
||||
}
|
||||
```
|
||||
|
|
@ -77,7 +77,14 @@ const getNewNode = (type: string, position: { x: number, y: number }, existingNo
|
|||
http_method: "POST" as const,
|
||||
endpoint_url: "",
|
||||
custom_headers: [],
|
||||
payload_template: {},
|
||||
payload_template: {
|
||||
call_id: "{{workflow_run_id}}",
|
||||
first_name: "{{initial_context.first_name}}",
|
||||
rsvp: "{{gathered_context.rsvp}}",
|
||||
duration: "{{cost_info.call_duration_seconds}}",
|
||||
recording_url: "{{recording_url}}",
|
||||
transcript_url: "{{transcript_url}}",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Globe, Headset, Link2, LucideIcon, OctagonX, Play, Webhook, X } from 'lucide-react';
|
||||
import { ExternalLink, Globe, Headset, Link2, LucideIcon, OctagonX, Play, Webhook, X } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
|
@ -125,7 +125,18 @@ export default function AddNodePanel({ isOpen, onNodeSelect, onClose }: AddNodeP
|
|||
>
|
||||
<div className="p-4 h-full overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-lg font-semibold">Add New Node</h2>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-lg font-semibold">Add New Node</h2>
|
||||
<a
|
||||
href="https://docs.dograh.com/voice-agent/introduction"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-muted-foreground hover:text-primary flex items-center gap-1 transition-colors"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
View Nodes Documentation
|
||||
</a>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
|
||||
import { AlertCircle, Check, Circle, Copy, Edit, Link2, Loader2, PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import { AlertCircle, Circle, Edit, Link2, Loader2, PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import { memo, useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
|
||||
|
|
@ -19,6 +19,7 @@ import {
|
|||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { JsonEditor, validateJson } from "@/components/ui/json-editor";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
|
|
@ -29,7 +30,6 @@ import {
|
|||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
import { NodeContent } from "./common/NodeContent";
|
||||
|
|
@ -93,13 +93,25 @@ export const WebhookNode = memo(({ data, selected, id }: WebhookNodeProps) => {
|
|||
}
|
||||
}, [getAccessToken]);
|
||||
|
||||
// Validation state - only shown on save attempt
|
||||
const [jsonError, setJsonError] = useState<string | null>(null);
|
||||
const [endpointError, setEndpointError] = useState<string | null>(null);
|
||||
|
||||
const handleSave = async () => {
|
||||
let parsedPayload = {};
|
||||
try {
|
||||
parsedPayload = JSON.parse(payloadTemplate);
|
||||
} catch {
|
||||
// Keep empty object if invalid JSON
|
||||
// Validate endpoint URL
|
||||
if (!endpointUrl.trim()) {
|
||||
setEndpointError('Endpoint URL is required');
|
||||
return;
|
||||
}
|
||||
setEndpointError(null);
|
||||
|
||||
// Validate JSON payload
|
||||
const validation = validateJson(payloadTemplate);
|
||||
if (!validation.valid) {
|
||||
setJsonError(validation.error || 'Invalid JSON. Please fix the payload template before saving.');
|
||||
return;
|
||||
}
|
||||
setJsonError(null);
|
||||
|
||||
handleSaveNodeData({
|
||||
...data,
|
||||
|
|
@ -109,7 +121,7 @@ export const WebhookNode = memo(({ data, selected, id }: WebhookNodeProps) => {
|
|||
endpoint_url: endpointUrl,
|
||||
credential_uuid: credentialUuid || undefined,
|
||||
custom_headers: customHeaders.filter((h) => h.key && h.value),
|
||||
payload_template: parsedPayload,
|
||||
payload_template: validation.parsed as Record<string, unknown>,
|
||||
});
|
||||
setOpen(false);
|
||||
setTimeout(async () => {
|
||||
|
|
@ -128,6 +140,9 @@ export const WebhookNode = memo(({ data, selected, id }: WebhookNodeProps) => {
|
|||
setPayloadTemplate(
|
||||
data.payload_template ? JSON.stringify(data.payload_template, null, 2) : "{}"
|
||||
);
|
||||
// Clear any previous errors
|
||||
setJsonError(null);
|
||||
setEndpointError(null);
|
||||
// Fetch credentials when dialog opens
|
||||
fetchCredentials();
|
||||
}
|
||||
|
|
@ -204,6 +219,7 @@ export const WebhookNode = memo(({ data, selected, id }: WebhookNodeProps) => {
|
|||
nodeData={data}
|
||||
title="Edit Webhook"
|
||||
onSave={handleSave}
|
||||
error={endpointError || jsonError}
|
||||
>
|
||||
{open && (
|
||||
<WebhookNodeEditForm
|
||||
|
|
@ -273,8 +289,6 @@ const WebhookNodeEditForm = ({
|
|||
payloadTemplate,
|
||||
setPayloadTemplate,
|
||||
}: WebhookNodeEditFormProps) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Add Credential Dialog state
|
||||
const [isAddCredentialOpen, setIsAddCredentialOpen] = useState(false);
|
||||
const [newCredName, setNewCredName] = useState("");
|
||||
|
|
@ -365,12 +379,6 @@ const WebhookNodeEditForm = ({
|
|||
}
|
||||
};
|
||||
|
||||
const handleCopyPayload = async () => {
|
||||
await navigator.clipboard.writeText(payloadTemplate);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const addHeader = () => {
|
||||
setCustomHeaders([...customHeaders, { key: "", value: "" }]);
|
||||
};
|
||||
|
|
@ -387,14 +395,11 @@ const WebhookNodeEditForm = ({
|
|||
|
||||
const availableVariables = [
|
||||
{ name: "workflow_run_id", description: "Unique ID of the workflow run" },
|
||||
{ name: "workflow_run_name", description: "Name of the workflow run" },
|
||||
{ name: "workflow_id", description: "ID of the workflow" },
|
||||
{ name: "workflow_name", description: "Name of the workflow" },
|
||||
{ name: "initial_context.*", description: "Initial context variables" },
|
||||
{ name: "gathered_context.*", description: "Extracted variables" },
|
||||
{ name: "cost_info.call_duration_seconds", description: "Call duration" },
|
||||
{ name: "completed_at", description: "Completion timestamp" },
|
||||
{ name: "disposition_code", description: "Final disposition code" },
|
||||
{ name: "recording_url", description: "Call recording URL" },
|
||||
{ name: "transcript_url", description: "Transcript URL" },
|
||||
];
|
||||
|
|
@ -643,32 +648,14 @@ const WebhookNodeEditForm = ({
|
|||
</TabsContent>
|
||||
|
||||
<TabsContent value="payload" className="space-y-4 mt-4">
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Payload Template (JSON)</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCopyPayload}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 mr-1" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Define the JSON payload. Use {"{{variable}}"} syntax for dynamic values.
|
||||
</Label>
|
||||
<Textarea
|
||||
value={payloadTemplate}
|
||||
onChange={(e) => setPayloadTemplate(e.target.value)}
|
||||
className="min-h-[200px] font-mono text-sm"
|
||||
placeholder='{"call_id": "{{workflow_run_id}}"}'
|
||||
/>
|
||||
</div>
|
||||
<JsonEditor
|
||||
value={payloadTemplate}
|
||||
onChange={setPayloadTemplate}
|
||||
label="Payload Template (JSON)"
|
||||
description='Define the JSON payload. Use "{{variable}}" syntax for dynamic values (must be quoted strings).'
|
||||
placeholder='{"call_id": "{{workflow_run_id}}", "name": "{{initial_context.name}}"}'
|
||||
minHeight="200px"
|
||||
/>
|
||||
|
||||
<div className="border rounded-md p-3 bg-muted/20">
|
||||
<Label className="text-sm font-medium">Available Variables</Label>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ interface NodeEditDialogProps {
|
|||
title: string;
|
||||
children: ReactNode;
|
||||
onSave?: () => void;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export const NodeEditDialog = ({
|
||||
|
|
@ -20,7 +21,8 @@ export const NodeEditDialog = ({
|
|||
nodeData,
|
||||
title,
|
||||
children,
|
||||
onSave
|
||||
onSave,
|
||||
error
|
||||
}: NodeEditDialogProps) => {
|
||||
const handleClose = () => onOpenChange(false);
|
||||
|
||||
|
|
@ -51,6 +53,12 @@ export const NodeEditDialog = ({
|
|||
<div className="grid gap-4 py-4">
|
||||
{children}
|
||||
</div>
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-red-50 p-3 text-sm text-red-600 border border-red-200">
|
||||
<AlertCircle className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleClose}>Cancel</Button>
|
||||
|
|
|
|||
163
ui/src/components/ui/json-editor.tsx
Normal file
163
ui/src/components/ui/json-editor.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { AlertCircle, Check, Copy } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
interface JsonEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
error?: string | null;
|
||||
minHeight?: string;
|
||||
showCopyButton?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface JsonValidationResult {
|
||||
valid: boolean;
|
||||
parsed: Record<string, unknown> | unknown[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates JSON and provides helpful error messages for common mistakes
|
||||
*/
|
||||
export function validateJson(jsonString: string): JsonValidationResult {
|
||||
const trimmed = jsonString.trim();
|
||||
|
||||
// Empty or default empty object is valid
|
||||
if (!trimmed || trimmed === '{}' || trimmed === '[]') {
|
||||
return { valid: true, parsed: trimmed === '[]' ? [] : {} };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
return { valid: true, parsed };
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : 'Invalid JSON';
|
||||
|
||||
// Detect common mistakes and provide helpful messages
|
||||
const helpfulError = getHelpfulJsonError(trimmed, errorMessage);
|
||||
return { valid: false, parsed: {}, error: helpfulError };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyzes JSON string and error to provide more helpful error messages
|
||||
*/
|
||||
function getHelpfulJsonError(jsonString: string, originalError: string): string {
|
||||
// Check for unquoted template variables like {{variable}} instead of "{{variable}}"
|
||||
const unquotedTemplateVar = /:\s*\{\{[^}]+\}\}/.test(jsonString);
|
||||
if (unquotedTemplateVar) {
|
||||
return 'Template variables must be quoted strings. Use "{{variable}}" instead of {{variable}}';
|
||||
}
|
||||
|
||||
// Check for trailing comma before } or ]
|
||||
const trailingComma = /,\s*[}\]]/.test(jsonString);
|
||||
if (trailingComma) {
|
||||
return 'Trailing comma detected. Remove the comma before the closing bracket.';
|
||||
}
|
||||
|
||||
// Check for missing comma between properties
|
||||
const missingComma = /"\s*\n\s*"/.test(jsonString) || /}\s*\n\s*"/.test(jsonString);
|
||||
if (missingComma) {
|
||||
return 'Missing comma between properties. Add a comma after each value.';
|
||||
}
|
||||
|
||||
// Check for single quotes instead of double quotes
|
||||
const singleQuotes = /'[^']*'\s*:/.test(jsonString) || /:\s*'[^']*'/.test(jsonString);
|
||||
if (singleQuotes) {
|
||||
return 'JSON requires double quotes. Use "key" instead of \'key\'.';
|
||||
}
|
||||
|
||||
// Check for unquoted string values
|
||||
const unquotedValue = /:\s*[a-zA-Z][a-zA-Z0-9_]*\s*[,}\]]/.test(jsonString);
|
||||
if (unquotedValue && !jsonString.includes('true') && !jsonString.includes('false') && !jsonString.includes('null')) {
|
||||
return 'String values must be quoted. Use "value" instead of value.';
|
||||
}
|
||||
|
||||
// Check for unquoted keys
|
||||
const unquotedKey = /{\s*[a-zA-Z][a-zA-Z0-9_]*\s*:/.test(jsonString) || /,\s*[a-zA-Z][a-zA-Z0-9_]*\s*:/.test(jsonString);
|
||||
if (unquotedKey) {
|
||||
return 'Property names must be quoted. Use "key" instead of key.';
|
||||
}
|
||||
|
||||
// Extract position info from error if available
|
||||
const positionMatch = originalError.match(/position (\d+)/i);
|
||||
if (positionMatch) {
|
||||
const position = parseInt(positionMatch[1], 10);
|
||||
const lines = jsonString.substring(0, position).split('\n');
|
||||
const line = lines.length;
|
||||
const column = lines[lines.length - 1].length + 1;
|
||||
return `Invalid JSON at line ${line}, column ${column}. Check for missing quotes, commas, or brackets.`;
|
||||
}
|
||||
|
||||
return 'Invalid JSON syntax. Check for missing quotes, commas, or brackets.';
|
||||
}
|
||||
|
||||
export function JsonEditor({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = '{}',
|
||||
label,
|
||||
description,
|
||||
error,
|
||||
minHeight = "200px",
|
||||
showCopyButton = true,
|
||||
className = "",
|
||||
}: JsonEditorProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div className={`grid gap-2 ${className}`}>
|
||||
{(label || showCopyButton) && (
|
||||
<div className="flex items-center justify-between">
|
||||
{label && <Label>{label}</Label>}
|
||||
{showCopyButton && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
type="button"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 mr-1" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
Copy
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{description && (
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{description}
|
||||
</Label>
|
||||
)}
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={`font-mono text-sm`}
|
||||
style={{ minHeight }}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 p-2 text-sm text-red-600 bg-red-50 border border-red-200 rounded-md">
|
||||
<AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue