Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 44 additions & 6 deletions frontend/public/widget.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,43 @@
var panel = document.createElement('div');
panel.className = 'xagent-widget-panel';

// Generate guest_id if not exists
var guestId = localStorage.getItem('xagent_guest_id');
if (!guestId) {
guestId = 'guest_' + Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
localStorage.setItem('xagent_guest_id', guestId);
// Identify the guest. If the embedding page knows who the visitor is
// (e.g. it renders the snippet server-side for a logged-in user), it can
// pass that identity via data-end-user-id so the widget session is scoped
// to that specific user instead of an anonymous per-browser id. This value
// is supplied by the embedding page on every load, so it is not cached.
//
// An identity claim is only trustworthy if it's signed: without
// data-end-user-signature, anyone viewing this page's source (or calling
// /api/widget/auth directly) could claim to be any other user and read
// their conversation history. The signature must be computed server-side
// by the embedding site using the agent's widget_end_user_secret (from
// App Widget settings) -- never compute it in browser JS, which would
// expose the secret to the same visitors it's meant to authenticate.
var endUserId = (scriptTag.getAttribute('data-end-user-id') || '').trim();
var endUserSignature = (scriptTag.getAttribute('data-end-user-signature') || '').trim();
var guestId = null;
if (endUserId && endUserId.length > 256) {
Comment thread
yiboyasss marked this conversation as resolved.
// Silently truncating would still send the truncated id, but the
// embedding server signed the FULL id -- the signature would no longer
// match it, surfacing as a confusing "Invalid end-user signature" error
// instead of an actionable one here.
console.error('Xagent Widget: data-end-user-id exceeds 256 characters; ignoring it and falling back to an anonymous session.');
endUserId = '';
}
if (endUserId && !endUserSignature) {
console.error('Xagent Widget: data-end-user-id was provided without data-end-user-signature; ignoring it and falling back to an anonymous session. Sign end_user_id server-side with the agent\'s end-user secret (see App Widget settings).');
endUserId = '';
}
if (!endUserId) {
// No verified end-user identity: fall back to an anonymous per-browser
// id, generated once and cached so the same browser resumes the same
// conversation across page loads.
guestId = localStorage.getItem('xagent_guest_id');
if (!guestId) {
guestId = 'guest_' + Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
localStorage.setItem('xagent_guest_id', guestId);
}
}

// Iframe
Expand All @@ -119,7 +151,13 @@
// The widget key is deliberately NOT placed in the iframe URL: the ticket
// is sufficient to authenticate, and keeping the key out of the frame
// means the embedded widget has no credential to fall back on.
var url = host + '/widget/chat/' + token + '?guest_id=' + guestId;
var url = host + '/widget/chat/' + token;
if (endUserId) {
url += '?end_user_id=' + encodeURIComponent(endUserId)
+ '&end_user_signature=' + encodeURIComponent(endUserSignature);
} else {
url += '?guest_id=' + encodeURIComponent(guestId);
}
if (agentId) {
url += '&agent_id=' + encodeURIComponent(agentId);
}
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/app/widget/chat/[token]/page-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ function WidgetChatInner() {
authMode="widget"
routeToken={token}
guestId={searchParams.get("guest_id")}
endUserId={searchParams.get("end_user_id")}
endUserSignature={searchParams.get("end_user_signature")}
searchAgentId={searchParams.get("agent_id") ? parseInt(searchParams.get("agent_id") as string, 10) : null}
embedTicket={searchParams.get("embed_ticket")}
widgetKey={searchParams.get("widget_key")}
Expand Down
157 changes: 0 additions & 157 deletions frontend/src/components/build/agent-builder-preview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -304,161 +304,4 @@ describe("AgentBuilder preview", () => {
expect(closeFilePreviewMock).toHaveBeenCalledTimes(1)
})

it("shows App Widget settings for an existing agent without adding it to trigger dialog", async () => {
apiRequestMock.mockImplementation((url: string) => {
if (url === "http://api.local/api/agents/42") {
return Promise.resolve(new Response(JSON.stringify({
id: 42,
name: "Widget Agent",
description: "Helps website visitors",
instructions: "Answer visitor questions",
execution_mode: "balanced",
suggested_prompts: [],
knowledge_bases: [],
skills: [],
tool_categories: [],
models: { general: 7 },
logo_url: null,
status: "draft",
widget_enabled: true,
allowed_domains: ["example.com"],
}), { status: 200 }))
}
if (url === "http://api.local/api/agents/42/triggers") {
return Promise.resolve(new Response(JSON.stringify([]), { status: 200 }))
}
if (url.endsWith("/api/agents/42/widget-key")) {
return Promise.resolve(new Response(JSON.stringify({
agent_id: 42,
widget_enabled: true,
widget_key: "wk-preview-key",
}), { status: 200 }))
}
if (url.endsWith("/api/kb/collections")) {
return Promise.resolve(new Response(JSON.stringify({ collections: [] }), { status: 200 }))
}
if (url.endsWith("/api/skills/")) {
return Promise.resolve(new Response(JSON.stringify([]), { status: 200 }))
}
if (url.endsWith("/api/tools/available")) {
return Promise.resolve(new Response(JSON.stringify({ tools: [] }), { status: 200 }))
}
if (url.endsWith("/api/models/?category=llm")) {
return Promise.resolve(new Response(JSON.stringify([
{ id: 7, model_id: "gpt-test", model_name: "GPT Test", model_provider: "test", category: "llm" },
]), { status: 200 }))
}
if (url.endsWith("/api/models/user-default")) {
return Promise.resolve(new Response(JSON.stringify([]), { status: 200 }))
}
if (url.endsWith("/api/mcp/servers")) {
return Promise.resolve(new Response(JSON.stringify([]), { status: 200 }))
}
return Promise.resolve(new Response(JSON.stringify({}), { status: 200 }))
})

render(<AgentBuilder agentId="42" />)

expect(await screen.findByText("appWidget.builder.title")).toBeInTheDocument()
expect(screen.getByRole("switch", { name: "appWidget.builder.toggle" })).toBeChecked()

fireEvent.click(screen.getByText("appWidget.builder.title"))

expect(await screen.findByText("appWidget.dialog.title")).toBeInTheDocument()
expect(screen.getByText("example.com")).toBeInTheDocument()
expect(screen.getByText((content) => content.includes("widget.js"))).toBeInTheDocument()
expect(
await screen.findByText((content) => content.includes('data-widget-key="wk-preview-key"')),
).toBeInTheDocument()
expect(screen.queryByText("triggers.cards.appWidget.title")).not.toBeInTheDocument()
})

it("updates widget_enabled when the builder card switch is toggled", async () => {
const widgetAgent = {
id: 42,
name: "Widget Agent",
description: "Helps website visitors",
instructions: "Answer visitor questions",
execution_mode: "balanced",
suggested_prompts: [],
knowledge_bases: [],
skills: [],
tool_categories: [],
models: { general: 7 },
logo_url: null,
status: "draft",
widget_enabled: true,
allowed_domains: ["example.com"],
}
apiRequestMock.mockImplementation((url: string, options?: { method?: string; body?: string }) => {
if (url === "http://api.local/api/agents/42" && options?.method === "PUT") {
const updates = options?.body ? JSON.parse(options.body) : {}
return Promise.resolve(new Response(JSON.stringify({ ...widgetAgent, ...updates }), { status: 200 }))
}
if (url === "http://api.local/api/agents/42") {
return Promise.resolve(new Response(JSON.stringify(widgetAgent), { status: 200 }))
}
if (url === "http://api.local/api/agents/42/triggers") {
return Promise.resolve(new Response(JSON.stringify([]), { status: 200 }))
}
if (url.endsWith("/api/agents/42/widget-key")) {
return Promise.resolve(new Response(JSON.stringify({
agent_id: 42,
widget_enabled: true,
widget_key: "wk-preview-key",
}), { status: 200 }))
}
if (url.endsWith("/api/kb/collections")) {
return Promise.resolve(new Response(JSON.stringify({ collections: [] }), { status: 200 }))
}
if (url.endsWith("/api/skills/")) {
return Promise.resolve(new Response(JSON.stringify([]), { status: 200 }))
}
if (url.endsWith("/api/tools/available")) {
return Promise.resolve(new Response(JSON.stringify({ tools: [] }), { status: 200 }))
}
if (url.endsWith("/api/models/?category=llm")) {
return Promise.resolve(new Response(JSON.stringify([
{ id: 7, model_id: "gpt-test", model_name: "GPT Test", model_provider: "test", category: "llm" },
]), { status: 200 }))
}
if (url.endsWith("/api/models/user-default")) {
return Promise.resolve(new Response(JSON.stringify([]), { status: 200 }))
}
if (url.endsWith("/api/mcp/servers")) {
return Promise.resolve(new Response(JSON.stringify([]), { status: 200 }))
}
return Promise.resolve(new Response(JSON.stringify({}), { status: 200 }))
})

render(<AgentBuilder agentId="42" />)

const widgetSwitch = await screen.findByRole("switch", { name: "appWidget.builder.toggle" })
expect(widgetSwitch).toBeChecked()

fireEvent.click(widgetSwitch)

await waitFor(() => {
expect(apiRequestMock).toHaveBeenCalledWith("http://api.local/api/agents/42", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ widget_enabled: false }),
})
})
await waitFor(() => {
expect(screen.getByRole("switch", { name: "appWidget.builder.toggle" })).not.toBeChecked()
})
})

