Merge pull request #1421 from sipeed/refactor/config-ui
refactor(web): redesign config pages and extract raw JSON editor
This commit is contained in:
parent
6460a0a7c7
commit
7872bb3f0a
8 changed files with 529 additions and 568 deletions
|
|
@ -13,7 +13,6 @@ import {
|
||||||
setLauncherConfig as updateLauncherConfig,
|
setLauncherConfig as updateLauncherConfig,
|
||||||
} from "@/api/system"
|
} from "@/api/system"
|
||||||
import {
|
import {
|
||||||
AdvancedSection,
|
|
||||||
AgentDefaultsSection,
|
AgentDefaultsSection,
|
||||||
DevicesSection,
|
DevicesSection,
|
||||||
LauncherSection,
|
LauncherSection,
|
||||||
|
|
@ -30,7 +29,6 @@ import {
|
||||||
} from "@/components/config/form-model"
|
} from "@/components/config/form-model"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Separator } from "@/components/ui/separator"
|
|
||||||
|
|
||||||
export function ConfigPage() {
|
export function ConfigPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
@ -56,11 +54,7 @@ export function ConfigPage() {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const {
|
const { data: launcherConfig, isLoading: isLauncherLoading } = useQuery({
|
||||||
data: launcherConfig,
|
|
||||||
isLoading: isLauncherLoading,
|
|
||||||
error: launcherError,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["system", "launcher-config"],
|
queryKey: ["system", "launcher-config"],
|
||||||
queryFn: getLauncherConfig,
|
queryFn: getLauncherConfig,
|
||||||
})
|
})
|
||||||
|
|
@ -111,10 +105,6 @@ export function ConfigPage() {
|
||||||
? t("pages.config.autostart_unsupported")
|
? t("pages.config.autostart_unsupported")
|
||||||
: t("pages.config.autostart_hint")
|
: t("pages.config.autostart_hint")
|
||||||
|
|
||||||
const launcherHint = launcherError
|
|
||||||
? t("pages.config.launcher_load_error")
|
|
||||||
: t("pages.config.launcher_restart_hint")
|
|
||||||
|
|
||||||
const updateField = <K extends keyof CoreConfigForm>(
|
const updateField = <K extends keyof CoreConfigForm>(
|
||||||
key: K,
|
key: K,
|
||||||
value: CoreConfigForm[K],
|
value: CoreConfigForm[K],
|
||||||
|
|
@ -287,21 +277,14 @@ export function ConfigPage() {
|
||||||
|
|
||||||
<AgentDefaultsSection form={form} onFieldChange={updateField} />
|
<AgentDefaultsSection form={form} onFieldChange={updateField} />
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<RuntimeSection form={form} onFieldChange={updateField} />
|
<RuntimeSection form={form} onFieldChange={updateField} />
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<LauncherSection
|
<LauncherSection
|
||||||
launcherForm={launcherForm}
|
launcherForm={launcherForm}
|
||||||
onFieldChange={updateLauncherField}
|
onFieldChange={updateLauncherField}
|
||||||
launcherHint={launcherHint}
|
|
||||||
disabled={saving || isLauncherLoading}
|
disabled={saving || isLauncherLoading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<DevicesSection
|
<DevicesSection
|
||||||
form={form}
|
form={form}
|
||||||
onFieldChange={updateField}
|
onFieldChange={updateField}
|
||||||
|
|
@ -316,10 +299,6 @@ export function ConfigPage() {
|
||||||
onAutoStartChange={setAutoStartEnabled}
|
onAutoStartChange={setAutoStartEnabled}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<AdvancedSection />
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { IconCode } from "@tabler/icons-react"
|
import type { ReactNode } from "react"
|
||||||
import { Link } from "@tanstack/react-router"
|
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -8,7 +7,13 @@ import {
|
||||||
type LauncherForm,
|
type LauncherForm,
|
||||||
} from "@/components/config/form-model"
|
} from "@/components/config/form-model"
|
||||||
import { Field, SwitchCardField } from "@/components/shared-form"
|
import { Field, SwitchCardField } from "@/components/shared-form"
|
||||||
import { Button } from "@/components/ui/button"
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
|
|
@ -29,6 +34,30 @@ type UpdateLauncherField = <K extends keyof LauncherForm>(
|
||||||
value: LauncherForm[K],
|
value: LauncherForm[K],
|
||||||
) => void
|
) => void
|
||||||
|
|
||||||
|
interface ConfigSectionCardProps {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfigSectionCard({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
}: ConfigSectionCardProps) {
|
||||||
|
return (
|
||||||
|
<Card size="sm">
|
||||||
|
<CardHeader className="border-border border-b">
|
||||||
|
<CardTitle>{title}</CardTitle>
|
||||||
|
{description && <CardDescription>{description}</CardDescription>}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<div className="divide-border/70 divide-y">{children}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
interface AgentDefaultsSectionProps {
|
interface AgentDefaultsSectionProps {
|
||||||
form: CoreConfigForm
|
form: CoreConfigForm
|
||||||
onFieldChange: UpdateCoreField
|
onFieldChange: UpdateCoreField
|
||||||
|
|
@ -41,89 +70,94 @@ export function AgentDefaultsSection({
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-3">
|
<ConfigSectionCard title={t("pages.config.sections.agent")}>
|
||||||
<div className="space-y-4">
|
<Field
|
||||||
<Field
|
label={t("pages.config.workspace")}
|
||||||
label={t("pages.config.workspace")}
|
hint={t("pages.config.workspace_hint")}
|
||||||
hint={t("pages.config.workspace_hint")}
|
layout="setting-row"
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
value={form.workspace}
|
value={form.workspace}
|
||||||
onChange={(e) => onFieldChange("workspace", e.target.value)}
|
onChange={(e) => onFieldChange("workspace", e.target.value)}
|
||||||
placeholder="~/.picoclaw/workspace"
|
placeholder="~/.picoclaw/workspace"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<SwitchCardField
|
<SwitchCardField
|
||||||
label={t("pages.config.restrict_workspace")}
|
label={t("pages.config.restrict_workspace")}
|
||||||
hint={t("pages.config.restrict_workspace_hint")}
|
hint={t("pages.config.restrict_workspace_hint")}
|
||||||
checked={form.restrictToWorkspace}
|
layout="setting-row"
|
||||||
onCheckedChange={(checked) =>
|
checked={form.restrictToWorkspace}
|
||||||
onFieldChange("restrictToWorkspace", checked)
|
onCheckedChange={(checked) =>
|
||||||
|
onFieldChange("restrictToWorkspace", checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SwitchCardField
|
||||||
|
label={t("pages.config.allow_remote")}
|
||||||
|
hint={t("pages.config.allow_remote_hint")}
|
||||||
|
layout="setting-row"
|
||||||
|
checked={form.allowRemote}
|
||||||
|
onCheckedChange={(checked) => onFieldChange("allowRemote", checked)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label={t("pages.config.max_tokens")}
|
||||||
|
hint={t("pages.config.max_tokens_hint")}
|
||||||
|
layout="setting-row"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={form.maxTokens}
|
||||||
|
onChange={(e) => onFieldChange("maxTokens", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label={t("pages.config.max_tool_iterations")}
|
||||||
|
hint={t("pages.config.max_tool_iterations_hint")}
|
||||||
|
layout="setting-row"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={form.maxToolIterations}
|
||||||
|
onChange={(e) => onFieldChange("maxToolIterations", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label={t("pages.config.summarize_threshold")}
|
||||||
|
hint={t("pages.config.summarize_threshold_hint")}
|
||||||
|
layout="setting-row"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={form.summarizeMessageThreshold}
|
||||||
|
onChange={(e) =>
|
||||||
|
onFieldChange("summarizeMessageThreshold", e.target.value)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
<SwitchCardField
|
<Field
|
||||||
label={t("pages.config.allow_remote")}
|
label={t("pages.config.summarize_token_percent")}
|
||||||
hint={t("pages.config.allow_remote_hint")}
|
hint={t("pages.config.summarize_token_percent_hint")}
|
||||||
checked={form.allowRemote}
|
layout="setting-row"
|
||||||
onCheckedChange={(checked) => onFieldChange("allowRemote", checked)}
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
value={form.summarizeTokenPercent}
|
||||||
|
onChange={(e) =>
|
||||||
|
onFieldChange("summarizeTokenPercent", e.target.value)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
</Field>
|
||||||
<Field
|
</ConfigSectionCard>
|
||||||
label={t("pages.config.max_tokens")}
|
|
||||||
hint={t("pages.config.max_tokens_hint")}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={form.maxTokens}
|
|
||||||
onChange={(e) => onFieldChange("maxTokens", e.target.value)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field
|
|
||||||
label={t("pages.config.max_tool_iterations")}
|
|
||||||
hint={t("pages.config.max_tool_iterations_hint")}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={form.maxToolIterations}
|
|
||||||
onChange={(e) => onFieldChange("maxToolIterations", e.target.value)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field
|
|
||||||
label={t("pages.config.summarize_threshold")}
|
|
||||||
hint={t("pages.config.summarize_threshold_hint")}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={form.summarizeMessageThreshold}
|
|
||||||
onChange={(e) =>
|
|
||||||
onFieldChange("summarizeMessageThreshold", e.target.value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field
|
|
||||||
label={t("pages.config.summarize_token_percent")}
|
|
||||||
hint={t("pages.config.summarize_token_percent_hint")}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={100}
|
|
||||||
value={form.summarizeTokenPercent}
|
|
||||||
onChange={(e) =>
|
|
||||||
onFieldChange("summarizeTokenPercent", e.target.value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -139,126 +173,123 @@ export function RuntimeSection({ form, onFieldChange }: RuntimeSectionProps) {
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-3">
|
<ConfigSectionCard title={t("pages.config.sections.runtime")}>
|
||||||
<div className="space-y-4">
|
<Field
|
||||||
<Field
|
label={t("pages.config.session_scope")}
|
||||||
label={t("pages.config.session_scope")}
|
hint={t("pages.config.session_scope_hint")}
|
||||||
hint={t("pages.config.session_scope_hint")}
|
layout="setting-row"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
value={form.dmScope}
|
||||||
|
onValueChange={(value) => onFieldChange("dmScope", value)}
|
||||||
>
|
>
|
||||||
<Select
|
<SelectTrigger className="w-full">
|
||||||
value={form.dmScope}
|
<SelectValue>
|
||||||
onValueChange={(value) => onFieldChange("dmScope", value)}
|
{selectedDmScopeOption
|
||||||
>
|
? t(
|
||||||
<SelectTrigger>
|
selectedDmScopeOption.labelKey,
|
||||||
<SelectValue>
|
selectedDmScopeOption.labelDefault,
|
||||||
{selectedDmScopeOption
|
)
|
||||||
? t(
|
: form.dmScope}
|
||||||
selectedDmScopeOption.labelKey,
|
</SelectValue>
|
||||||
selectedDmScopeOption.labelDefault,
|
</SelectTrigger>
|
||||||
)
|
<SelectContent>
|
||||||
: form.dmScope}
|
{DM_SCOPE_OPTIONS.map((scope) => (
|
||||||
</SelectValue>
|
<SelectItem key={scope.value} value={scope.value}>
|
||||||
</SelectTrigger>
|
<div className="flex flex-col gap-0.5">
|
||||||
<SelectContent>
|
<span className="font-medium">{t(scope.labelKey)}</span>
|
||||||
{DM_SCOPE_OPTIONS.map((scope) => (
|
<span className="text-muted-foreground text-xs">
|
||||||
<SelectItem key={scope.value} value={scope.value}>
|
{t(scope.descKey)}
|
||||||
<div className="flex flex-col gap-0.5">
|
</span>
|
||||||
<span className="font-medium">{t(scope.labelKey)}</span>
|
</div>
|
||||||
<span className="text-muted-foreground text-xs">
|
</SelectItem>
|
||||||
{t(scope.descKey)}
|
))}
|
||||||
</span>
|
</SelectContent>
|
||||||
</div>
|
</Select>
|
||||||
</SelectItem>
|
</Field>
|
||||||
))}
|
|
||||||
</SelectContent>
|
<SwitchCardField
|
||||||
</Select>
|
label={t("pages.config.heartbeat_enabled")}
|
||||||
|
hint={t("pages.config.heartbeat_enabled_hint")}
|
||||||
|
layout="setting-row"
|
||||||
|
checked={form.heartbeatEnabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onFieldChange("heartbeatEnabled", checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{form.heartbeatEnabled && (
|
||||||
|
<Field
|
||||||
|
label={t("pages.config.heartbeat_interval")}
|
||||||
|
hint={t("pages.config.heartbeat_interval_hint")}
|
||||||
|
layout="setting-row"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={form.heartbeatInterval}
|
||||||
|
onChange={(e) => onFieldChange("heartbeatInterval", e.target.value)}
|
||||||
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
)}
|
||||||
<SwitchCardField
|
</ConfigSectionCard>
|
||||||
label={t("pages.config.heartbeat_enabled")}
|
|
||||||
hint={t("pages.config.heartbeat_enabled_hint")}
|
|
||||||
checked={form.heartbeatEnabled}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
onFieldChange("heartbeatEnabled", checked)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{form.heartbeatEnabled && (
|
|
||||||
<Field
|
|
||||||
label={t("pages.config.heartbeat_interval")}
|
|
||||||
hint={t("pages.config.heartbeat_interval_hint")}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={form.heartbeatInterval}
|
|
||||||
onChange={(e) =>
|
|
||||||
onFieldChange("heartbeatInterval", e.target.value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LauncherSectionProps {
|
interface LauncherSectionProps {
|
||||||
launcherForm: LauncherForm
|
launcherForm: LauncherForm
|
||||||
onFieldChange: UpdateLauncherField
|
onFieldChange: UpdateLauncherField
|
||||||
launcherHint: string
|
|
||||||
disabled: boolean
|
disabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LauncherSection({
|
export function LauncherSection({
|
||||||
launcherForm,
|
launcherForm,
|
||||||
onFieldChange,
|
onFieldChange,
|
||||||
launcherHint,
|
|
||||||
disabled,
|
disabled,
|
||||||
}: LauncherSectionProps) {
|
}: LauncherSectionProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-3">
|
<ConfigSectionCard title={t("pages.config.sections.launcher")}>
|
||||||
<div className="space-y-4">
|
<SwitchCardField
|
||||||
<Field
|
label={t("pages.config.lan_access")}
|
||||||
label={t("pages.config.server_port")}
|
hint={t("pages.config.lan_access_hint")}
|
||||||
hint={t("pages.config.server_port_hint")}
|
layout="setting-row"
|
||||||
>
|
checked={launcherForm.publicAccess}
|
||||||
<Input
|
disabled={disabled}
|
||||||
type="number"
|
onCheckedChange={(checked) => onFieldChange("publicAccess", checked)}
|
||||||
min={1}
|
/>
|
||||||
max={65535}
|
|
||||||
value={launcherForm.port}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(e) => onFieldChange("port", e.target.value)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<SwitchCardField
|
<Field
|
||||||
label={t("pages.config.lan_access")}
|
label={t("pages.config.server_port")}
|
||||||
hint={t("pages.config.lan_access_hint")}
|
hint={t("pages.config.server_port_hint")}
|
||||||
checked={launcherForm.publicAccess}
|
layout="setting-row"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
value={launcherForm.port}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onCheckedChange={(checked) => onFieldChange("publicAccess", checked)}
|
onChange={(e) => onFieldChange("port", e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
label={t("pages.config.allowed_cidrs")}
|
label={t("pages.config.allowed_cidrs")}
|
||||||
hint={t("pages.config.allowed_cidrs_hint")}
|
hint={t("pages.config.allowed_cidrs_hint")}
|
||||||
>
|
layout="setting-row"
|
||||||
<Textarea
|
controlClassName="md:max-w-md"
|
||||||
value={launcherForm.allowedCIDRsText}
|
>
|
||||||
disabled={disabled}
|
<Textarea
|
||||||
placeholder={t("pages.config.allowed_cidrs_placeholder")}
|
value={launcherForm.allowedCIDRsText}
|
||||||
className="min-h-[88px]"
|
disabled={disabled}
|
||||||
onChange={(e) => onFieldChange("allowedCIDRsText", e.target.value)}
|
placeholder={t("pages.config.allowed_cidrs_placeholder")}
|
||||||
/>
|
className="min-h-[88px]"
|
||||||
</Field>
|
onChange={(e) => onFieldChange("allowedCIDRsText", e.target.value)}
|
||||||
|
/>
|
||||||
<p className="text-muted-foreground text-xs">{launcherHint}</p>
|
</Field>
|
||||||
</div>
|
</ConfigSectionCard>
|
||||||
</section>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -282,52 +313,31 @@ export function DevicesSection({
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-3">
|
<ConfigSectionCard title={t("pages.config.sections.devices")}>
|
||||||
<div className="space-y-4">
|
<SwitchCardField
|
||||||
<SwitchCardField
|
label={t("pages.config.devices_enabled")}
|
||||||
label={t("pages.config.devices_enabled")}
|
hint={t("pages.config.devices_enabled_hint")}
|
||||||
hint={t("pages.config.devices_enabled_hint")}
|
layout="setting-row"
|
||||||
checked={form.devicesEnabled}
|
checked={form.devicesEnabled}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) => onFieldChange("devicesEnabled", checked)}
|
||||||
onFieldChange("devicesEnabled", checked)
|
/>
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SwitchCardField
|
<SwitchCardField
|
||||||
label={t("pages.config.monitor_usb")}
|
label={t("pages.config.monitor_usb")}
|
||||||
hint={t("pages.config.monitor_usb_hint")}
|
hint={t("pages.config.monitor_usb_hint")}
|
||||||
checked={form.monitorUSB}
|
layout="setting-row"
|
||||||
onCheckedChange={(checked) => onFieldChange("monitorUSB", checked)}
|
checked={form.monitorUSB}
|
||||||
/>
|
onCheckedChange={(checked) => onFieldChange("monitorUSB", checked)}
|
||||||
|
/>
|
||||||
|
|
||||||
<SwitchCardField
|
<SwitchCardField
|
||||||
label={t("pages.config.autostart_label")}
|
label={t("pages.config.autostart_label")}
|
||||||
hint={autoStartHint}
|
hint={autoStartHint}
|
||||||
checked={autoStartEnabled}
|
layout="setting-row"
|
||||||
disabled={autoStartDisabled}
|
checked={autoStartEnabled}
|
||||||
onCheckedChange={onAutoStartChange}
|
disabled={autoStartDisabled}
|
||||||
/>
|
onCheckedChange={onAutoStartChange}
|
||||||
</div>
|
/>
|
||||||
</section>
|
</ConfigSectionCard>
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AdvancedSection() {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="space-y-3">
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
{t("pages.config.advanced_desc")}
|
|
||||||
</p>
|
|
||||||
<div>
|
|
||||||
<Button variant="outline" asChild>
|
|
||||||
<Link to="/config/raw">
|
|
||||||
<IconCode className="size-4" />
|
|
||||||
{t("pages.config.open_raw")}
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
207
web/frontend/src/components/config/raw-config-page.tsx
Normal file
207
web/frontend/src/components/config/raw-config-page.tsx
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
import { IconAdjustments } from "@tabler/icons-react"
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { Link } from "@tanstack/react-router"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/components/ui/alert-dialog"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
|
||||||
|
export function RawConfigPage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const { data: config, isLoading } = useQuery({
|
||||||
|
queryKey: ["config"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch("/api/config")
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error("Failed to fetch config")
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async (newConfig: string) => {
|
||||||
|
const res = await fetch("/api/config", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: newConfig,
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error("Failed to save config")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: (_, submittedConfig) => {
|
||||||
|
toast.success(t("pages.config.save_success"))
|
||||||
|
try {
|
||||||
|
const savedConfig = JSON.parse(submittedConfig)
|
||||||
|
setLastSavedConfig(savedConfig)
|
||||||
|
setIsDirty(false)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["config"] })
|
||||||
|
} catch {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["config"] })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t("pages.config.save_error"))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const [editorValue, setEditorValue] = useState("")
|
||||||
|
const [isDirty, setIsDirty] = useState(false)
|
||||||
|
const [lastSavedConfig, setLastSavedConfig] = useState<Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
> | null>(null)
|
||||||
|
|
||||||
|
const effectiveEditorValue =
|
||||||
|
editorValue || (config ? JSON.stringify(config, null, 2) : "")
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
try {
|
||||||
|
JSON.parse(effectiveEditorValue)
|
||||||
|
mutation.mutate(effectiveEditorValue)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
t(
|
||||||
|
"pages.config.invalid_json",
|
||||||
|
error instanceof Error ? error.message : "Invalid JSON format.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFormat = () => {
|
||||||
|
try {
|
||||||
|
const formatted = JSON.stringify(
|
||||||
|
JSON.parse(effectiveEditorValue),
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
setEditorValue(formatted)
|
||||||
|
toast.success(t("pages.config.format_success"))
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
t(
|
||||||
|
"pages.config.format_error",
|
||||||
|
error instanceof Error ? error.message : "Invalid JSON format.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [showResetDialog, setShowResetDialog] = useState(false)
|
||||||
|
|
||||||
|
const confirmReset = () => {
|
||||||
|
if (lastSavedConfig) {
|
||||||
|
setEditorValue(JSON.stringify(lastSavedConfig, null, 2))
|
||||||
|
} else if (config) {
|
||||||
|
setEditorValue(JSON.stringify(config, null, 2))
|
||||||
|
}
|
||||||
|
setIsDirty(false)
|
||||||
|
toast.info(t("pages.config.reset_success"))
|
||||||
|
setShowResetDialog(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<PageHeader title={t("pages.config.raw_json_title")}>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link to="/config">
|
||||||
|
<IconAdjustments className="size-4" />
|
||||||
|
{t("pages.config.back_to_visual")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col p-1 lg:p-3 lg:p-6">
|
||||||
|
<div className="mx-auto flex h-full min-h-0 w-full max-w-[1000px] flex-col">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
<p>{t("labels.loading")}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||||
|
{isDirty && (
|
||||||
|
<div className="shrink-0 rounded-lg border border-yellow-200 bg-yellow-50 p-2 text-sm text-yellow-700">
|
||||||
|
{t("pages.config.unsaved_changes")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="relative min-h-0 flex-1 overflow-hidden rounded-lg border shadow-sm">
|
||||||
|
<Textarea
|
||||||
|
value={effectiveEditorValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
setEditorValue(e.target.value)
|
||||||
|
setIsDirty(true)
|
||||||
|
}}
|
||||||
|
wrap="off"
|
||||||
|
className="h-full min-h-0 resize-none overflow-auto border-0 bg-transparent px-4 py-3 font-mono text-sm [overflow-wrap:normal] whitespace-pre shadow-none focus-visible:ring-0"
|
||||||
|
placeholder={t("pages.config.json_placeholder")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleFormat}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
>
|
||||||
|
{t("pages.config.format")}
|
||||||
|
</Button>
|
||||||
|
<AlertDialog
|
||||||
|
open={showResetDialog}
|
||||||
|
onOpenChange={setShowResetDialog}
|
||||||
|
>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={!isDirty}
|
||||||
|
onClick={() => setShowResetDialog(true)}
|
||||||
|
>
|
||||||
|
{t("common.reset")}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{t("pages.config.reset_confirm_title")}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t("pages.config.reset_confirm_desc")}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={confirmReset}>
|
||||||
|
{t("common.confirm")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
<Button onClick={handleSave} disabled={mutation.isPending}>
|
||||||
|
{mutation.isPending ? t("common.saving") : t("common.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,202 +0,0 @@
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
|
||||||
import { useState } from "react"
|
|
||||||
import { useTranslation } from "react-i18next"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
} from "@/components/ui/alert-dialog"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card"
|
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
|
||||||
|
|
||||||
export function RawJsonPanel() {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
const { data: config, isLoading } = useQuery({
|
|
||||||
queryKey: ["config"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await fetch("/api/config")
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error("Failed to fetch config")
|
|
||||||
}
|
|
||||||
return res.json()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const mutation = useMutation({
|
|
||||||
mutationFn: async (newConfig: string) => {
|
|
||||||
const res = await fetch("/api/config", {
|
|
||||||
method: "PUT",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: newConfig,
|
|
||||||
})
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error("Failed to save config")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSuccess: (_, submittedConfig) => {
|
|
||||||
toast.success(t("pages.config.save_success"))
|
|
||||||
try {
|
|
||||||
const savedConfig = JSON.parse(submittedConfig)
|
|
||||||
setLastSavedConfig(savedConfig)
|
|
||||||
setIsDirty(false)
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["config"] })
|
|
||||||
} catch {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["config"] })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast.error(t("pages.config.save_error"))
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const [editorValue, setEditorValue] = useState("")
|
|
||||||
const [isDirty, setIsDirty] = useState(false)
|
|
||||||
const [lastSavedConfig, setLastSavedConfig] = useState<Record<
|
|
||||||
string,
|
|
||||||
unknown
|
|
||||||
> | null>(null)
|
|
||||||
|
|
||||||
const effectiveEditorValue =
|
|
||||||
editorValue || (config ? JSON.stringify(config, null, 2) : "")
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
try {
|
|
||||||
JSON.parse(effectiveEditorValue)
|
|
||||||
mutation.mutate(effectiveEditorValue)
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(
|
|
||||||
t(
|
|
||||||
"pages.config.invalid_json",
|
|
||||||
error instanceof Error ? error.message : "Invalid JSON format.",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFormat = () => {
|
|
||||||
try {
|
|
||||||
const formatted = JSON.stringify(
|
|
||||||
JSON.parse(effectiveEditorValue),
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
)
|
|
||||||
setEditorValue(formatted)
|
|
||||||
toast.success(t("pages.config.format_success"))
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(
|
|
||||||
t(
|
|
||||||
"pages.config.format_error",
|
|
||||||
error instanceof Error ? error.message : "Invalid JSON format.",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [showResetDialog, setShowResetDialog] = useState(false)
|
|
||||||
|
|
||||||
const confirmReset = () => {
|
|
||||||
if (lastSavedConfig) {
|
|
||||||
setEditorValue(JSON.stringify(lastSavedConfig, null, 2))
|
|
||||||
} else if (config) {
|
|
||||||
setEditorValue(JSON.stringify(config, null, 2))
|
|
||||||
}
|
|
||||||
setIsDirty(false)
|
|
||||||
toast.info(t("pages.config.reset_success"))
|
|
||||||
setShowResetDialog(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{t("pages.config.raw_json_title")}</CardTitle>
|
|
||||||
<CardDescription>{t("pages.config.raw_json_desc")}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex h-64 items-center justify-center">
|
|
||||||
<p>{t("labels.loading")}</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{isDirty && (
|
|
||||||
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-2 text-sm text-yellow-700">
|
|
||||||
{t("pages.config.unsaved_changes")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="bg-muted/30 relative rounded-lg border">
|
|
||||||
<Textarea
|
|
||||||
value={effectiveEditorValue}
|
|
||||||
onChange={(e) => {
|
|
||||||
setEditorValue(e.target.value)
|
|
||||||
setIsDirty(true)
|
|
||||||
}}
|
|
||||||
wrap="off"
|
|
||||||
className="min-h-[200px] resize-none overflow-x-auto border-0 bg-transparent px-4 py-3 font-mono text-sm whitespace-pre shadow-none [overflow-wrap:normal] focus-visible:ring-0"
|
|
||||||
placeholder={t("pages.config.json_placeholder")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end space-x-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleFormat}
|
|
||||||
disabled={mutation.isPending}
|
|
||||||
>
|
|
||||||
{t("pages.config.format")}
|
|
||||||
</Button>
|
|
||||||
<AlertDialog
|
|
||||||
open={showResetDialog}
|
|
||||||
onOpenChange={setShowResetDialog}
|
|
||||||
>
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
disabled={!isDirty}
|
|
||||||
onClick={() => setShowResetDialog(true)}
|
|
||||||
>
|
|
||||||
{t("common.reset")}
|
|
||||||
</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>
|
|
||||||
{t("pages.config.reset_confirm_title")}
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
{t("pages.config.reset_confirm_desc")}
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
|
||||||
<AlertDialogAction onClick={confirmReset}>
|
|
||||||
{t("common.confirm")}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
<Button onClick={handleSave} disabled={mutation.isPending}>
|
|
||||||
{mutation.isPending ? t("common.saving") : t("common.save")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -9,6 +9,9 @@ import {
|
||||||
} from "@/components/ui/field"
|
} from "@/components/ui/field"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
type FieldLayout = "default" | "setting-row"
|
||||||
|
|
||||||
interface FieldProps {
|
interface FieldProps {
|
||||||
label: string
|
label: string
|
||||||
|
|
@ -16,9 +19,45 @@ interface FieldProps {
|
||||||
error?: string
|
error?: string
|
||||||
required?: boolean
|
required?: boolean
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
|
layout?: FieldLayout
|
||||||
|
controlClassName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Field({ label, hint, error, required, children }: FieldProps) {
|
export function Field({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
error,
|
||||||
|
required,
|
||||||
|
children,
|
||||||
|
layout = "default",
|
||||||
|
controlClassName,
|
||||||
|
}: FieldProps) {
|
||||||
|
if (layout === "setting-row") {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 py-4 md:grid md:grid-cols-[minmax(0,1fr)_minmax(240px,320px)] md:items-center md:gap-6">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<FieldLabel>
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-destructive ml-1">*</span>}
|
||||||
|
</FieldLabel>
|
||||||
|
{hint && (
|
||||||
|
<FieldDescription className="text-xs leading-normal">
|
||||||
|
{hint}
|
||||||
|
</FieldDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={cn("w-full md:justify-self-center", controlClassName)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<FieldDescription className="text-destructive text-xs leading-normal md:col-start-2">
|
||||||
|
{error}
|
||||||
|
</FieldDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UiField className="gap-2.5">
|
<UiField className="gap-2.5">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|
@ -85,6 +124,7 @@ interface SwitchCardFieldProps {
|
||||||
ariaLabel?: string
|
ariaLabel?: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
|
layout?: FieldLayout
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SwitchCardField({
|
export function SwitchCardField({
|
||||||
|
|
@ -96,7 +136,37 @@ export function SwitchCardField({
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
disabled,
|
disabled,
|
||||||
children,
|
children,
|
||||||
|
layout = "default",
|
||||||
}: SwitchCardFieldProps) {
|
}: SwitchCardFieldProps) {
|
||||||
|
if (layout === "setting-row") {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 py-4 md:grid md:grid-cols-[minmax(0,1fr)_auto] md:items-center md:gap-6">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium">{label}</p>
|
||||||
|
{hint && (
|
||||||
|
<p className="text-muted-foreground mt-0.5 text-xs leading-normal">
|
||||||
|
{hint}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center md:justify-self-center">
|
||||||
|
<Switch
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={onCheckedChange}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={ariaLabel ?? label}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{children && <div className="md:col-start-2">{children}</div>}
|
||||||
|
{error && (
|
||||||
|
<p className="text-destructive text-xs leading-normal md:col-start-2">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-border/60 bg-background rounded-lg border px-4 py-3">
|
<div className="border-border/60 bg-background rounded-lg border px-4 py-3">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
|
|
||||||
|
|
@ -331,33 +331,10 @@
|
||||||
"pages": {
|
"pages": {
|
||||||
"agent": {
|
"agent": {
|
||||||
"load_error": "Failed to load agent support information.",
|
"load_error": "Failed to load agent support information.",
|
||||||
"stats": {
|
|
||||||
"workspace": "Workspace",
|
|
||||||
"workspace_hint": "The default agent workspace used for runtime files and workspace skills.",
|
|
||||||
"skills": "Available Skills",
|
|
||||||
"skills_hint": "Skills discovered from workspace, global, and builtin roots.",
|
|
||||||
"tools": "Enabled Tools",
|
|
||||||
"tools_hint": "{{blocked}} blocked by missing dependencies."
|
|
||||||
},
|
|
||||||
"skills": {
|
"skills": {
|
||||||
"title": "Skills",
|
|
||||||
"description": "Skills are loaded from the workspace, global PicoClaw home, and builtin directories.",
|
"description": "Skills are loaded from the workspace, global PicoClaw home, and builtin directories.",
|
||||||
"hero_title": "Skill Library",
|
|
||||||
"hero_description": "Browse every capability package the agent can load, then drill straight into the effective SKILL.md without leaving the page.",
|
|
||||||
"stats": {
|
|
||||||
"total": "Total Skills",
|
|
||||||
"workspace": "Workspace",
|
|
||||||
"shared": "Shared"
|
|
||||||
},
|
|
||||||
"empty": "No skills are currently available.",
|
"empty": "No skills are currently available.",
|
||||||
"import": "Import Skill",
|
"import": "Import Skill",
|
||||||
"import_title": "Import Skill",
|
|
||||||
"import_description": "Create a workspace skill by uploading a markdown file as the new SKILL.md.",
|
|
||||||
"import_name": "Skill Name",
|
|
||||||
"import_name_placeholder": "e.g. my-workflow",
|
|
||||||
"import_file": "Markdown File",
|
|
||||||
"import_file_hint": "Upload a .md file. The backend stores it as workspace/skills/<name>/SKILL.md.",
|
|
||||||
"import_confirm": "Import Skill",
|
|
||||||
"import_success": "Skill imported.",
|
"import_success": "Skill imported.",
|
||||||
"import_error": "Failed to import skill.",
|
"import_error": "Failed to import skill.",
|
||||||
"view": "View",
|
"view": "View",
|
||||||
|
|
@ -371,28 +348,11 @@
|
||||||
"viewer_description": "Read the current effective SKILL.md content here.",
|
"viewer_description": "Read the current effective SKILL.md content here.",
|
||||||
"loading_detail": "Loading skill content...",
|
"loading_detail": "Loading skill content...",
|
||||||
"load_detail_error": "Failed to load skill content.",
|
"load_detail_error": "Failed to load skill content.",
|
||||||
"source": "Source",
|
|
||||||
"path": "Skill Path",
|
"path": "Skill Path",
|
||||||
"no_description": "No description provided.",
|
"no_description": "No description provided."
|
||||||
"sources": {
|
|
||||||
"workspace": "Workspace",
|
|
||||||
"global": "Global",
|
|
||||||
"builtin": "Builtin"
|
|
||||||
},
|
|
||||||
"errors": {
|
|
||||||
"file_required": "Please choose a markdown file to import."
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
"title": "Tools",
|
|
||||||
"description": "This view reflects whether each agent tool is enabled, disabled, or blocked by a missing prerequisite.",
|
"description": "This view reflects whether each agent tool is enabled, disabled, or blocked by a missing prerequisite.",
|
||||||
"hero_title": "Tool Surface",
|
|
||||||
"hero_description": "Inspect what the agent can actually call right now, which capabilities are blocked, and where each tool is controlled in config.",
|
|
||||||
"stats": {
|
|
||||||
"enabled": "Enabled",
|
|
||||||
"blocked": "Blocked",
|
|
||||||
"categories": "Categories"
|
|
||||||
},
|
|
||||||
"empty": "No tools are available.",
|
"empty": "No tools are available.",
|
||||||
"enable": "Enable",
|
"enable": "Enable",
|
||||||
"disable": "Disable",
|
"disable": "Disable",
|
||||||
|
|
@ -468,13 +428,15 @@
|
||||||
"allowed_cidrs": "Allowed Network CIDRs",
|
"allowed_cidrs": "Allowed Network CIDRs",
|
||||||
"allowed_cidrs_hint": "Only clients from these CIDR ranges can access the service. One per line or comma-separated. Leave empty to allow all.",
|
"allowed_cidrs_hint": "Only clients from these CIDR ranges can access the service. One per line or comma-separated. Leave empty to allow all.",
|
||||||
"allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8",
|
"allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8",
|
||||||
"launcher_load_error": "Failed to load service parameters.",
|
"sections": {
|
||||||
"launcher_restart_hint": "Service parameter changes apply after restarting PicoClaw Web.",
|
"agent": "Agent",
|
||||||
"advanced_desc": "Open the raw JSON page to edit every field directly.",
|
"runtime": "Runtime",
|
||||||
|
"launcher": "Service",
|
||||||
|
"devices": "Devices"
|
||||||
|
},
|
||||||
"open_raw": "Raw Config",
|
"open_raw": "Raw Config",
|
||||||
"back_to_visual": "Visual Config",
|
"back_to_visual": "Visual Config",
|
||||||
"raw_json_title": "Raw JSON Configuration",
|
"raw_json_title": "Raw JSON Configuration",
|
||||||
"raw_json_desc": "Advanced users can directly edit the raw JSON configuration below.",
|
|
||||||
"json_placeholder": "Enter valid JSON configuration...",
|
"json_placeholder": "Enter valid JSON configuration...",
|
||||||
"save_success": "Configuration saved successfully.",
|
"save_success": "Configuration saved successfully.",
|
||||||
"save_error": "Failed to save configuration.",
|
"save_error": "Failed to save configuration.",
|
||||||
|
|
|
||||||
|
|
@ -331,33 +331,10 @@
|
||||||
"pages": {
|
"pages": {
|
||||||
"agent": {
|
"agent": {
|
||||||
"load_error": "加载 Agent 支持信息失败。",
|
"load_error": "加载 Agent 支持信息失败。",
|
||||||
"stats": {
|
|
||||||
"workspace": "工作目录",
|
|
||||||
"workspace_hint": "默认 Agent 运行时使用的工作目录,也用于加载工作区技能。",
|
|
||||||
"skills": "可用技能数",
|
|
||||||
"skills_hint": "从工作区、全局目录和内置目录发现的技能。",
|
|
||||||
"tools": "已启用工具",
|
|
||||||
"tools_hint": "其中 {{blocked}} 个因依赖未满足而不可用。"
|
|
||||||
},
|
|
||||||
"skills": {
|
"skills": {
|
||||||
"title": "技能",
|
|
||||||
"description": "技能会从工作区、PicoClaw 全局目录和内置目录中加载。",
|
"description": "技能会从工作区、PicoClaw 全局目录和内置目录中加载。",
|
||||||
"hero_title": "技能库",
|
|
||||||
"hero_description": "在这里查看 Agent 当前可加载的能力包,并且不离开页面就能直接阅读生效后的 SKILL.md。",
|
|
||||||
"stats": {
|
|
||||||
"total": "技能总数",
|
|
||||||
"workspace": "工作区技能",
|
|
||||||
"shared": "共享技能"
|
|
||||||
},
|
|
||||||
"empty": "当前没有可用技能。",
|
"empty": "当前没有可用技能。",
|
||||||
"import": "导入技能",
|
"import": "导入技能",
|
||||||
"import_title": "导入技能",
|
|
||||||
"import_description": "通过上传 Markdown 文件创建工作区技能,文件会保存为新的 SKILL.md。",
|
|
||||||
"import_name": "技能名称",
|
|
||||||
"import_name_placeholder": "例如 my-workflow",
|
|
||||||
"import_file": "Markdown 文件",
|
|
||||||
"import_file_hint": "上传一个 .md 文件。后端会保存到 workspace/skills/<name>/SKILL.md。",
|
|
||||||
"import_confirm": "导入技能",
|
|
||||||
"import_success": "技能导入成功。",
|
"import_success": "技能导入成功。",
|
||||||
"import_error": "导入技能失败。",
|
"import_error": "导入技能失败。",
|
||||||
"view": "查看",
|
"view": "查看",
|
||||||
|
|
@ -371,28 +348,11 @@
|
||||||
"viewer_description": "这里展示当前生效的 SKILL.md 内容。",
|
"viewer_description": "这里展示当前生效的 SKILL.md 内容。",
|
||||||
"loading_detail": "正在加载技能内容...",
|
"loading_detail": "正在加载技能内容...",
|
||||||
"load_detail_error": "加载技能内容失败。",
|
"load_detail_error": "加载技能内容失败。",
|
||||||
"source": "来源",
|
|
||||||
"path": "技能路径",
|
"path": "技能路径",
|
||||||
"no_description": "未提供描述。",
|
"no_description": "未提供描述。"
|
||||||
"sources": {
|
|
||||||
"workspace": "工作区",
|
|
||||||
"global": "全局",
|
|
||||||
"builtin": "内置"
|
|
||||||
},
|
|
||||||
"errors": {
|
|
||||||
"file_required": "请先选择要导入的 Markdown 文件。"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
"title": "工具",
|
|
||||||
"description": "这里展示每个 Agent 工具当前是已启用、已禁用,还是被依赖条件阻塞。",
|
"description": "这里展示每个 Agent 工具当前是已启用、已禁用,还是被依赖条件阻塞。",
|
||||||
"hero_title": "工具面板",
|
|
||||||
"hero_description": "集中查看 Agent 现在真正可调用的工具、被阻塞的能力,以及它们分别受哪项配置控制。",
|
|
||||||
"stats": {
|
|
||||||
"enabled": "已启用",
|
|
||||||
"blocked": "被阻塞",
|
|
||||||
"categories": "分类数"
|
|
||||||
},
|
|
||||||
"empty": "当前没有可用工具。",
|
"empty": "当前没有可用工具。",
|
||||||
"enable": "启用",
|
"enable": "启用",
|
||||||
"disable": "禁用",
|
"disable": "禁用",
|
||||||
|
|
@ -465,16 +425,18 @@
|
||||||
"server_port_hint": "PicoClaw Web 的 HTTP 监听端口。",
|
"server_port_hint": "PicoClaw Web 的 HTTP 监听端口。",
|
||||||
"lan_access": "启用局域网访问",
|
"lan_access": "启用局域网访问",
|
||||||
"lan_access_hint": "允许局域网中的其他设备访问当前服务。",
|
"lan_access_hint": "允许局域网中的其他设备访问当前服务。",
|
||||||
"allowed_cidrs": "允许访问网段(CIDR)",
|
"allowed_cidrs": "允许访问网段",
|
||||||
"allowed_cidrs_hint": "仅允许这些 CIDR 网段的客户端访问服务。可按行或逗号分隔;留空表示允许所有来源。",
|
"allowed_cidrs_hint": "仅允许这些 CIDR 网段的客户端访问服务。可按行或逗号分隔;留空表示允许所有来源。",
|
||||||
"allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8",
|
"allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8",
|
||||||
"launcher_load_error": "加载服务参数失败。",
|
"sections": {
|
||||||
"launcher_restart_hint": "服务参数变更需重启 PicoClaw Web 后生效。",
|
"agent": "智能体",
|
||||||
"advanced_desc": "可打开原始 JSON 页面直接编辑全部字段。",
|
"runtime": "运行时",
|
||||||
|
"launcher": "服务参数",
|
||||||
|
"devices": "设备"
|
||||||
|
},
|
||||||
"open_raw": "原始配置",
|
"open_raw": "原始配置",
|
||||||
"back_to_visual": "可视化配置",
|
"back_to_visual": "可视化配置",
|
||||||
"raw_json_title": "原始 JSON 配置",
|
"raw_json_title": "原始 JSON 配置",
|
||||||
"raw_json_desc": "高级用户可以直接编辑下方的原始 JSON 配置。",
|
|
||||||
"json_placeholder": "请输入有效的 JSON 配置...",
|
"json_placeholder": "请输入有效的 JSON 配置...",
|
||||||
"save_success": "配置保存成功。",
|
"save_success": "配置保存成功。",
|
||||||
"save_error": "配置保存失败。",
|
"save_error": "配置保存失败。",
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,7 @@
|
||||||
import { IconAdjustments } from "@tabler/icons-react"
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
import { Link, createFileRoute } from "@tanstack/react-router"
|
|
||||||
import { useTranslation } from "react-i18next"
|
|
||||||
|
|
||||||
import { RawJsonPanel } from "@/components/config/raw-json-panel"
|
import { RawConfigPage } from "@/components/config/raw-config-page"
|
||||||
import { PageHeader } from "@/components/page-header"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/config/raw")({
|
export const Route = createFileRoute("/config/raw")({
|
||||||
component: RawConfigPage,
|
component: RawConfigPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
function RawConfigPage() {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col">
|
|
||||||
<PageHeader title={t("pages.config.raw_json_title")}>
|
|
||||||
<Button variant="outline" asChild>
|
|
||||||
<Link to="/config">
|
|
||||||
<IconAdjustments className="size-4" />
|
|
||||||
{t("pages.config.back_to_visual")}
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</PageHeader>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto p-3 lg:p-6">
|
|
||||||
<div className="mx-auto max-w-4xl">
|
|
||||||
<RawJsonPanel />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue