diff --git a/web/frontend/src/components/chat/chat-page.tsx b/web/frontend/src/components/chat/chat-page.tsx
index ebcde8981..ae705ff1b 100644
--- a/web/frontend/src/components/chat/chat-page.tsx
+++ b/web/frontend/src/components/chat/chat-page.tsx
@@ -39,7 +39,7 @@ export function ChatPage() {
const {
defaultModelName,
- hasConfiguredModels,
+ hasAvailableModels,
apiKeyModels,
oauthModels,
localModels,
@@ -94,7 +94,7 @@ export function ChatPage() {
hasScrolled ? "shadow-sm" : "shadow-none"
}`}
titleExtra={
- hasConfiguredModels && (
+ hasAvailableModels && (
{messages.length === 0 && !isTyping && (
diff --git a/web/frontend/src/components/config/config-page.tsx b/web/frontend/src/components/config/config-page.tsx
index 664e75440..cbe4d8e91 100644
--- a/web/frontend/src/components/config/config-page.tsx
+++ b/web/frontend/src/components/config/config-page.tsx
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { patchAppConfig } from "@/api/channels"
+import { launcherFetch } from "@/api/http"
import {
getAutoStartStatus,
getLauncherConfig,
@@ -50,7 +51,7 @@ export function ConfigPage() {
const { data, isLoading, error } = useQuery({
queryKey: ["config"],
queryFn: async () => {
- const res = await fetch("/api/config")
+ const res = await launcherFetch("/api/config")
if (!res.ok) {
throw new Error("Failed to load config")
}
diff --git a/web/frontend/src/components/config/raw-config-page.tsx b/web/frontend/src/components/config/raw-config-page.tsx
index 56a922fe6..f8f987651 100644
--- a/web/frontend/src/components/config/raw-config-page.tsx
+++ b/web/frontend/src/components/config/raw-config-page.tsx
@@ -5,6 +5,7 @@ import { useState } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
+import { launcherFetch } from "@/api/http"
import { PageHeader } from "@/components/page-header"
import {
AlertDialog,
@@ -28,7 +29,7 @@ export function RawConfigPage() {
const { data: config, isLoading } = useQuery({
queryKey: ["config"],
queryFn: async () => {
- const res = await fetch("/api/config")
+ const res = await launcherFetch("/api/config")
if (!res.ok) {
throw new Error("Failed to fetch config")
}
@@ -38,7 +39,7 @@ export function RawConfigPage() {
const mutation = useMutation({
mutationFn: async (newConfig: string) => {
- const res = await fetch("/api/config", {
+ const res = await launcherFetch("/api/config", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: newConfig,
diff --git a/web/frontend/src/components/logs/log-level-select.tsx b/web/frontend/src/components/logs/log-level-select.tsx
new file mode 100644
index 000000000..a8a273b32
--- /dev/null
+++ b/web/frontend/src/components/logs/log-level-select.tsx
@@ -0,0 +1,102 @@
+import { useQuery, useQueryClient } from "@tanstack/react-query"
+import { useEffect, useState } from "react"
+import { useTranslation } from "react-i18next"
+import { toast } from "sonner"
+
+import { type AppConfig, getAppConfig, patchAppConfig } from "@/api/channels"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { refreshGatewayState } from "@/store/gateway"
+
+const LOG_LEVEL_OPTIONS = ["debug", "info", "warn", "error", "fatal"] as const
+type GatewayLogLevel = (typeof LOG_LEVEL_OPTIONS)[number]
+
+const LOG_LEVEL_LABELS: Record = {
+ debug: "Debug",
+ info: "Info",
+ warn: "Warn",
+ error: "Error",
+ fatal: "Fatal",
+}
+
+function getGatewayLogLevel(config: AppConfig | undefined): GatewayLogLevel {
+ const gateway = config?.gateway
+ if (typeof gateway === "object" && gateway !== null) {
+ const logLevel = (gateway as Record).log_level
+ if (
+ typeof logLevel === "string" &&
+ LOG_LEVEL_OPTIONS.includes(logLevel as GatewayLogLevel)
+ ) {
+ return logLevel as GatewayLogLevel
+ }
+ }
+ return "warn"
+}
+
+export function LogLevelSelect() {
+ const { t } = useTranslation()
+ const queryClient = useQueryClient()
+ const [logLevel, setLogLevel] = useState("warn")
+ const [savingLogLevel, setSavingLogLevel] = useState(false)
+
+ const { data: configData } = useQuery({
+ queryKey: ["config"],
+ queryFn: getAppConfig,
+ })
+
+ useEffect(() => {
+ setLogLevel(getGatewayLogLevel(configData))
+ }, [configData])
+
+ const handleLogLevelChange = async (nextValue: string) => {
+ const nextLevel = nextValue as GatewayLogLevel
+ const previousLevel = logLevel
+ setLogLevel(nextLevel)
+ setSavingLogLevel(true)
+
+ try {
+ await patchAppConfig({
+ gateway: {
+ log_level: nextLevel,
+ },
+ })
+ await queryClient.invalidateQueries({ queryKey: ["config"] })
+ await refreshGatewayState({ force: true })
+ } catch (error) {
+ setLogLevel(previousLevel)
+ toast.error(
+ error instanceof Error
+ ? error.message
+ : t("pages.logs.log_level_error"),
+ )
+ } finally {
+ setSavingLogLevel(false)
+ }
+ }
+
+ return (
+
+
+
+
+
+
+ {LOG_LEVEL_OPTIONS.map((level) => (
+
+ {LOG_LEVEL_LABELS[level]}
+
+ ))}
+
+
+
+ )
+}
diff --git a/web/frontend/src/components/logs/logs-page.tsx b/web/frontend/src/components/logs/logs-page.tsx
index a4c458fa2..853da223a 100644
--- a/web/frontend/src/components/logs/logs-page.tsx
+++ b/web/frontend/src/components/logs/logs-page.tsx
@@ -1,6 +1,7 @@
import { IconTrash } from "@tabler/icons-react"
import { useTranslation } from "react-i18next"
+import { LogLevelSelect } from "@/components/logs/log-level-select"
import { LogsPanel } from "@/components/logs/logs-panel"
import { PageHeader } from "@/components/page-header"
import { Button } from "@/components/ui/button"
@@ -17,15 +18,19 @@ export function LogsPage() {
-
- {t("pages.logs.clear")}
-
+ <>
+
+
+
+
+ {t("pages.logs.clear")}
+
+ >
}
/>
diff --git a/web/frontend/src/components/models/edit-model-sheet.tsx b/web/frontend/src/components/models/edit-model-sheet.tsx
index d1cba6719..52e2d8d9d 100644
--- a/web/frontend/src/components/models/edit-model-sheet.tsx
+++ b/web/frontend/src/components/models/edit-model-sheet.tsx
@@ -133,9 +133,10 @@ export function EditModelSheet({
}
const isOAuth = model?.auth_method === "oauth"
- const apiKeyPlaceholder = model?.configured
+ const hasSavedAPIKey = Boolean(model?.api_key)
+ const apiKeyPlaceholder = hasSavedAPIKey
? maskedSecretPlaceholder(
- model.api_key,
+ model?.api_key ?? "",
t("models.field.apiKeyPlaceholderSet"),
)
: t("models.field.apiKeyPlaceholder")
@@ -161,7 +162,7 @@ export function EditModelSheet({
{model.model_name}
@@ -127,14 +127,14 @@ export function ModelCard({
OAuth
- ) : model.configured && model.api_key ? (
+ ) : status === "available" && model.api_key ? (
{model.api_key}
) : (
- {t("models.status.unconfigured")}
+ {statusLabel}
)}
diff --git a/web/frontend/src/components/models/models-page.tsx b/web/frontend/src/components/models/models-page.tsx
index a6747c5e0..c08b3bdd6 100644
--- a/web/frontend/src/components/models/models-page.tsx
+++ b/web/frontend/src/components/models/models-page.tsx
@@ -40,7 +40,7 @@ interface ProviderGroup {
label: string
models: ModelInfo[]
hasDefault: boolean
- configuredCount: number
+ availableCount: number
}
export function ModelsPage() {
@@ -62,8 +62,8 @@ export function ModelsPage() {
const sorted = [...data.models].sort((a, b) => {
if (a.is_default && !b.is_default) return -1
if (!a.is_default && b.is_default) return 1
- if (a.configured && !b.configured) return -1
- if (!a.configured && b.configured) return 1
+ if (a.available && !b.available) return -1
+ if (!a.available && b.available) return 1
return a.model_name.localeCompare(b.model_name)
})
setModels(sorted)
@@ -107,23 +107,23 @@ export function ModelsPage() {
const providerGroups: ProviderGroup[] = Object.entries(grouped)
.map(([key, group]) => {
- const configuredCount = group.models.filter(
- (model) => model.configured,
+ const availableCount = group.models.filter(
+ (model) => model.available,
).length
return {
key,
label: group.label,
models: group.models,
hasDefault: group.models.some((model) => model.is_default),
- configuredCount,
+ availableCount,
}
})
.sort((a, b) => {
if (a.hasDefault && !b.hasDefault) return -1
if (!a.hasDefault && b.hasDefault) return 1
- if (a.configuredCount !== b.configuredCount) {
- return b.configuredCount - a.configuredCount
+ if (a.availableCount !== b.availableCount) {
+ return b.availableCount - a.availableCount
}
const aPriority = PROVIDER_PRIORITY[a.key] ?? Number.MAX_SAFE_INTEGER
diff --git a/web/frontend/src/components/skills/skills-page.tsx b/web/frontend/src/components/skills/skills-page.tsx
deleted file mode 100644
index d8eeb1d93..000000000
--- a/web/frontend/src/components/skills/skills-page.tsx
+++ /dev/null
@@ -1,319 +0,0 @@
-import {
- IconFileInfo,
- IconLoader2,
- IconPlus,
- IconTrash,
-} from "@tabler/icons-react"
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
-import { type ChangeEvent, useRef, useState } from "react"
-import { useTranslation } from "react-i18next"
-import ReactMarkdown from "react-markdown"
-import rehypeRaw from "rehype-raw"
-import rehypeSanitize from "rehype-sanitize"
-import remarkGfm from "remark-gfm"
-import { toast } from "sonner"
-
-import {
- type SkillSupportItem,
- deleteSkill,
- getSkill,
- getSkills,
- importSkill,
-} from "@/api/skills"
-import { PageHeader } from "@/components/page-header"
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
-} from "@/components/ui/alert-dialog"
-import { Button } from "@/components/ui/button"
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from "@/components/ui/card"
-import {
- Sheet,
- SheetContent,
- SheetDescription,
- SheetHeader,
- SheetTitle,
-} from "@/components/ui/sheet"
-
-export function SkillsPage() {
- const { t } = useTranslation()
- const queryClient = useQueryClient()
- const importInputRef = useRef
(null)
- const [selectedSkill, setSelectedSkill] = useState(
- null,
- )
- const [skillPendingDelete, setSkillPendingDelete] =
- useState(null)
-
- const { data, isLoading, error } = useQuery({
- queryKey: ["skills"],
- queryFn: getSkills,
- })
- const {
- data: selectedSkillDetail,
- isLoading: isSkillDetailLoading,
- error: skillDetailError,
- } = useQuery({
- queryKey: ["skills", selectedSkill?.name],
- queryFn: () => getSkill(selectedSkill!.name),
- enabled: selectedSkill !== null,
- })
-
- const importMutation = useMutation({
- mutationFn: async (file: File) => importSkill(file),
- onSuccess: () => {
- toast.success(t("pages.agent.skills.import_success"))
- void queryClient.invalidateQueries({ queryKey: ["skills"] })
- },
- onError: (err) => {
- toast.error(
- err instanceof Error
- ? err.message
- : t("pages.agent.skills.import_error"),
- )
- },
- })
-
- const deleteMutation = useMutation({
- mutationFn: async (name: string) => deleteSkill(name),
- onSuccess: (_, deletedName) => {
- toast.success(t("pages.agent.skills.delete_success"))
- setSkillPendingDelete(null)
- if (
- selectedSkill?.name === deletedName &&
- selectedSkill.source === "workspace"
- ) {
- setSelectedSkill(null)
- }
- void queryClient.invalidateQueries({ queryKey: ["skills"] })
- },
- onError: (err) => {
- toast.error(
- err instanceof Error
- ? err.message
- : t("pages.agent.skills.delete_error"),
- )
- },
- })
-
- const handleImportClick = () => {
- importInputRef.current?.click()
- }
-
- const handleImportFileChange = (event: ChangeEvent) => {
- const file = event.target.files?.[0]
- if (!file) return
- importMutation.mutate(file)
- event.target.value = ""
- }
-
- return (
-
-
-
-
- {importMutation.isPending ? (
-
- ) : (
-
- )}
- {t("pages.agent.skills.import")}
-
- >
- }
- />
-
-
-
- {isLoading ? (
-
- {t("labels.loading")}
-
- ) : error ? (
-
- {t("pages.agent.load_error")}
-
- ) : (
-
-
- {t("pages.agent.skills.description")}
-
-
- {data?.skills.length ? (
-
- {data.skills.map((skill) => (
-
-
-
-
-
- {skill.name}
-
-
- {skill.description ||
- t("pages.agent.skills.no_description")}
-
-
-
- setSelectedSkill(skill)}
- title={t("pages.agent.skills.view")}
- >
-
-
- {skill.source === "workspace" ? (
- setSkillPendingDelete(skill)}
- title={t("pages.agent.skills.delete")}
- >
-
-
- ) : null}
-
-
-
-
-
- {t("pages.agent.skills.path")}
-
-
- {skill.path}
-
-
-
- ))}
-
- ) : (
-
-
- {t("pages.agent.skills.empty")}
-
-
- )}
-
- )}
-
-
-
- {
- if (!open) setSelectedSkill(null)
- }}
- >
-
-
-
- {selectedSkill?.name || t("pages.agent.skills.viewer_title")}
-
-
- {selectedSkill?.description ||
- t("pages.agent.skills.viewer_description")}
-
-
-
-
- {isSkillDetailLoading ? (
-
- {t("pages.agent.skills.loading_detail")}
-
- ) : skillDetailError ? (
-
- {t("pages.agent.skills.load_detail_error")}
-
- ) : selectedSkillDetail ? (
-
-
-
- {selectedSkillDetail.content}
-
-
-
- ) : null}
-
-
-
-
- {
- if (!open) setSkillPendingDelete(null)
- }}
- >
-
-
-
- {t("pages.agent.skills.delete_title")}
-
-
- {t("pages.agent.skills.delete_description", {
- name: skillPendingDelete?.name,
- })}
-
-
-
-
- {t("common.cancel")}
-
- {
- if (skillPendingDelete)
- deleteMutation.mutate(skillPendingDelete.name)
- }}
- >
- {deleteMutation.isPending ? (
-
- ) : (
-
- )}
- {t("pages.agent.skills.delete_confirm")}
-
-
-
-
-
- )
-}
diff --git a/web/frontend/src/components/tools/tools-page.tsx b/web/frontend/src/components/tools/tools-page.tsx
deleted file mode 100644
index 6a521a565..000000000
--- a/web/frontend/src/components/tools/tools-page.tsx
+++ /dev/null
@@ -1,192 +0,0 @@
-import { IconLoader2 } from "@tabler/icons-react"
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
-import { useTranslation } from "react-i18next"
-import { toast } from "sonner"
-
-import { type ToolSupportItem, getTools, setToolEnabled } from "@/api/tools"
-import { PageHeader } from "@/components/page-header"
-import { Button } from "@/components/ui/button"
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from "@/components/ui/card"
-import { cn } from "@/lib/utils"
-import { refreshGatewayState } from "@/store/gateway"
-
-export function ToolsPage() {
- const { t } = useTranslation()
- const queryClient = useQueryClient()
- const { data, isLoading, error } = useQuery({
- queryKey: ["tools"],
- queryFn: getTools,
- })
-
- const toggleMutation = useMutation({
- mutationFn: async ({ name, enabled }: { name: string; enabled: boolean }) =>
- setToolEnabled(name, enabled),
- onSuccess: (_, variables) => {
- toast.success(
- variables.enabled
- ? t("pages.agent.tools.enable_success")
- : t("pages.agent.tools.disable_success"),
- )
- void queryClient.invalidateQueries({ queryKey: ["tools"] })
- void refreshGatewayState({ force: true })
- },
- onError: (err) => {
- toast.error(
- err instanceof Error
- ? err.message
- : t("pages.agent.tools.toggle_error"),
- )
- },
- })
-
- const groupedTools = (() => {
- if (!data) return [] as Array<[string, ToolSupportItem[]]>
- const buckets = new Map()
- for (const item of data.tools) {
- const list = buckets.get(item.category) ?? []
- list.push(item)
- buckets.set(item.category, list)
- }
- return Array.from(buckets.entries())
- })()
-
- return (
-
-
-
-
-
- {isLoading ? (
-
- {t("labels.loading")}
-
- ) : error ? (
-
- {t("pages.agent.load_error")}
-
- ) : (
-
-
- {t("pages.agent.tools.description")}
-
-
- {data?.tools.length ? (
- groupedTools.map(([category, items]) => (
-
-
- {t(`pages.agent.tools.categories.${category}`)}
-
-
- {items.map((tool) => {
- const reasonText = tool.reason_code
- ? t(`pages.agent.tools.reasons.${tool.reason_code}`)
- : ""
- const isPending =
- toggleMutation.isPending &&
- toggleMutation.variables?.name === tool.name
- const nextEnabled = tool.status !== "enabled"
-
- return (
-
-
-
-
-
- {tool.name}
-
-
- {tool.description}
-
-
-
-
-
- toggleMutation.mutate({
- name: tool.name,
- enabled: nextEnabled,
- })
- }
- >
- {isPending ? (
-
- ) : null}
- {nextEnabled
- ? t("pages.agent.tools.enable")
- : t("pages.agent.tools.disable")}
-
-
-
-
-
-
- {t("pages.agent.tools.config_key", {
- key: tool.config_key,
- })}
-
- {reasonText ? (
-
- {reasonText}
-
- ) : null}
-
-
- )
- })}
-
-
- ))
- ) : (
-
-
- {t("pages.agent.tools.empty")}
-
-
- )}
-
- )}
-
-
-
- )
-}
-
-function ToolStatusBadge({ status }: { status: ToolSupportItem["status"] }) {
- const { t } = useTranslation()
-
- return (
-
- {t(`pages.agent.tools.status.${status}`)}
-
- )
-}
diff --git a/web/frontend/src/components/tour/tour-guide.tsx b/web/frontend/src/components/tour/tour-guide.tsx
new file mode 100644
index 000000000..cc1e6e3a1
--- /dev/null
+++ b/web/frontend/src/components/tour/tour-guide.tsx
@@ -0,0 +1,242 @@
+import {
+ IconBook,
+ IconChevronLeft,
+ IconChevronRight,
+} from "@tabler/icons-react"
+import { useAtom } from "jotai"
+import { useTranslation } from "react-i18next"
+
+import { Button } from "@/components/ui/button"
+import {
+ tourAtom,
+ tourCurrentStepAtom,
+ tourIsActiveAtom,
+ type TourStep,
+ useTourActions,
+} from "@/store/tour"
+import { cn } from "@/lib/utils"
+
+interface TourStepConfig {
+ title: string
+ description: string
+ targetSelector?: string
+ position: "top" | "bottom" | "left" | "right"
+ icon?: React.ReactNode
+ offsetY?: number
+}
+
+export function TourGuide() {
+ const { t } = useTranslation()
+ const [tourState] = useAtom(tourAtom)
+ const [, setCurrentStep] = useAtom(tourCurrentStepAtom)
+ const [, setIsActive] = useAtom(tourIsActiveAtom)
+ const { goToNextStep, goToPrevStep } = useTourActions()
+
+ if (!tourState.isActive || tourState.currentStep === "completed") {
+ return null
+ }
+
+ const steps: Record = {
+ welcome: {
+ title: t("tour.welcome.title"),
+ description: t("tour.welcome.description"),
+ position: "bottom",
+ },
+ models: {
+ title: t("tour.models.title"),
+ description: t("tour.models.description"),
+ targetSelector: "[data-tour='models-nav']",
+ position: "right",
+ },
+ gateway: {
+ title: t("tour.gateway.title"),
+ description: t("tour.gateway.description"),
+ targetSelector: "[data-tour='gateway-button']",
+ position: "left",
+ offsetY: 60,
+ },
+ docs: {
+ title: t("tour.docs.title"),
+ description: t("tour.docs.description"),
+ targetSelector: "[data-tour='docs-button']",
+ position: "left",
+ icon: ,
+ offsetY: 60,
+ },
+ completed: {
+ title: "",
+ description: "",
+ position: "bottom",
+ },
+ }
+
+ const currentConfig = steps[tourState.currentStep]
+ const stepOrder: TourStep[] = [
+ "welcome",
+ "models",
+ "gateway",
+ "docs",
+ "completed",
+ ]
+ const currentStepIndex = stepOrder.indexOf(tourState.currentStep)
+ const totalSteps = stepOrder.length - 1
+
+ const handleNext = () => {
+ const nextStep = goToNextStep(tourState.currentStep)
+ setCurrentStep(nextStep)
+ if (nextStep === "completed") {
+ setIsActive(false)
+ }
+ }
+
+ const handlePrev = () => {
+ const prevStep = goToPrevStep(tourState.currentStep)
+ setCurrentStep(prevStep)
+ }
+
+ const handleSkip = () => {
+ setCurrentStep("completed")
+ setIsActive(false)
+ }
+
+ const getTargetElement = () => {
+ if (!currentConfig.targetSelector) return null
+ return document.querySelector(currentConfig.targetSelector)
+ }
+
+ const targetElement = getTargetElement()
+
+ const getPopoverPosition = () => {
+ if (!targetElement) {
+ return {
+ top: "50%",
+ left: "50%",
+ transform: "translate(-50%, -50%)",
+ }
+ }
+
+ const rect = targetElement.getBoundingClientRect()
+ const offset = 12
+ const offsetY = currentConfig.offsetY ?? 0
+
+ switch (currentConfig.position) {
+ case "top":
+ return {
+ top: rect.top - offset,
+ left: rect.left + rect.width / 2,
+ transform: "translate(-50%, -100%)",
+ }
+ case "bottom":
+ return {
+ top: rect.bottom + offset,
+ left: rect.left + rect.width / 2,
+ transform: "translateX(-50%)",
+ }
+ case "left":
+ return {
+ top: rect.top + rect.height / 2 + offsetY,
+ left: rect.left - offset,
+ transform: "translate(-100%, -50%)",
+ }
+ case "right":
+ return {
+ top: rect.top + rect.height / 2 + offsetY,
+ left: rect.right + offset,
+ transform: "translateY(-50%)",
+ }
+ default:
+ return {
+ top: rect.bottom + offset,
+ left: rect.left + rect.width / 2,
+ transform: "translateX(-50%)",
+ }
+ }
+ }
+
+ const position = getPopoverPosition()
+ const isCentered = !targetElement
+
+ return (
+ <>
+ {targetElement ? (
+
+ ) : (
+
+ )}
+
+ {targetElement && (
+
+ )}
+
+
+
+ {currentConfig.icon}
+
{currentConfig.title}
+
+
+
+ {currentConfig.description}
+
+
+
+
+ {currentStepIndex + 1} / {totalSteps}
+
+
+
+ {currentStepIndex > 0 && (
+
+
+ {t("tour.prev")}
+
+ )}
+
+ {currentStepIndex === totalSteps - 1
+ ? t("tour.finish")
+ : t("tour.next")}
+ {currentStepIndex < totalSteps - 1 && (
+
+ )}
+
+
+
+
+ {currentStepIndex < totalSteps - 1 && (
+
+ {t("tour.skip")}
+
+ )}
+
+ >
+ )
+}
diff --git a/web/frontend/src/components/ui/dialog.tsx b/web/frontend/src/components/ui/dialog.tsx
new file mode 100644
index 000000000..da1eb3a12
--- /dev/null
+++ b/web/frontend/src/components/ui/dialog.tsx
@@ -0,0 +1,163 @@
+import * as React from "react"
+import { Dialog as DialogPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { IconX } from "@tabler/icons-react"
+
+function Dialog({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogPortal({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogClose({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: React.ComponentProps & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+
+
+ Close
+
+
+ )}
+
+
+ )
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DialogFooter({
+ className,
+ showCloseButton = false,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+ {children}
+ {showCloseButton && (
+
+ Close
+
+ )}
+
+ )
+}
+
+function DialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+}
diff --git a/web/frontend/src/features/chat/websocket.ts b/web/frontend/src/features/chat/websocket.ts
index 6b132e9a6..17ba36075 100644
--- a/web/frontend/src/features/chat/websocket.ts
+++ b/web/frontend/src/features/chat/websocket.ts
@@ -14,6 +14,18 @@ export function normalizeWsUrlForBrowser(wsUrl: string): string {
if (isLocalHost && !isBrowserLocal) {
parsedUrl.hostname = window.location.hostname
finalWsUrl = parsedUrl.toString()
+ } else if (
+ isLocalHost &&
+ isBrowserLocal &&
+ parsedUrl.hostname !== window.location.hostname &&
+ (parsedUrl.hostname === "127.0.0.1" ||
+ parsedUrl.hostname === "localhost") &&
+ (window.location.hostname === "127.0.0.1" ||
+ window.location.hostname === "localhost")
+ ) {
+ // Same machine, but cookies are host-specific; match the page origin.
+ parsedUrl.hostname = window.location.hostname
+ finalWsUrl = parsedUrl.toString()
}
} catch (error) {
console.warn("Could not parse ws_url:", error)
diff --git a/web/frontend/src/hooks/use-chat-models.ts b/web/frontend/src/hooks/use-chat-models.ts
index 9afa882db..17cfba00f 100644
--- a/web/frontend/src/hooks/use-chat-models.ts
+++ b/web/frontend/src/hooks/use-chat-models.ts
@@ -65,32 +65,32 @@ export function useChatModels({ isConnected }: UseChatModelsOptions) {
[defaultModelName],
)
- const hasConfiguredModels = useMemo(
- () => modelList.some((m) => m.configured),
+ const hasAvailableModels = useMemo(
+ () => modelList.some((m) => m.available),
[modelList],
)
const oauthModels = useMemo(
- () => modelList.filter((m) => m.configured && m.auth_method === "oauth"),
+ () => modelList.filter((m) => m.available && m.auth_method === "oauth"),
[modelList],
)
const localModels = useMemo(
- () => modelList.filter((m) => m.configured && isLocalModel(m)),
+ () => modelList.filter((m) => m.available && isLocalModel(m)),
[modelList],
)
const apiKeyModels = useMemo(
() =>
modelList.filter(
- (m) => m.configured && m.auth_method !== "oauth" && !isLocalModel(m),
+ (m) => m.available && m.auth_method !== "oauth" && !isLocalModel(m),
),
[modelList],
)
return {
defaultModelName,
- hasConfiguredModels,
+ hasAvailableModels,
apiKeyModels,
oauthModels,
localModels,
diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json
index 25608fe93..b99ff9594 100644
--- a/web/frontend/src/i18n/locales/en.json
+++ b/web/frontend/src/i18n/locales/en.json
@@ -5,6 +5,7 @@
"models": "Models",
"credentials": "Credentials",
"agent_group": "Agent",
+ "hub": "Hub",
"skills": "Skills",
"tools": "Tools",
"services": "Services",
@@ -14,6 +15,20 @@
"config": "Config",
"logs": "Logs"
},
+ "launcherLogin": {
+ "title": "Launcher access",
+ "description": "Sign in with the dashboard access token for this launcher process (it may change after each restart unless you pin it with an environment variable).",
+ "tokenLabel": "Token",
+ "tokenPlaceholder": "Enter access token",
+ "submit": "Continue to Dashboard",
+ "errorInvalid": "Invalid token. Please try again.",
+ "errorNetwork": "Network error. Please try again.",
+ "helpTitle": "Where to find the token",
+ "helpConsole": "Console mode: printed in the terminal when the launcher starts.",
+ "helpTray": "Tray mode: menu «Copy dashboard token».",
+ "helpLogFile": "Log file (startup line includes the token): {{path}}",
+ "helpEnv": "Stable token: set {{env}}."
+ },
"chat": {
"welcome": "How can I help you today?",
"welcomeDesc": "Ask me about weather, settings, or any other tasks. I'm here to assist you.",
@@ -79,6 +94,12 @@
"labels": {
"loading": "Loading..."
},
+ "footer": {
+ "version": "Version",
+ "commit": "Commit",
+ "build": "Build",
+ "version_unknown": "Unknown"
+ },
"credentials": {
"description": "Manage OAuth and token-based credentials for supported providers.",
"loading": "Loading credentials...",
@@ -150,8 +171,9 @@
"noDefaultHintPrefix": "No default model set yet. Click",
"noDefaultHintSuffix": "to set one.",
"status": {
- "configured": "Configured",
- "unconfigured": "Not configured"
+ "available": "Available",
+ "unconfigured": "Not configured",
+ "unreachable": "Service unreachable"
},
"badge": {
"default": "Default",
@@ -377,11 +399,18 @@
"agent": {
"load_error": "Failed to load agent support information.",
"skills": {
- "description": "Skills are loaded from the workspace, global PicoClaw home, and builtin directories.",
"empty": "No skills are currently available.",
+ "install_success": "Installed {{name}}.",
+ "install_error": "Failed to install skill.",
+ "search_placeholder": "Search by name, description, or registry",
+ "source_label": "Type",
+ "sort_label": "Sort",
"import": "Import Skill",
"import_success": "Skill imported.",
"import_error": "Failed to import skill.",
+ "import_invalid_type": "Only Markdown or ZIP skill files are supported.",
+ "import_invalid_size": "Skill file must be 1 MB or smaller.",
+ "import_constraints": "Import a Markdown or ZIP skill file up to 1 MB",
"view": "View",
"delete": "Delete",
"delete_title": "Delete Skill?",
@@ -391,20 +420,78 @@
"delete_error": "Failed to delete skill.",
"viewer_title": "Skill Content",
"viewer_description": "Read the current effective SKILL.md content here.",
- "loading_detail": "Loading skill content...",
"load_detail_error": "Failed to load skill content.",
- "path": "Skill Path",
- "no_description": "No description provided."
+ "no_description": "No description provided.",
+ "no_results": "No skills matched the current filters.",
+ "dropzone_title": "Import Into Workspace",
+ "dropzone_description": "Drag a skill file here or pick one from disk.",
+ "dropzone_label": "Drop a skill file here",
+ "dropzone_active": "Release to import this skill",
+ "dropzone_release": "The skill will be normalized and saved into the workspace skills directory.",
+ "marketplace_title": "Discover Skills",
+ "marketplace_description": "Search the skill registries and install useful skills into this workspace",
+ "marketplace_search_placeholder": "Search for capabilities like github, docker, database...",
+ "marketplace_search_action": "Search",
+ "marketplace_search_status": "Search Status",
+ "marketplace_install_status": "Install Status",
+ "marketplace_notice_title": "Security Notice",
+ "marketplace_notice_body": "Registry skills are third-party content. Review the author, page URL, instructions, and any required code or credentials before installing.",
+ "marketplace_status_disabled": "Disabled. Enable the corresponding tool on the Tools page first.",
+ "marketplace_status_enable_hint": "Enable the related tool on the Tools page first.",
+ "marketplace_search_error": "Failed to search registries.",
+ "marketplace_loading_results": "Searching skills...",
+ "marketplace_loading_more": "Loading more skills...",
+ "marketplace_results_title": "{{count}} results for “{{query}}”",
+ "marketplace_results_hint": "Registry results install into the current workspace.",
+ "marketplace_install_action": "Install",
+ "marketplace_installed": "Installed",
+ "marketplace_view_installed": "View Local",
+ "marketplace_installed_hint": "Already available in this workspace as “{{name}}”.",
+ "marketplace_empty_results": "No installable skills matched “{{query}}”.",
+ "marketplace_idle": "Search for a capability to discover installable skills from configured registries.",
+ "marketplace_unavailable": "Registry search is currently unavailable. Check the Skills tools configuration.",
+ "sort": {
+ "name_asc": "Name (A-Z)",
+ "name_desc": "Name (Z-A)",
+ "source": "Type"
+ },
+ "origin": {
+ "all": "All Types",
+ "builtin": "Builtin",
+ "third_party": "Third-Party",
+ "manual": "Manual"
+ },
+ "summary": {
+ "total": "Total Skills"
+ },
+ "detail_tabs": {
+ "preview": "Preview",
+ "raw": "Raw",
+ "meta": "Metadata"
+ },
+ "metadata": {
+ "name": "Name",
+ "description": "Description",
+ "registry": "Registry",
+ "url": "URL",
+ "version": "Installed Version",
+ "lines": "Line Count",
+ "characters": "Character Count"
+ }
},
"tools": {
- "description": "This view reflects whether each agent tool is enabled, disabled, or blocked by a missing prerequisite.",
+ "search_placeholder": "Search tools...",
+ "no_results": "No tools match your criteria.",
+ "filter": {
+ "all": "All Status",
+ "enabled": "Enabled only",
+ "disabled": "Disabled only",
+ "blocked": "Blocked only"
+ },
"empty": "No tools are available.",
- "enable": "Enable",
- "disable": "Disable",
"enable_success": "Tool enabled.",
"disable_success": "Tool disabled.",
"toggle_error": "Failed to update tool state.",
- "config_key": "Controlled by tools.{{key}}",
"status": {
"enabled": "Enabled",
"disabled": "Disabled",
@@ -527,8 +614,31 @@
"unsaved_changes": "You have unsaved changes."
},
"logs": {
+ "log_level_error": "Failed to update log level.",
"clear": "Clear logs",
"empty": "Waiting for logs..."
}
+ },
+ "tour": {
+ "skip": "Skip tour",
+ "prev": "Previous",
+ "next": "Next",
+ "finish": "Finish",
+ "welcome": {
+ "title": "Welcome to PicoClaw",
+ "description": "PicoClaw is a powerful AI assistant platform. Let's take a few seconds to help you complete the basic setup."
+ },
+ "models": {
+ "title": "Configure Models",
+ "description": "Click the \"Models\" menu on the left to configure API keys for AI providers. Only configured models can be used for chat."
+ },
+ "gateway": {
+ "title": "Start Gateway",
+ "description": "After configuring models, click the \"Start Gateway\" button at the top to begin chatting with AI."
+ },
+ "docs": {
+ "title": "View Documentation",
+ "description": "Need more help? Click the documentation button in the top right corner to view detailed guides and configuration docs."
+ }
}
}
diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json
index 346822407..9fa45e981 100644
--- a/web/frontend/src/i18n/locales/zh.json
+++ b/web/frontend/src/i18n/locales/zh.json
@@ -5,6 +5,7 @@
"models": "模型",
"credentials": "凭据",
"agent_group": "智能体",
+ "hub": "Hub",
"skills": "技能",
"tools": "工具",
"services": "服务",
@@ -14,6 +15,20 @@
"config": "配置",
"logs": "日志"
},
+ "launcherLogin": {
+ "title": "Launcher 访问验证",
+ "description": "请使用当前 Launcher 进程的访问口令登录(每次重启可能变化,除非用环境变量固定)。",
+ "tokenLabel": "令牌",
+ "tokenPlaceholder": "输入访问令牌",
+ "submit": "进入 Dashboard",
+ "errorInvalid": "令牌错误,请重试。",
+ "errorNetwork": "网络错误,请重试。",
+ "helpTitle": "口令在哪里",
+ "helpConsole": "控制台模式:启动时在终端输出。",
+ "helpTray": "托盘模式:菜单「复制控制台口令」。",
+ "helpLogFile": "日志文件(启动时会写入口令):{{path}}",
+ "helpEnv": "固定口令:设置环境变量 {{env}}。"
+ },
"chat": {
"welcome": "今天我能为您做些什么?",
"welcomeDesc": "您可以询问我天气、设置或其他任何任务,我随时为您效劳。",
@@ -79,6 +94,12 @@
"labels": {
"loading": "加载中..."
},
+ "footer": {
+ "version": "版本",
+ "commit": "提交",
+ "build": "构建",
+ "version_unknown": "未知"
+ },
"credentials": {
"description": "管理已支持服务商的 OAuth 与 Token 凭据。",
"loading": "正在加载凭据...",
@@ -150,8 +171,9 @@
"noDefaultHintPrefix": "尚未设置默认模型,点击",
"noDefaultHintSuffix": "设为默认。",
"status": {
- "configured": "已配置",
- "unconfigured": "未配置"
+ "available": "可用",
+ "unconfigured": "未配置",
+ "unreachable": "服务不可达"
},
"badge": {
"default": "默认",
@@ -377,11 +399,18 @@
"agent": {
"load_error": "加载 Agent 支持信息失败。",
"skills": {
- "description": "技能会从工作区、PicoClaw 全局目录和内置目录中加载。",
"empty": "当前没有可用技能。",
+ "install_success": "已安装 {{name}}。",
+ "install_error": "安装技能失败。",
+ "search_placeholder": "按名称、描述或技能源搜索",
+ "source_label": "类型",
+ "sort_label": "排序",
"import": "导入技能",
"import_success": "技能导入成功。",
"import_error": "导入技能失败。",
+ "import_invalid_type": "仅支持导入 Markdown 或 ZIP 技能文件。",
+ "import_invalid_size": "技能文件大小不能超过 1 MB。",
+ "import_constraints": "支持导入最大 1 MB 的 Markdown 或 ZIP 文件",
"view": "查看",
"delete": "删除",
"delete_title": "删除技能?",
@@ -391,20 +420,78 @@
"delete_error": "删除技能失败。",
"viewer_title": "技能内容",
"viewer_description": "这里展示当前生效的 SKILL.md 内容。",
- "loading_detail": "正在加载技能内容...",
"load_detail_error": "加载技能内容失败。",
- "path": "技能路径",
- "no_description": "未提供描述。"
+ "no_description": "未提供描述。",
+ "no_results": "没有技能匹配当前筛选条件。",
+ "dropzone_title": "导入到工作区",
+ "dropzone_description": "将技能文件拖到这里,或从本地选择一个文件。",
+ "dropzone_label": "将技能文件拖到这里",
+ "dropzone_active": "松开即可导入该技能",
+ "dropzone_release": "导入后会自动规范化内容,并保存到工作区技能目录。",
+ "marketplace_title": "安装技能",
+ "marketplace_description": "搜索第三方技能源,并将技能安装到当前工作区",
+ "marketplace_search_placeholder": "搜索 github、docker、database 等技能",
+ "marketplace_search_action": "搜索",
+ "marketplace_search_status": "搜索状态",
+ "marketplace_install_status": "安装状态",
+ "marketplace_notice_title": "安全提示",
+ "marketplace_notice_body": "搜索结果中的 skills 属于第三方内容。安装前请先确认作者、页面 URL、说明文档,以及它要求执行的代码或使用的凭据是否可信。",
+ "marketplace_status_disabled": "当前未启用,请先在工具页启用对应工具。",
+ "marketplace_status_enable_hint": "请先在工具页启用相关工具。",
+ "marketplace_search_error": "搜索技能源失败。",
+ "marketplace_loading_results": "正在搜索技能...",
+ "marketplace_loading_more": "正在加载更多技能...",
+ "marketplace_results_title": "“{{query}}” 共找到 {{count}} 个结果",
+ "marketplace_results_hint": "搜索结果会安装到当前工作区。",
+ "marketplace_install_action": "安装",
+ "marketplace_installed": "已安装",
+ "marketplace_view_installed": "查看本地技能",
+ "marketplace_installed_hint": "该技能已在当前工作区中可用,名称为「{{name}}」。",
+ "marketplace_empty_results": "没有找到与“{{query}}”匹配的可安装技能。",
+ "marketplace_idle": "输入一个关键词,搜索可安装的第三方技能。",
+ "marketplace_unavailable": "当前无法使用技能搜索,请检查 Skills 相关工具配置。",
+ "sort": {
+ "name_asc": "名称(A-Z)",
+ "name_desc": "名称(Z-A)",
+ "source": "按类型"
+ },
+ "origin": {
+ "all": "全部类型",
+ "builtin": "内置",
+ "third_party": "第三方",
+ "manual": "手动导入"
+ },
+ "summary": {
+ "total": "技能总数"
+ },
+ "detail_tabs": {
+ "preview": "预览",
+ "raw": "原始内容",
+ "meta": "元数据"
+ },
+ "metadata": {
+ "name": "名称",
+ "description": "描述",
+ "registry": "来源平台",
+ "url": "链接地址",
+ "version": "已安装版本",
+ "lines": "行数",
+ "characters": "字符数"
+ }
},
"tools": {
- "description": "这里展示每个 Agent 工具当前是已启用、已禁用,还是被依赖条件阻塞。",
+ "search_placeholder": "搜索工具...",
+ "no_results": "没有找到符合条件的工具",
+ "filter": {
+ "all": "所有状态",
+ "enabled": "已启用",
+ "disabled": "已禁用",
+ "blocked": "被阻塞"
+ },
"empty": "当前没有可用工具。",
- "enable": "启用",
- "disable": "禁用",
"enable_success": "工具已启用。",
"disable_success": "工具已禁用。",
"toggle_error": "更新工具状态失败。",
- "config_key": "由 tools.{{key}} 控制",
"status": {
"enabled": "已启用",
"disabled": "已禁用",
@@ -527,8 +614,31 @@
"unsaved_changes": "您有未保存的更改。"
},
"logs": {
+ "log_level_error": "更新日志等级失败。",
"clear": "清空日志",
"empty": "等待日志中..."
}
+ },
+ "tour": {
+ "skip": "跳过引导",
+ "prev": "上一步",
+ "next": "下一步",
+ "finish": "完成",
+ "welcome": {
+ "title": "欢迎使用 PicoClaw",
+ "description": "PicoClaw 是一个强大的 AI 助手平台。让我们花几秒钟时间,帮您完成基础配置。"
+ },
+ "models": {
+ "title": "配置模型",
+ "description": "点击左侧「模型」菜单,为 AI 服务商配置 API Key。只有配置好的模型才能用于对话。"
+ },
+ "gateway": {
+ "title": "启动服务",
+ "description": "配置好模型后,点击顶部的「启动服务」按钮,即可开始与 AI 对话。"
+ },
+ "docs": {
+ "title": "查看文档",
+ "description": "需要更多帮助?点击右上角的文档按钮,查看详细的使用文档和配置指南。"
+ }
}
}
diff --git a/web/frontend/src/lib/launcher-login-path.ts b/web/frontend/src/lib/launcher-login-path.ts
new file mode 100644
index 000000000..52c35d240
--- /dev/null
+++ b/web/frontend/src/lib/launcher-login-path.ts
@@ -0,0 +1,9 @@
+/** Normalize URL pathname for comparisons (trailing slashes, empty). */
+export function normalizePathname(p: string): string {
+ const t = p.replace(/\/+$/, "")
+ return t === "" ? "/" : t
+}
+
+export function isLauncherLoginPathname(pathname: string): boolean {
+ return normalizePathname(pathname) === "/launcher-login"
+}
diff --git a/web/frontend/src/routeTree.gen.ts b/web/frontend/src/routeTree.gen.ts
index 60f19ab53..a32a6150d 100644
--- a/web/frontend/src/routeTree.gen.ts
+++ b/web/frontend/src/routeTree.gen.ts
@@ -11,6 +11,7 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as ModelsRouteImport } from './routes/models'
import { Route as LogsRouteImport } from './routes/logs'
+import { Route as LauncherLoginRouteImport } from './routes/launcher-login'
import { Route as CredentialsRouteImport } from './routes/credentials'
import { Route as ConfigRouteImport } from './routes/config'
import { Route as AgentRouteImport } from './routes/agent'
@@ -20,6 +21,7 @@ import { Route as ConfigRawRouteImport } from './routes/config.raw'
import { Route as ChannelsNameRouteImport } from './routes/channels/$name'
import { Route as AgentToolsRouteImport } from './routes/agent/tools'
import { Route as AgentSkillsRouteImport } from './routes/agent/skills'
+import { Route as AgentHubRouteImport } from './routes/agent/hub'
const ModelsRoute = ModelsRouteImport.update({
id: '/models',
@@ -31,6 +33,11 @@ const LogsRoute = LogsRouteImport.update({
path: '/logs',
getParentRoute: () => rootRouteImport,
} as any)
+const LauncherLoginRoute = LauncherLoginRouteImport.update({
+ id: '/launcher-login',
+ path: '/launcher-login',
+ getParentRoute: () => rootRouteImport,
+} as any)
const CredentialsRoute = CredentialsRouteImport.update({
id: '/credentials',
path: '/credentials',
@@ -76,6 +83,11 @@ const AgentSkillsRoute = AgentSkillsRouteImport.update({
path: '/skills',
getParentRoute: () => AgentRoute,
} as any)
+const AgentHubRoute = AgentHubRouteImport.update({
+ id: '/hub',
+ path: '/hub',
+ getParentRoute: () => AgentRoute,
+} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
@@ -83,8 +95,10 @@ export interface FileRoutesByFullPath {
'/agent': typeof AgentRouteWithChildren
'/config': typeof ConfigRouteWithChildren
'/credentials': typeof CredentialsRoute
+ '/launcher-login': typeof LauncherLoginRoute
'/logs': typeof LogsRoute
'/models': typeof ModelsRoute
+ '/agent/hub': typeof AgentHubRoute
'/agent/skills': typeof AgentSkillsRoute
'/agent/tools': typeof AgentToolsRoute
'/channels/$name': typeof ChannelsNameRoute
@@ -96,8 +110,10 @@ export interface FileRoutesByTo {
'/agent': typeof AgentRouteWithChildren
'/config': typeof ConfigRouteWithChildren
'/credentials': typeof CredentialsRoute
+ '/launcher-login': typeof LauncherLoginRoute
'/logs': typeof LogsRoute
'/models': typeof ModelsRoute
+ '/agent/hub': typeof AgentHubRoute
'/agent/skills': typeof AgentSkillsRoute
'/agent/tools': typeof AgentToolsRoute
'/channels/$name': typeof ChannelsNameRoute
@@ -110,8 +126,10 @@ export interface FileRoutesById {
'/agent': typeof AgentRouteWithChildren
'/config': typeof ConfigRouteWithChildren
'/credentials': typeof CredentialsRoute
+ '/launcher-login': typeof LauncherLoginRoute
'/logs': typeof LogsRoute
'/models': typeof ModelsRoute
+ '/agent/hub': typeof AgentHubRoute
'/agent/skills': typeof AgentSkillsRoute
'/agent/tools': typeof AgentToolsRoute
'/channels/$name': typeof ChannelsNameRoute
@@ -125,8 +143,10 @@ export interface FileRouteTypes {
| '/agent'
| '/config'
| '/credentials'
+ | '/launcher-login'
| '/logs'
| '/models'
+ | '/agent/hub'
| '/agent/skills'
| '/agent/tools'
| '/channels/$name'
@@ -138,8 +158,10 @@ export interface FileRouteTypes {
| '/agent'
| '/config'
| '/credentials'
+ | '/launcher-login'
| '/logs'
| '/models'
+ | '/agent/hub'
| '/agent/skills'
| '/agent/tools'
| '/channels/$name'
@@ -151,8 +173,10 @@ export interface FileRouteTypes {
| '/agent'
| '/config'
| '/credentials'
+ | '/launcher-login'
| '/logs'
| '/models'
+ | '/agent/hub'
| '/agent/skills'
| '/agent/tools'
| '/channels/$name'
@@ -165,6 +189,7 @@ export interface RootRouteChildren {
AgentRoute: typeof AgentRouteWithChildren
ConfigRoute: typeof ConfigRouteWithChildren
CredentialsRoute: typeof CredentialsRoute
+ LauncherLoginRoute: typeof LauncherLoginRoute
LogsRoute: typeof LogsRoute
ModelsRoute: typeof ModelsRoute
}
@@ -185,6 +210,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LogsRouteImport
parentRoute: typeof rootRouteImport
}
+ '/launcher-login': {
+ id: '/launcher-login'
+ path: '/launcher-login'
+ fullPath: '/launcher-login'
+ preLoaderRoute: typeof LauncherLoginRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/credentials': {
id: '/credentials'
path: '/credentials'
@@ -248,6 +280,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AgentSkillsRouteImport
parentRoute: typeof AgentRoute
}
+ '/agent/hub': {
+ id: '/agent/hub'
+ path: '/hub'
+ fullPath: '/agent/hub'
+ preLoaderRoute: typeof AgentHubRouteImport
+ parentRoute: typeof AgentRoute
+ }
}
}
@@ -264,11 +303,13 @@ const ChannelsRouteRouteWithChildren = ChannelsRouteRoute._addFileChildren(
)
interface AgentRouteChildren {
+ AgentHubRoute: typeof AgentHubRoute
AgentSkillsRoute: typeof AgentSkillsRoute
AgentToolsRoute: typeof AgentToolsRoute
}
const AgentRouteChildren: AgentRouteChildren = {
+ AgentHubRoute: AgentHubRoute,
AgentSkillsRoute: AgentSkillsRoute,
AgentToolsRoute: AgentToolsRoute,
}
@@ -292,6 +333,7 @@ const rootRouteChildren: RootRouteChildren = {
AgentRoute: AgentRouteWithChildren,
ConfigRoute: ConfigRouteWithChildren,
CredentialsRoute: CredentialsRoute,
+ LauncherLoginRoute: LauncherLoginRoute,
LogsRoute: LogsRoute,
ModelsRoute: ModelsRoute,
}
diff --git a/web/frontend/src/routes/__root.tsx b/web/frontend/src/routes/__root.tsx
index 31fdb7804..d2303a29c 100644
--- a/web/frontend/src/routes/__root.tsx
+++ b/web/frontend/src/routes/__root.tsx
@@ -1,19 +1,56 @@
-import { Outlet, createRootRoute } from "@tanstack/react-router"
+import {
+ Outlet,
+ createRootRoute,
+ useRouterState,
+} from "@tanstack/react-router"
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"
import { useEffect } from "react"
import { AppLayout } from "@/components/app-layout"
import { initializeChatStore } from "@/features/chat/controller"
+import { isLauncherLoginPathname } from "@/lib/launcher-login-path"
const RootLayout = () => {
+ // Prefer the real address bar path: stale embedded bundles may not register
+ // /launcher-login in the route tree, which would otherwise keep AppLayout +
+ // gateway polling → 401 → launcherFetch redirect loop.
+ const routerState = useRouterState({
+ select: (s) => ({
+ pathname: s.location.pathname,
+ matches: s.matches,
+ }),
+ })
+
+ const windowPath =
+ typeof globalThis.location !== "undefined"
+ ? globalThis.location.pathname || "/"
+ : routerState.pathname
+
+ const isLauncherLogin =
+ isLauncherLoginPathname(windowPath) ||
+ isLauncherLoginPathname(routerState.pathname) ||
+ routerState.matches.some((m) => m.routeId === "/launcher-login")
+
useEffect(() => {
+ if (isLauncherLogin) {
+ return
+ }
initializeChatStore()
- }, [])
+ }, [isLauncherLogin])
+
+ if (isLauncherLogin) {
+ return (
+ <>
+
+ {import.meta.env.DEV ? : null}
+ >
+ )
+ }
return (
-
+ {import.meta.env.DEV ? : null}
)
}
diff --git a/web/frontend/src/routes/agent.tsx b/web/frontend/src/routes/agent.tsx
index 78104de5b..149d095cd 100644
--- a/web/frontend/src/routes/agent.tsx
+++ b/web/frontend/src/routes/agent.tsx
@@ -15,7 +15,7 @@ function AgentLayout() {
})
if (pathname === "/agent") {
- return
+ return
}
return
diff --git a/web/frontend/src/routes/agent/hub.tsx b/web/frontend/src/routes/agent/hub.tsx
new file mode 100644
index 000000000..032d19c05
--- /dev/null
+++ b/web/frontend/src/routes/agent/hub.tsx
@@ -0,0 +1,11 @@
+import { createFileRoute } from "@tanstack/react-router"
+
+import { HubPage } from "@/components/agent/hub/hub-page"
+
+export const Route = createFileRoute("/agent/hub")({
+ component: AgentHubRoute,
+})
+
+function AgentHubRoute() {
+ return
+}
diff --git a/web/frontend/src/routes/agent/skills.tsx b/web/frontend/src/routes/agent/skills.tsx
index bbe396bdb..58890594a 100644
--- a/web/frontend/src/routes/agent/skills.tsx
+++ b/web/frontend/src/routes/agent/skills.tsx
@@ -1,6 +1,6 @@
import { createFileRoute } from "@tanstack/react-router"
-import { SkillsPage } from "@/components/skills/skills-page"
+import { SkillsPage } from "@/components/agent/skills/skills-page"
export const Route = createFileRoute("/agent/skills")({
component: AgentSkillsRoute,
diff --git a/web/frontend/src/routes/agent/tools.tsx b/web/frontend/src/routes/agent/tools.tsx
index ac8738a8f..f33553eba 100644
--- a/web/frontend/src/routes/agent/tools.tsx
+++ b/web/frontend/src/routes/agent/tools.tsx
@@ -1,6 +1,6 @@
import { createFileRoute } from "@tanstack/react-router"
-import { ToolsPage } from "@/components/tools/tools-page"
+import { ToolsPage } from "@/components/agent/tools/tools-page"
export const Route = createFileRoute("/agent/tools")({
component: AgentToolsRoute,
diff --git a/web/frontend/src/routes/launcher-login.tsx b/web/frontend/src/routes/launcher-login.tsx
new file mode 100644
index 000000000..e7f774df7
--- /dev/null
+++ b/web/frontend/src/routes/launcher-login.tsx
@@ -0,0 +1,184 @@
+import { IconLanguage, IconMoon, IconSun } from "@tabler/icons-react"
+import { createFileRoute } from "@tanstack/react-router"
+import * as React from "react"
+import { useTranslation } from "react-i18next"
+
+import {
+ getLauncherAuthStatus,
+ postLauncherDashboardLogin,
+ type LauncherAuthTokenHelp,
+} from "@/api/launcher-auth"
+import { Button } from "@/components/ui/button"
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { useTheme } from "@/hooks/use-theme"
+
+function LauncherLoginPage() {
+ const { t, i18n } = useTranslation()
+ const { theme, toggleTheme } = useTheme()
+ const [token, setToken] = React.useState("")
+ const [submitting, setSubmitting] = React.useState(false)
+ const [error, setError] = React.useState("")
+ const [tokenHelp, setTokenHelp] = React.useState(
+ null,
+ )
+
+ React.useEffect(() => {
+ let cancelled = false
+ void getLauncherAuthStatus()
+ .then((s) => {
+ if (cancelled || s.authenticated || !s.token_help) {
+ return
+ }
+ setTokenHelp(s.token_help)
+ })
+ .catch(() => {
+ /* ignore; login form still usable */
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [])
+
+ const loginWithToken = React.useCallback(
+ async (tokenValue: string) => {
+ setError("")
+ setSubmitting(true)
+ try {
+ const ok = await postLauncherDashboardLogin(tokenValue)
+ if (ok) {
+ globalThis.location.assign("/")
+ return
+ }
+ setError(t("launcherLogin.errorInvalid"))
+ } catch {
+ setError(t("launcherLogin.errorNetwork"))
+ } finally {
+ setSubmitting(false)
+ }
+ },
+ [t],
+ )
+
+ const onSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ await loginWithToken(token)
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ i18n.changeLanguage("en")}>
+ English
+
+ i18n.changeLanguage("zh")}>
+ 简体中文
+
+
+
+ toggleTheme()}
+ aria-label={theme === "dark" ? "Light mode" : "Dark mode"}
+ >
+ {theme === "dark" ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ {t("launcherLogin.title")}
+ {t("launcherLogin.description")}
+
+
+
+ {tokenHelp ? (
+
+
+ {t("launcherLogin.helpTitle")}
+
+
+ {tokenHelp.console_stdout ? (
+ {t("launcherLogin.helpConsole")}
+ ) : null}
+ {tokenHelp.tray_copy_menu ? (
+ {t("launcherLogin.helpTray")}
+ ) : null}
+ {tokenHelp.log_file ? (
+
+ {t("launcherLogin.helpLogFile", {
+ path: tokenHelp.log_file,
+ })}
+
+ ) : null}
+ {tokenHelp.env_var_name ? (
+
+ {t("launcherLogin.helpEnv", {
+ env: tokenHelp.env_var_name,
+ })}
+
+ ) : null}
+
+
+ ) : null}
+
+
+
+
+ )
+}
+
+export const Route = createFileRoute("/launcher-login")({
+ component: LauncherLoginPage,
+})
diff --git a/web/frontend/src/store/index.ts b/web/frontend/src/store/index.ts
index d377cdace..a13b7b161 100644
--- a/web/frontend/src/store/index.ts
+++ b/web/frontend/src/store/index.ts
@@ -1,2 +1,3 @@
export * from "./gateway"
export * from "./chat"
+export * from "./tour"
diff --git a/web/frontend/src/store/tour.ts b/web/frontend/src/store/tour.ts
new file mode 100644
index 000000000..40fe697e2
--- /dev/null
+++ b/web/frontend/src/store/tour.ts
@@ -0,0 +1,69 @@
+import { atom } from "jotai"
+import { atomWithStorage } from "jotai/utils"
+
+export type TourStep = "welcome" | "models" | "gateway" | "docs" | "completed"
+
+export interface TourState {
+ currentStep: TourStep
+ isActive: boolean
+}
+
+const STORAGE_KEY = "picoclaw-tour-state"
+
+const DEFAULT_TOUR_STATE: TourState = {
+ currentStep: "welcome",
+ isActive: true,
+}
+
+export const tourAtom = atomWithStorage(
+ STORAGE_KEY,
+ DEFAULT_TOUR_STATE,
+)
+
+export const tourIsActiveAtom = atom(
+ (get) => get(tourAtom).isActive,
+ (get, set, isActive: boolean) => {
+ set(tourAtom, { ...get(tourAtom), isActive })
+ },
+)
+
+export const tourCurrentStepAtom = atom(
+ (get) => get(tourAtom).currentStep,
+ (get, set, step: TourStep) => {
+ set(tourAtom, { ...get(tourAtom), currentStep: step })
+ },
+)
+
+export function useTourActions() {
+ const goToNextStep = (currentStep: TourStep): TourStep => {
+ const steps: TourStep[] = [
+ "welcome",
+ "models",
+ "gateway",
+ "docs",
+ "completed",
+ ]
+ const currentIndex = steps.indexOf(currentStep)
+ if (currentIndex < steps.length - 1) {
+ return steps[currentIndex + 1]
+ }
+ return "completed"
+ }
+
+ const goToPrevStep = (currentStep: TourStep): TourStep => {
+ const steps: TourStep[] = [
+ "welcome",
+ "models",
+ "gateway",
+ "docs",
+ "completed",
+ ]
+ const currentIndex = steps.indexOf(currentStep)
+ if (currentIndex > 0) {
+ return steps[currentIndex - 1]
+ }
+ return currentStep
+ }
+
+ return { goToNextStep, goToPrevStep }
+}