it("shows a save-first App Widget state before an agent exists", async () => {
render(<AgentBuilder />)

expect(await screen.findByText("appWidget.builder.title")).toBeInTheDocument()
expect(screen.getByText("appWidget.builder.saveFirst")).toBeInTheDocument()
expect(screen.getByTitle("appWidget.builder.configure")).toBeDisabled()

fireEvent.click(screen.getByTitle("appWidget.builder.configure"))

expect(screen.queryByText("appWidget.dialog.title")).not.toBeInTheDocument()
})
})
92 changes: 2 additions & 90 deletions frontend/src/components/build/agent-builder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { apiRequest } from "@/lib/api-wrapper"
import { getApiUrl } from "@/lib/utils"
import { PlusCircle, MessageSquare, Upload, Settings2, Check, Zap, BookOpen, ChevronLeft, Gauge, Sparkles, Loader2, X, XCircle, Trash2, Bot, Brain, Webhook, CalendarClock, Mail, Code2 } from "lucide-react"
import { PlusCircle, MessageSquare, Upload, Settings2, Check, Zap, BookOpen, ChevronLeft, Gauge, Sparkles, Loader2, X, XCircle, Trash2, Bot, Brain, Webhook, CalendarClock, Mail } from "lucide-react"
import { ConnectMcpDialog } from "@/components/mcp/connect-mcp-dialog"
import { useI18n } from "@/contexts/i18n-context"
import { useApp } from "@/contexts/app-context-chat"
Expand Down Expand Up @@ -42,8 +42,6 @@ import { BuildFilePreviewSheet } from "./build-file-preview-sheet"
import { TaskConversationPanel } from "@/components/task/task-conversation-panel"
import { AgentTriggersDialog } from "./agent-triggers-dialog"
import { AgentTrigger, AgentTriggerType, listAgentTriggers } from "@/lib/agent-triggers-api"
import { AgentWidgetSettingsDialog } from "./agent-widget-settings-dialog"
import { updateAgentWidgetConfig } from "@/lib/agent-widget-config"

