mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
fix(web): restore chat composer disabled-state messaging and clean up code (#2526)
This commit is contained in:
@@ -41,9 +41,7 @@ export async function postLauncherDashboardLogout(): Promise<boolean> {
|
||||
return res.ok
|
||||
}
|
||||
|
||||
export type SetupResult =
|
||||
| { ok: true }
|
||||
| { ok: false; error: string }
|
||||
export type SetupResult = { ok: true } | { ok: false; error: string }
|
||||
|
||||
export async function postLauncherDashboardSetup(
|
||||
password: string,
|
||||
@@ -53,7 +51,10 @@ export async function postLauncherDashboardSetup(
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({ password: password.trim(), confirm: confirm.trim() }),
|
||||
body: JSON.stringify({
|
||||
password: password.trim(),
|
||||
confirm: confirm.trim(),
|
||||
}),
|
||||
})
|
||||
if (res.ok) return { ok: true }
|
||||
let msg = "Unknown error"
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Link } from "@tanstack/react-router"
|
||||
import * as React from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { postLauncherDashboardLogout } from "@/api/launcher-auth"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -40,7 +41,6 @@ import {
|
||||
} from "@/components/ui/tooltip"
|
||||
import { useGateway } from "@/hooks/use-gateway.ts"
|
||||
import { useTheme } from "@/hooks/use-theme.ts"
|
||||
import { postLauncherDashboardLogout } from "@/api/launcher-auth"
|
||||
|
||||
export function AppHeader() {
|
||||
const { i18n, t } = useTranslation()
|
||||
@@ -198,27 +198,42 @@ export function AppHeader() {
|
||||
<IconPower className="h-4 w-4 opacity-80" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{gwError ?? t("header.gateway.action.stop")}</TooltipContent>
|
||||
<TooltipContent>
|
||||
{gwError ?? t("header.gateway.action.stop")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip delayDuration={(gwError || (!canStart && startReason)) ? 0 : 700}>
|
||||
<Tooltip
|
||||
delayDuration={gwError || (!canStart && startReason) ? 0 : 700}
|
||||
>
|
||||
<TooltipTrigger asChild>
|
||||
{/* Wrap in span so the tooltip still fires when the button is disabled */}
|
||||
<span
|
||||
className={!canStart && startReason ? "cursor-not-allowed" : undefined}
|
||||
className={
|
||||
!canStart && startReason ? "cursor-not-allowed" : undefined
|
||||
}
|
||||
tabIndex={!canStart && startReason ? 0 : undefined}
|
||||
>
|
||||
<Button
|
||||
variant={
|
||||
isStarting || isRestarting || isStopping ? "secondary" : "default"
|
||||
isStarting || isRestarting || isStopping
|
||||
? "secondary"
|
||||
: "default"
|
||||
}
|
||||
size="sm"
|
||||
data-tour="gateway-button"
|
||||
className={`h-8 gap-2 px-3 ${isStopped ? "bg-green-500 text-white hover:bg-green-600" : ""
|
||||
} ${!canStart ? "pointer-events-none" : ""}`}
|
||||
className={`h-8 gap-2 px-3 ${
|
||||
isStopped
|
||||
? "bg-green-500 text-white hover:bg-green-600"
|
||||
: ""
|
||||
} ${!canStart ? "pointer-events-none" : ""}`}
|
||||
onClick={handleGatewayToggle}
|
||||
disabled={
|
||||
gwLoading || isStarting || isRestarting || isStopping || !canStart
|
||||
gwLoading ||
|
||||
isStarting ||
|
||||
isRestarting ||
|
||||
isStopping ||
|
||||
!canStart
|
||||
}
|
||||
>
|
||||
{gwLoading || isStarting || isRestarting || isStopping ? (
|
||||
@@ -238,7 +253,7 @@ export function AppHeader() {
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{(gwError || (!canStart && startReason)) ? (
|
||||
{gwError || (!canStart && startReason) ? (
|
||||
<TooltipContent>{gwError ?? startReason}</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
|
||||
@@ -42,15 +42,11 @@ export function ChatComposer({
|
||||
}: ChatComposerProps) {
|
||||
const { t } = useTranslation()
|
||||
const canInput = inputDisabledReason === null
|
||||
const placeholder = canInput
|
||||
? t("chat.placeholder")
|
||||
: t(`chat.disabledPlaceholder.${inputDisabledReason}`)
|
||||
|
||||
const inputDisabledReason = (() => {
|
||||
if (!isConnected) return t("chat.inputDisabled.notConnected")
|
||||
if (!hasDefaultModel) return t("chat.inputDisabled.noModel")
|
||||
return null
|
||||
})()
|
||||
const disabledMessage =
|
||||
inputDisabledReason === null
|
||||
? null
|
||||
: t(`chat.disabledPlaceholder.${inputDisabledReason}`)
|
||||
const placeholder = disabledMessage ?? t("chat.placeholder")
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.nativeEvent.isComposing) return
|
||||
@@ -95,7 +91,7 @@ export function ChatComposer({
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={!canInput}
|
||||
title={inputDisabledReason || undefined}
|
||||
title={disabledMessage || undefined}
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground/50 max-h-[200px] min-h-[60px] resize-none border-0 bg-transparent px-2 py-1 text-[15px] shadow-none transition-colors focus-visible:ring-0 focus-visible:outline-none dark:bg-transparent",
|
||||
!canInput && "cursor-not-allowed",
|
||||
@@ -103,9 +99,9 @@ export function ChatComposer({
|
||||
minRows={1}
|
||||
maxRows={8}
|
||||
/>
|
||||
{!canInput && inputDisabledReason && (
|
||||
<div className="px-3 py-1 text-xs text-muted-foreground">
|
||||
{inputDisabledReason}
|
||||
{!canInput && disabledMessage && (
|
||||
<div className="text-muted-foreground px-3 py-1 text-xs">
|
||||
{disabledMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ import { toast } from "sonner"
|
||||
|
||||
import { AssistantMessage } from "@/components/chat/assistant-message"
|
||||
import {
|
||||
type ChatInputDisabledReason,
|
||||
ChatComposer,
|
||||
type ChatInputDisabledReason,
|
||||
} from "@/components/chat/chat-composer"
|
||||
import { ChatEmptyState } from "@/components/chat/chat-empty-state"
|
||||
import { ModelSelector } from "@/components/chat/model-selector"
|
||||
|
||||
@@ -12,10 +12,7 @@ import {
|
||||
generateSessionId,
|
||||
readStoredSessionId,
|
||||
} from "@/features/chat/state"
|
||||
import {
|
||||
invalidateSocket,
|
||||
isCurrentSocket,
|
||||
} from "@/features/chat/websocket"
|
||||
import { invalidateSocket, isCurrentSocket } from "@/features/chat/websocket"
|
||||
import i18n from "@/i18n"
|
||||
import {
|
||||
type ChatAttachment,
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { normalizeUnixTimestamp } from "@/features/chat/state"
|
||||
import {
|
||||
type AssistantMessageKind,
|
||||
updateChatStore,
|
||||
} from "@/store/chat"
|
||||
import { type AssistantMessageKind, updateChatStore } from "@/store/chat"
|
||||
|
||||
export interface PicoMessage {
|
||||
type: string
|
||||
|
||||
@@ -77,5 +77,15 @@ export function useGateway() {
|
||||
}
|
||||
}, [state])
|
||||
|
||||
return { state, loading, canStart, startReason, restartRequired, start, stop, restart, error }
|
||||
return {
|
||||
state,
|
||||
loading,
|
||||
canStart,
|
||||
startReason,
|
||||
restartRequired,
|
||||
start,
|
||||
stop,
|
||||
restart,
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,9 @@ const RootLayout = () => {
|
||||
globalThis.location.assign("/launcher-login")
|
||||
} else {
|
||||
setAuthError(
|
||||
err instanceof Error ? err.message : "Auth service unavailable, please try to delete the launcher-auth.db at picoclaw home directory and restart the application.",
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Auth service unavailable, please try to delete the launcher-auth.db at picoclaw home directory and restart the application.",
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3,7 +3,10 @@ import { createFileRoute } from "@tanstack/react-router"
|
||||
import * as React from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { postLauncherDashboardLogin, getLauncherAuthStatus } from "@/api/launcher-auth"
|
||||
import {
|
||||
getLauncherAuthStatus,
|
||||
postLauncherDashboardLogin,
|
||||
} from "@/api/launcher-auth"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
@@ -37,7 +40,9 @@ function LauncherLoginPage() {
|
||||
globalThis.location.assign("/launcher-setup")
|
||||
}
|
||||
})
|
||||
.catch(() => { /* network error — stay on login page */ })
|
||||
.catch(() => {
|
||||
/* network error — stay on login page */
|
||||
})
|
||||
}, [])
|
||||
|
||||
const loginWithToken = React.useCallback(
|
||||
|
||||
@@ -6,141 +6,141 @@ import { useTranslation } from "react-i18next"
|
||||
import { postLauncherDashboardSetup } from "@/api/launcher-auth"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
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 LauncherSetupPage() {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
const [password, setPassword] = React.useState("")
|
||||
const [confirm, setConfirm] = React.useState("")
|
||||
const [submitting, setSubmitting] = React.useState(false)
|
||||
const [error, setError] = React.useState("")
|
||||
const { t, i18n } = useTranslation()
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
const [password, setPassword] = React.useState("")
|
||||
const [confirm, setConfirm] = React.useState("")
|
||||
const [submitting, setSubmitting] = React.useState(false)
|
||||
const [error, setError] = React.useState("")
|
||||
|
||||
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
setError("")
|
||||
if (password !== confirm) {
|
||||
setError(t("launcherSetup.errorMismatch"))
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const result = await postLauncherDashboardSetup(password, confirm)
|
||||
if (result.ok) {
|
||||
globalThis.location.assign("/launcher-login")
|
||||
return
|
||||
}
|
||||
setError(result.error)
|
||||
} catch {
|
||||
setError(t("launcherSetup.errorNetwork"))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
setError("")
|
||||
if (password !== confirm) {
|
||||
setError(t("launcherSetup.errorMismatch"))
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const result = await postLauncherDashboardSetup(password, confirm)
|
||||
if (result.ok) {
|
||||
globalThis.location.assign("/launcher-login")
|
||||
return
|
||||
}
|
||||
setError(result.error)
|
||||
} catch {
|
||||
setError(t("launcherSetup.errorNetwork"))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background text-foreground flex min-h-dvh flex-col">
|
||||
<header className="border-border/50 flex h-14 shrink-0 items-center justify-end gap-2 border-b px-4">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon" aria-label="Language">
|
||||
<IconLanguage className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => i18n.changeLanguage("en")}>
|
||||
English
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => i18n.changeLanguage("zh")}>
|
||||
简体中文
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => toggleTheme()}
|
||||
aria-label={theme === "dark" ? "Light mode" : "Dark mode"}
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<IconSun className="size-4" />
|
||||
) : (
|
||||
<IconMoon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</header>
|
||||
return (
|
||||
<div className="bg-background text-foreground flex min-h-dvh flex-col">
|
||||
<header className="border-border/50 flex h-14 shrink-0 items-center justify-end gap-2 border-b px-4">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon" aria-label="Language">
|
||||
<IconLanguage className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => i18n.changeLanguage("en")}>
|
||||
English
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => i18n.changeLanguage("zh")}>
|
||||
简体中文
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => toggleTheme()}
|
||||
aria-label={theme === "dark" ? "Light mode" : "Dark mode"}
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<IconSun className="size-4" />
|
||||
) : (
|
||||
<IconMoon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-1 items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md" size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("launcherSetup.title")}</CardTitle>
|
||||
<CardDescription>{t("launcherSetup.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="setup-password">
|
||||
{t("launcherSetup.passwordLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="setup-password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={8}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t("launcherSetup.passwordPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="setup-confirm">
|
||||
{t("launcherSetup.confirmLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="setup-confirm"
|
||||
name="confirm"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={8}
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
placeholder={t("launcherSetup.confirmPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? t("labels.loading") : t("launcherSetup.submit")}
|
||||
</Button>
|
||||
{error ? (
|
||||
<p className="text-destructive text-sm" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
<div className="flex flex-1 items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md" size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("launcherSetup.title")}</CardTitle>
|
||||
<CardDescription>{t("launcherSetup.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="setup-password">
|
||||
{t("launcherSetup.passwordLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="setup-password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={8}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t("launcherSetup.passwordPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="setup-confirm">
|
||||
{t("launcherSetup.confirmLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="setup-confirm"
|
||||
name="confirm"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={8}
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
placeholder={t("launcherSetup.confirmPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? t("labels.loading") : t("launcherSetup.submit")}
|
||||
</Button>
|
||||
{error ? (
|
||||
<p className="text-destructive text-sm" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/launcher-setup")({
|
||||
component: LauncherSetupPage,
|
||||
component: LauncherSetupPage,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user