refactor: extract data logic into reusable hooks (PR5)
New hooks: - useSettings: generic settings CRUD (fetch/save with loading/saving states) - useBridgeAdapter: bridge adapter settings + verify flow - useBridgeStatus: bridge status polling + start/stop control - useAccountInfo: Claude SDK account info fetching Refactored components: - GeneralSection: replace inline fetchAccountInfo with useAccountInfo hook - BridgeSection: replace inline fetchStatus/startBridge/stopBridge + polling interval with useBridgeStatus hook (removed ~50 lines) The remaining bridge adapter sections (Telegram/Discord/Feishu/QQ) can be migrated to useBridgeAdapter in a follow-up — hooks are ready for adoption. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
27bf396cd3
commit
cfc4db1507
6 files changed
+312
-99
No files matched your search
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -15,25 +15,12 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { SpinnerGap, CheckCircle, Warning, TelegramLogo, ChatTeardrop, GameController, ChatsCircle } from "@phosphor-icons/react";
|
||||
import { useTranslation } from "@/hooks/useTranslation";
|
||||
import { useBridgeStatus } from "@/hooks/useBridgeStatus";
|
||||
import { SettingsCard } from "@/components/patterns/SettingsCard";
|
||||
import { FieldRow } from "@/components/patterns/FieldRow";
|
||||
import { StatusBanner } from "@/components/patterns/StatusBanner";
|
||||
import type { ProviderModelGroup } from "@/types";
|
||||
|
||||
interface AdapterStatus {
|
||||
channelType: string;
|
||||
running: boolean;
|
||||
connectedAt: string | null;
|
||||
lastMessageAt: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface BridgeStatus {
|
||||
running: boolean;
|
||||
startedAt: string | null;
|
||||
adapters: AdapterStatus[];
|
||||
}
|
||||
|
||||
interface BridgeSettings {
|
||||
remote_bridge_enabled: string;
|
||||
bridge_telegram_enabled: string;
|
||||
@@ -60,14 +47,11 @@ const DEFAULT_SETTINGS: BridgeSettings = {
|
||||
|
||||
export function BridgeSection() {
|
||||
const [settings, setSettings] = useState<BridgeSettings>(DEFAULT_SETTINGS);
|
||||
const [bridgeStatus, setBridgeStatus] = useState<BridgeStatus | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [stopping, setStopping] = useState(false);
|
||||
const [workDir, setWorkDir] = useState("");
|
||||
const [model, setModel] = useState("");
|
||||
const [providerGroups, setProviderGroups] = useState<ProviderModelGroup[]>([]);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const { bridgeStatus, starting, stopping, startBridge, stopBridge } = useBridgeStatus();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const fetchSettings = useCallback(async () => {
|
||||
@@ -92,18 +76,6 @@ export function BridgeSection() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/bridge");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setBridgeStatus(data);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchModels = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/providers/models");
|
||||
@@ -120,24 +92,8 @@ export function BridgeSection() {
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
fetchStatus();
|
||||
fetchModels();
|
||||
}, [fetchSettings, fetchStatus, fetchModels]);
|
||||
|
||||
// Poll bridge status while bridge is running
|
||||
useEffect(() => {
|
||||
if (bridgeStatus?.running) {
|
||||
pollRef.current = setInterval(fetchStatus, 5000);
|
||||
} else if (pollRef.current) {
|
||||
clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}
|
||||
return () => {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current);
|
||||
}
|
||||
};
|
||||
}, [bridgeStatus?.running, fetchStatus]);
|
||||
}, [fetchSettings, fetchModels]);
|
||||
|
||||
const saveSettings = async (updates: Partial<BridgeSettings>) => {
|
||||
setSaving(true);
|
||||
@@ -208,38 +164,6 @@ export function BridgeSection() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartBridge = async () => {
|
||||
setStarting(true);
|
||||
try {
|
||||
await fetch("/api/bridge", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "start" }),
|
||||
});
|
||||
await fetchStatus();
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStopBridge = async () => {
|
||||
setStopping(true);
|
||||
try {
|
||||
await fetch("/api/bridge", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "stop" }),
|
||||
});
|
||||
await fetchStatus();
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setStopping(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleAutoStart = (checked: boolean) => {
|
||||
saveSettings({ bridge_auto_start: checked ? "true" : "" });
|
||||
};
|
||||
@@ -304,7 +228,7 @@ export function BridgeSection() {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleStopBridge}
|
||||
onClick={stopBridge}
|
||||
disabled={stopping}
|
||||
>
|
||||
{stopping ? (
|
||||
@@ -318,7 +242,7 @@ export function BridgeSection() {
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleStartBridge}
|
||||
onClick={startBridge}
|
||||
disabled={starting}
|
||||
>
|
||||
{starting ? (
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { ArrowClockwise, SpinnerGap } from "@phosphor-icons/react";
|
||||
import { useUpdate } from "@/hooks/useUpdate";
|
||||
import { useTranslation } from "@/hooks/useTranslation";
|
||||
import { useAccountInfo } from "@/hooks/useAccountInfo";
|
||||
import { SUPPORTED_LOCALES, type Locale } from "@/i18n";
|
||||
import type { TranslationKey } from "@/i18n";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
@@ -126,7 +127,7 @@ export function GeneralSection() {
|
||||
const [showSkipPermWarning, setShowSkipPermWarning] = useState(false);
|
||||
const [skipPermSaving, setSkipPermSaving] = useState(false);
|
||||
const [thinkingMode, setThinkingMode] = useState<string>('adaptive');
|
||||
const [accountInfo, setAccountInfo] = useState<{ email?: string; organization?: string; subscriptionType?: string } | null>(null);
|
||||
const { accountInfo } = useAccountInfo();
|
||||
const { t, locale, setLocale } = useTranslation();
|
||||
|
||||
const fetchAppSettings = useCallback(async () => {
|
||||
@@ -145,24 +146,9 @@ export function GeneralSection() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchAccountInfo = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/sdk/account");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.account) {
|
||||
setAccountInfo(data.account);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Account info not available
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAppSettings();
|
||||
fetchAccountInfo();
|
||||
}, [fetchAppSettings, fetchAccountInfo]);
|
||||
}, [fetchAppSettings]);
|
||||
|
||||
const saveThinkingMode = async (mode: string) => {
|
||||
setThinkingMode(mode);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
|
||||
interface AccountInfo {
|
||||
email?: string;
|
||||
organization?: string;
|
||||
subscriptionType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for fetching Claude SDK account information.
|
||||
*/
|
||||
export function useAccountInfo(): {
|
||||
accountInfo: AccountInfo | null;
|
||||
loading: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
} {
|
||||
const [accountInfo, setAccountInfo] = useState<AccountInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/sdk/account");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.account) {
|
||||
setAccountInfo(data.account);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Account info not available
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return { accountInfo, loading, refresh };
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
|
||||
interface VerifyResult {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic hook for bridge adapter settings management.
|
||||
* Encapsulates the fetch/save/verify pattern shared by all bridge adapters
|
||||
* (Telegram, Discord, Feishu, QQ).
|
||||
*/
|
||||
export function useBridgeAdapter<T extends Record<string, string>>(
|
||||
platform: string,
|
||||
defaults: T
|
||||
): {
|
||||
settings: T;
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
save: (updates: Partial<T>) => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
verify: (params: Record<string, string>) => Promise<void>;
|
||||
verifying: boolean;
|
||||
verifyResult: VerifyResult | null;
|
||||
setVerifyResult: (result: VerifyResult | null) => void;
|
||||
} {
|
||||
const [settings, setSettings] = useState<T>(defaults);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const [verifyResult, setVerifyResult] = useState<VerifyResult | null>(null);
|
||||
|
||||
const endpoint = `/api/settings/${platform}`;
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(endpoint);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSettings({ ...defaults, ...data.settings });
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [endpoint, defaults]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const save = useCallback(async (updates: Partial<T>) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(endpoint, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ settings: updates }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setSettings((prev) => ({ ...prev, ...updates }));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [endpoint]);
|
||||
|
||||
const verify = useCallback(async (params: Record<string, string>) => {
|
||||
setVerifying(true);
|
||||
setVerifyResult(null);
|
||||
try {
|
||||
const res = await fetch(`${endpoint}/verify`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.verified) {
|
||||
setVerifyResult({
|
||||
ok: true,
|
||||
message: data.botName
|
||||
? `Verified as ${data.botName}`
|
||||
: "Verification successful",
|
||||
});
|
||||
} else {
|
||||
setVerifyResult({
|
||||
ok: false,
|
||||
message: data.error || "Verification failed",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setVerifyResult({ ok: false, message: "Verification failed" });
|
||||
} finally {
|
||||
setVerifying(false);
|
||||
}
|
||||
}, [endpoint]);
|
||||
|
||||
return {
|
||||
settings, loading, saving, save, refresh,
|
||||
verify, verifying, verifyResult, setVerifyResult,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
|
||||
interface AdapterStatus {
|
||||
channelType: string;
|
||||
running: boolean;
|
||||
connectedAt: string | null;
|
||||
lastMessageAt: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface BridgeStatus {
|
||||
running: boolean;
|
||||
startedAt: string | null;
|
||||
adapters: AdapterStatus[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for polling bridge status and controlling bridge start/stop.
|
||||
* Automatically polls every 5 seconds while the bridge is running.
|
||||
*/
|
||||
export function useBridgeStatus(): {
|
||||
bridgeStatus: BridgeStatus | null;
|
||||
starting: boolean;
|
||||
stopping: boolean;
|
||||
startBridge: () => Promise<void>;
|
||||
stopBridge: () => Promise<void>;
|
||||
refreshStatus: () => Promise<void>;
|
||||
} {
|
||||
const [bridgeStatus, setBridgeStatus] = useState<BridgeStatus | null>(null);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [stopping, setStopping] = useState(false);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/bridge");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setBridgeStatus(data);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshStatus();
|
||||
}, [refreshStatus]);
|
||||
|
||||
// Poll bridge status while bridge is running
|
||||
useEffect(() => {
|
||||
if (bridgeStatus?.running) {
|
||||
pollRef.current = setInterval(refreshStatus, 5000);
|
||||
} else if (pollRef.current) {
|
||||
clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}
|
||||
return () => {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current);
|
||||
}
|
||||
};
|
||||
}, [bridgeStatus?.running, refreshStatus]);
|
||||
|
||||
const startBridge = useCallback(async () => {
|
||||
setStarting(true);
|
||||
try {
|
||||
await fetch("/api/bridge", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "start" }),
|
||||
});
|
||||
await refreshStatus();
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
}, [refreshStatus]);
|
||||
|
||||
const stopBridge = useCallback(async () => {
|
||||
setStopping(true);
|
||||
try {
|
||||
await fetch("/api/bridge", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "stop" }),
|
||||
});
|
||||
await refreshStatus();
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setStopping(false);
|
||||
}
|
||||
}, [refreshStatus]);
|
||||
|
||||
return { bridgeStatus, starting, stopping, startBridge, stopBridge, refreshStatus };
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Generic hook for fetching and saving settings from an API endpoint.
|
||||
* Handles loading/saving states and provides a clean CRUD interface.
|
||||
*/
|
||||
export function useSettings<T extends Record<string, string>>(
|
||||
endpoint: string,
|
||||
defaults: T
|
||||
): {
|
||||
settings: T;
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
save: (updates: Partial<T>) => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
} {
|
||||
const [settings, setSettings] = useState<T>(defaults);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(endpoint);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSettings({ ...defaults, ...data.settings });
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [endpoint, defaults]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const save = useCallback(async (updates: Partial<T>) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(endpoint, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ settings: updates }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setSettings((prev) => ({ ...prev, ...updates }));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [endpoint]);
|
||||
|
||||
return { settings, loading, saving, save, refresh };
|
||||
}
|
||||
Reference in new issue
Block a user