interface KnowledgeBase {
name: string
Expand Down Expand Up @@ -145,8 +143,6 @@ export function AgentBuilder({ agentId }: AgentBuilderProps) {
const [isKbModalOpen, setIsKbModalOpen] = useState(false)
const [isModelConfigOpen, setIsModelConfigOpen] = useState(false)
const [isTriggersDialogOpen, setIsTriggersDialogOpen] = useState(false)
const [isWidgetSettingsOpen, setIsWidgetSettingsOpen] = useState(false)
const [isWidgetUpdating, setIsWidgetUpdating] = useState(false)
const [triggerDialogInitialType, setTriggerDialogInitialType] = useState<AgentTriggerType | null>(null)
const [triggerSummary, setTriggerSummary] = useState<AgentTrigger[]>([])
const [triggerSummaryLoading, setTriggerSummaryLoading] = useState(false)
Expand Down Expand Up @@ -216,37 +212,6 @@ export function AgentBuilder({ agentId }: AgentBuilderProps) {
return stats
}, [triggerSummary])

const appWidgetConfig = useMemo(() => {
const source = originalData ?? createdAgent
return {
widget_enabled: Boolean(source?.widget_enabled),
allowed_domains: Array.isArray(source?.allowed_domains) ? source.allowed_domains : [],
}
}, [createdAgent, originalData])

const mergeWidgetAgentData = useCallback((updatedAgent: Record<string, unknown>) => {
setOriginalData((current: any) => current ? { ...current, ...updatedAgent } : updatedAgent)
setCreatedAgent((current: any) => current ? { ...current, ...updatedAgent } : current)
}, [])

const handleWidgetEnabledChange = useCallback(async (checked: boolean) => {
if (!localAgentId) return
setIsWidgetUpdating(true)
try {
const updatedAgent = await updateAgentWidgetConfig(
localAgentId,
{ widget_enabled: checked },
t("appWidget.messages.updateFailed"),
)
mergeWidgetAgentData(updatedAgent)
toast.success(t("appWidget.messages.updated"))
} catch (error) {
toast.error(error instanceof Error ? error.message : t("appWidget.messages.updateFailed"))
} finally {
setIsWidgetUpdating(false)
}
}, [localAgentId, mergeWidgetAgentData, t])

const gmailConnection = useMemo(() => {
const gmailApp = findMatchingMcpApp(officialApps, "gmail")
return {
Expand Down Expand Up @@ -1869,7 +1834,7 @@ export function AgentBuilder({ agentId }: AgentBuilderProps) {

<div className={cn(
"space-y-2 rounded-lg border bg-card/40 p-2.5",
(triggerSummary.some((trigger) => trigger.enabled) || appWidgetConfig.widget_enabled) && "border-primary/70",
triggerSummary.some((trigger) => trigger.enabled) && "border-primary/70",
)}>
<div className="flex flex-wrap items-center justify-between gap-3 px-0.5">
<div className="flex items-center gap-1.5">
Expand Down Expand Up @@ -1981,50 +1946,6 @@ export function AgentBuilder({ agentId }: AgentBuilderProps) {
</div>
)
})}
<div
className={cn(
"flex min-w-0 items-center gap-2 rounded-md border bg-background px-2.5 py-2 text-left transition-colors",
appWidgetConfig.widget_enabled && "border-primary/40 bg-primary/[0.03]",
!localAgentId && "opacity-60",
)}
>
<button
type="button"
disabled={!localAgentId}
onClick={() => setIsWidgetSettingsOpen(true)}
className="flex min-w-0 flex-1 items-center gap-2 text-left disabled:cursor-not-allowed"
>
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded bg-sky-50 text-sky-700 dark:bg-sky-950/40 dark:text-sky-300">
<Code2 className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">{t("appWidget.builder.title")}</span>
</div>
<div className="mt-0.5 truncate text-xs text-muted-foreground">
{localAgentId ? t("appWidget.builder.description") : t("appWidget.builder.saveFirst")}
</div>
</div>
</button>
<Switch
aria-label={t("appWidget.builder.toggle")}
checked={appWidgetConfig.widget_enabled}
disabled={!localAgentId || isWidgetUpdating}
onCheckedChange={(checked) => void handleWidgetEnabledChange(checked)}
className="scale-75"
/>
<Button
type="button"
variant="ghost"
size="icon"
disabled={!localAgentId}
onClick={() => setIsWidgetSettingsOpen(true)}
className="h-7 w-7 text-muted-foreground hover:text-foreground"
title={t("appWidget.builder.configure")}
>
<Settings2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>

Expand Down Expand Up @@ -2298,15 +2219,6 @@ export function AgentBuilder({ agentId }: AgentBuilderProps) {
}}
/>

<AgentWidgetSettingsDialog
agentId={localAgentId ? Number(localAgentId) : null}
agentName={name}
open={isWidgetSettingsOpen}
onOpenChange={setIsWidgetSettingsOpen}
widgetConfig={appWidgetConfig}
onWidgetConfigUpdated={mergeWidgetAgentData}
/>

{state.filePreview.isOpen && (
<div className="absolute inset-y-0 right-0 z-50 w-full max-w-[720px] p-4 pointer-events-none">
<div className="h-full pointer-events-auto">
Expand Down
Loading
Loading