Initial commit

This commit is contained in:
xggz
2026-03-06 22:56:13 +08:00
commit 54d1097b41
273 changed files with 92457 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,80 @@
"use client"
import { Monitor, Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
type ThemeMode = "system" | "light" | "dark"
export function AppearanceSettings() {
const { theme, resolvedTheme, setTheme } = useTheme()
const resolvedThemeLabel =
resolvedTheme === "dark"
? "深色"
: resolvedTheme === "light"
? "浅色"
: "--"
return (
<div className="h-full overflow-auto">
<div className="w-full space-y-4">
<section className="rounded-xl border bg-card p-4 space-y-4">
<div className="flex items-center gap-2">
<Sun className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold"></h2>
</div>
<p className="text-xs text-muted-foreground leading-5">
</p>
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
</label>
<Select
value={theme ?? "system"}
onValueChange={(value) => setTheme(value as ThemeMode)}
>
<SelectTrigger className="w-56">
<SelectValue placeholder="请选择主题模式" />
</SelectTrigger>
<SelectContent align="start">
<SelectItem value="system">
<span className="inline-flex items-center gap-2">
<Monitor className="h-3.5 w-3.5" />
</span>
</SelectItem>
<SelectItem value="light">
<span className="inline-flex items-center gap-2">
<Sun className="h-3.5 w-3.5" />
</span>
</SelectItem>
<SelectItem value="dark">
<span className="inline-flex items-center gap-2">
<Moon className="h-3.5 w-3.5" />
</span>
</SelectItem>
</SelectContent>
</Select>
<p
className="text-[11px] text-muted-foreground"
suppressHydrationWarning
>
{resolvedThemeLabel}
</p>
</div>
</section>
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,144 @@
"use client"
import { useCallback, type ComponentType, type ReactNode } from "react"
import {
Bot,
BookOpenText,
Keyboard,
Palette,
PlugZap,
Settings,
} from "lucide-react"
import { usePathname } from "next/navigation"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { AppToaster } from "@/components/ui/app-toaster"
import { cn } from "@/lib/utils"
import { AppTitleBar } from "@/components/layout/app-title-bar"
interface SettingsNavItem {
href: string
label: string
icon: ComponentType<{ className?: string }>
}
const SETTINGS_NAV_ITEMS: SettingsNavItem[] = [
{
href: "/settings/appearance",
label: "Appearance",
icon: Palette,
},
{
href: "/settings/agents",
label: "Agents",
icon: Bot,
},
{
href: "/settings/mcp",
label: "MCP",
icon: PlugZap,
},
{
href: "/settings/skills",
label: "Skills",
icon: BookOpenText,
},
{
href: "/settings/shortcuts",
label: "Shortcuts",
icon: Keyboard,
},
{
href: "/settings/system",
label: "System",
icon: Settings,
},
]
interface SettingsShellProps {
children: ReactNode
}
function normalizePath(path: string): string {
const noSuffix = path.replace(/\/index\.html$/, "").replace(/\.html$/, "")
const noTrailingSlash = noSuffix.replace(/\/+$/, "")
return noTrailingSlash || "/"
}
function isWindowsRuntime(): boolean {
if (typeof navigator === "undefined") return false
const platform = navigator.platform.toLowerCase()
const userAgent = navigator.userAgent.toLowerCase()
return platform.includes("win") || userAgent.includes("windows")
}
export function SettingsShell({ children }: SettingsShellProps) {
const pathname = usePathname()
const router = useRouter()
const normalizedPathname = normalizePath(pathname)
const navigateTo = useCallback(
(href: string) => {
if (typeof window === "undefined") return
const target = normalizePath(href)
const current = normalizePath(window.location.pathname)
if (current === target) return
if (isWindowsRuntime()) {
// WebView2 on Windows: hard navigation is more reliable than client routing.
window.location.assign(target)
return
}
// macOS/Linux: keep client-side routing for snappier transitions.
router.push(target)
},
[router]
)
return (
<div className="h-screen flex flex-col overflow-hidden bg-background text-foreground">
<AppTitleBar
center={
<div className="text-sm font-bold tracking-tight">Settings</div>
}
/>
<div className="flex-1 min-h-0 flex">
<aside className="w-56 shrink-0 border-r p-3">
<div className="px-1 pb-2 text-[11px] font-medium text-muted-foreground">
Preferences
</div>
<nav className="space-y-1">
{SETTINGS_NAV_ITEMS.map((item) => {
const Icon = item.icon
const active =
normalizedPathname === item.href ||
normalizedPathname.startsWith(`${item.href}/`)
return (
<Button
key={item.href}
variant={active ? "secondary" : "ghost"}
size="sm"
className={cn("w-full justify-start")}
type="button"
onClick={() => navigateTo(item.href)}
aria-current={active ? "page" : undefined}
>
<span className="inline-flex items-center gap-1">
<Icon className="h-3.5 w-3.5" />
{item.label}
</span>
</Button>
)
})}
</nav>
</aside>
<section className="flex-1 min-w-0 min-h-0 p-4">{children}</section>
</div>
<AppToaster position="bottom-right" closeButton duration={4000} />
</div>
)
}

View File

@@ -0,0 +1,156 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { Keyboard, RotateCcw } from "lucide-react"
import { toast } from "sonner"
import { useIsMac } from "@/hooks/use-is-mac"
import { useShortcutSettings } from "@/hooks/use-shortcut-settings"
import {
DEFAULT_SHORTCUTS,
SHORTCUT_DEFINITIONS,
type ShortcutActionId,
formatShortcutLabel,
shortcutFromKeyboardEvent,
} from "@/lib/keyboard-shortcuts"
import { Button } from "@/components/ui/button"
const SHARED_SHORTCUT_PAIRS: Array<[ShortcutActionId, ShortcutActionId]> = [
["new_terminal_tab", "new_conversation"],
["close_current_terminal_tab", "close_current_tab"],
]
function canShareShortcut(a: ShortcutActionId, b: ShortcutActionId): boolean {
return SHARED_SHORTCUT_PAIRS.some(
([left, right]) =>
(left === a && right === b) || (left === b && right === a)
)
}
export function ShortcutSettings() {
const { shortcuts, updateShortcut, resetShortcuts } = useShortcutSettings()
const isMac = useIsMac()
const [recordingAction, setRecordingAction] =
useState<ShortcutActionId | null>(null)
const isDefault = useMemo(
() =>
SHORTCUT_DEFINITIONS.every(
(definition) =>
shortcuts[definition.id] === DEFAULT_SHORTCUTS[definition.id]
),
[shortcuts]
)
useEffect(() => {
if (!recordingAction) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.repeat) return
event.preventDefault()
event.stopPropagation()
if (event.key === "Escape") {
setRecordingAction(null)
return
}
const shortcut = shortcutFromKeyboardEvent(event)
if (!shortcut) return
const conflict = SHORTCUT_DEFINITIONS.find(
(definition) =>
definition.id !== recordingAction &&
!canShareShortcut(definition.id, recordingAction) &&
shortcuts[definition.id] === shortcut
)
if (conflict) {
toast.error(`快捷键已被「${conflict.title}」占用`)
return
}
if (updateShortcut(recordingAction, shortcut)) {
toast.success("快捷键已更新")
} else {
toast.error("快捷键无效,请重试")
}
setRecordingAction(null)
}
window.addEventListener("keydown", onKeyDown, true)
return () => {
window.removeEventListener("keydown", onKeyDown, true)
}
}, [recordingAction, shortcuts, updateShortcut])
return (
<div className="h-full overflow-auto">
<div className="w-full space-y-4">
<section className="rounded-xl border bg-card p-4 space-y-4">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Keyboard className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold"></h2>
</div>
<Button
variant="outline"
size="sm"
onClick={() => {
resetShortcuts()
setRecordingAction(null)
toast.success("已恢复默认快捷键")
}}
disabled={isDefault}
>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
</div>
<p className="text-xs text-muted-foreground leading-5">
使 Ctrl/CmdAltShift
Esc
</p>
<div className="space-y-2">
{SHORTCUT_DEFINITIONS.map((definition) => {
const isRecording = recordingAction === definition.id
return (
<div
key={definition.id}
className="rounded-lg border px-3 py-2 flex items-center justify-between gap-4"
>
<div className="min-w-0">
<div className="text-sm font-medium">
{definition.title}
</div>
<p className="text-xs text-muted-foreground truncate">
{definition.description}
</p>
</div>
<Button
variant={isRecording ? "default" : "secondary"}
size="sm"
className="font-mono min-w-36 justify-center"
onClick={() => {
setRecordingAction((previous) =>
previous === definition.id ? null : definition.id
)
}}
>
{isRecording
? "按下快捷键..."
: formatShortcutLabel(shortcuts[definition.id], isMac)}
</Button>
</div>
)
})}
</div>
</section>
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,306 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { ArrowUpCircle, Loader2, RefreshCw, Save, Wifi } from "lucide-react"
import type { Update } from "@tauri-apps/plugin-updater"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { getSystemProxySettings, updateSystemProxySettings } from "@/lib/tauri"
import {
checkAppUpdate,
closeAppUpdate,
getCurrentAppVersion,
installAppUpdate,
relaunchApp,
} from "@/lib/updater"
const PROXY_EXAMPLE = "http://127.0.0.1:7890"
export function SystemNetworkSettings() {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [enabled, setEnabled] = useState(false)
const [proxyUrl, setProxyUrl] = useState("")
const [loadError, setLoadError] = useState<string | null>(null)
const [currentVersion, setCurrentVersion] = useState<string>("")
const [availableUpdate, setAvailableUpdate] = useState<Update | null>(null)
const [checkingUpdate, setCheckingUpdate] = useState(false)
const [installingUpdate, setInstallingUpdate] = useState(false)
const [updateError, setUpdateError] = useState<string | null>(null)
const [lastCheckedAt, setLastCheckedAt] = useState<Date | null>(null)
const loadSettings = useCallback(async () => {
setLoading(true)
setLoadError(null)
try {
const settings = await getSystemProxySettings()
setEnabled(settings.enabled)
setProxyUrl(settings.proxy_url ?? "")
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setLoadError(message)
} finally {
setLoading(false)
}
}, [])
const loadAppVersion = useCallback(async () => {
try {
const version = await getCurrentAppVersion()
setCurrentVersion(version)
} catch (err) {
console.error("[Settings] load app version failed:", err)
}
}, [])
useEffect(() => {
loadSettings().catch((err) => {
console.error("[Settings] load system proxy settings failed:", err)
})
loadAppVersion().catch((err) => {
console.error("[Settings] load app version failed:", err)
})
}, [loadSettings, loadAppVersion])
useEffect(() => {
return () => {
if (!availableUpdate) return
closeAppUpdate(availableUpdate).catch((err) => {
console.error("[Settings] release updater resource failed:", err)
})
}
}, [availableUpdate])
const saveSettings = useCallback(async () => {
if (enabled && !proxyUrl.trim()) {
toast.error("启用代理时必须填写代理地址")
return
}
setSaving(true)
try {
const next = await updateSystemProxySettings({
enabled,
proxy_url: proxyUrl.trim() || null,
})
setEnabled(next.enabled)
setProxyUrl(next.proxy_url ?? "")
toast.success("系统代理设置已保存")
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
toast.error(`保存失败:${message}`)
} finally {
setSaving(false)
}
}, [enabled, proxyUrl])
const checkForUpdates = useCallback(async () => {
setCheckingUpdate(true)
setUpdateError(null)
try {
const previousUpdate = availableUpdate
const result = await checkAppUpdate()
setCurrentVersion(result.currentVersion)
setLastCheckedAt(new Date())
if (result.update) {
setAvailableUpdate(result.update)
toast.success(`发现新版本 v${result.update.version}`)
} else {
setAvailableUpdate(null)
toast.success("当前已经是最新版本")
}
if (previousUpdate && previousUpdate !== result.update) {
await closeAppUpdate(previousUpdate)
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setUpdateError(message)
toast.error(`检查更新失败:${message}`)
} finally {
setCheckingUpdate(false)
}
}, [availableUpdate])
const installUpdate = useCallback(async () => {
if (!availableUpdate) return
setInstallingUpdate(true)
setUpdateError(null)
try {
await installAppUpdate(availableUpdate)
toast.success("升级包已安装,正在重启应用")
await relaunchApp()
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setUpdateError(message)
toast.error(`升级失败:${message}`)
} finally {
setInstallingUpdate(false)
}
}, [availableUpdate])
if (loading) {
return (
<div className="h-full flex items-center justify-center text-sm text-muted-foreground gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
...
</div>
)
}
return (
<div className="h-full overflow-auto">
<div className="w-full space-y-4">
<section className="space-y-1">
<h1 className="text-sm font-semibold"></h1>
<p className="text-xs text-muted-foreground">
</p>
</section>
<section className="rounded-xl border bg-card p-4 space-y-4">
<div className="flex items-center gap-2">
<Wifi className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold"></h2>
</div>
<p className="text-xs text-muted-foreground leading-5">
ACP Agent Git
</p>
{loadError && (
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-400">
{loadError}
</div>
)}
<label className="inline-flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={enabled}
onChange={(event) => setEnabled(event.target.checked)}
/>
</label>
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
</label>
<Input
value={proxyUrl}
onChange={(event) => setProxyUrl(event.target.value)}
placeholder={PROXY_EXAMPLE}
/>
<p className="text-[11px] text-muted-foreground">
http(s)/socks5{PROXY_EXAMPLE}
</p>
</div>
<div className="flex justify-end">
<Button size="sm" onClick={saveSettings} disabled={saving}>
{saving ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
...
</>
) : (
<>
<Save className="h-3.5 w-3.5" />
</>
)}
</Button>
</div>
</section>
<section className="rounded-xl border bg-card p-4 space-y-4">
<div className="flex items-center gap-2">
<RefreshCw className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold"></h2>
</div>
<p className="text-xs text-muted-foreground leading-5">
</p>
<div className="grid grid-cols-1 gap-2 text-xs sm:grid-cols-2">
<div className="rounded-md border bg-muted/20 px-3 py-2">
<div className="text-muted-foreground"></div>
<div className="mt-1 font-medium">
{currentVersion ? `v${currentVersion}` : "-"}
</div>
</div>
<div className="rounded-md border bg-muted/20 px-3 py-2">
<div className="text-muted-foreground"></div>
<div className="mt-1 font-medium">
{availableUpdate ? `v${availableUpdate.version}` : "暂无"}
</div>
</div>
</div>
{lastCheckedAt && (
<p className="text-[11px] text-muted-foreground">
{lastCheckedAt.toLocaleString()}
</p>
)}
{updateError && (
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-400">
{updateError}
</div>
)}
<div className="flex flex-wrap items-center gap-2 justify-end">
<Button
size="sm"
variant="secondary"
onClick={checkForUpdates}
disabled={checkingUpdate || installingUpdate}
>
{checkingUpdate ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
...
</>
) : (
<>
<RefreshCw className="h-3.5 w-3.5" />
</>
)}
</Button>
{availableUpdate && (
<Button
size="sm"
onClick={installUpdate}
disabled={installingUpdate || checkingUpdate}
>
{installingUpdate ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
...
</>
) : (
<>
<ArrowUpCircle className="h-3.5 w-3.5" />
v{availableUpdate.version}
</>
)}
</Button>
)}
</div>
</section>
</div>
</div>
)
}