feat(dashboard): clone profiles from any source

This commit is contained in:
WompaJango 2026-06-13 06:37:06 -07:00 committed by Teknium
parent 3380563d94
commit 28bf8fb47d
31 changed files with 182 additions and 105 deletions

View file

@ -284,6 +284,7 @@ export function ProfileRail() {
selectProfile(name)
}}
open={createOpen}
profiles={profiles}
/>
<RenameProfileDialog

View file

@ -2,14 +2,15 @@ import { useEffect, useState } from 'react'
import { ActionStatus } from '@/components/ui/action-status'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { createProfile, updateProfileSoul } from '@/hermes'
import { useI18n } from '@/i18n'
import { AlertTriangle } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type { ProfileInfo } from '@/types/hermes'
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
@ -23,16 +24,18 @@ export function isValidProfileName(name: string): boolean {
export function CreateProfileDialog({
onClose,
onCreated,
open
open,
profiles = []
}: {
onClose: () => void
onCreated?: (name: string) => Promise<void> | void
open: boolean
profiles?: ProfileInfo[]
}) {
const { t } = useI18n()
const p = t.profiles
const [name, setName] = useState('')
const [cloneFromDefault, setCloneFromDefault] = useState(true)
const [cloneFrom, setCloneFrom] = useState<null | string>('default')
const [soul, setSoul] = useState('')
const [status, setStatus] = useState<'done' | 'idle' | 'saving'>('idle')
const [error, setError] = useState<null | string>(null)
@ -43,7 +46,7 @@ export function CreateProfileDialog({
}
setName('')
setCloneFromDefault(true)
setCloneFrom('default')
setSoul('')
setError(null)
setStatus('idle')
@ -66,7 +69,7 @@ export function CreateProfileDialog({
setError(null)
try {
await createProfile({ name: trimmed, clone_from_default: cloneFromDefault })
await createProfile({ name: trimmed, clone_from: cloneFrom })
if (soul.trim()) {
await updateProfileSoul(trimmed, soul)
@ -107,17 +110,25 @@ export function CreateProfileDialog({
</p>
</div>
<label className="flex cursor-pointer select-none items-start gap-2.5 px-0.5 py-1">
<Checkbox
checked={cloneFromDefault}
className="mt-0.5 shrink-0"
onCheckedChange={checked => setCloneFromDefault(checked === true)}
/>
<span className="grid gap-0.5 leading-snug">
<span className="text-sm font-medium">{p.cloneFromDefault}</span>
<span className="text-xs text-muted-foreground">{p.cloneFromDefaultDesc}</span>
</span>
</label>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="new-profile-clone-from">
{p.cloneFrom}
</label>
<Select onValueChange={value => setCloneFrom(value === '__none__' ? null : value)} value={cloneFrom ?? '__none__'}>
<SelectTrigger className="h-9 rounded-md" id="new-profile-clone-from">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">{p.cloneFromNone}</SelectItem>
{profiles.map(profile => (
<SelectItem key={profile.name} value={profile.name}>
{profile.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{p.cloneFromDesc}</p>
</div>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="new-profile-soul">
@ -127,7 +138,7 @@ export function CreateProfileDialog({
className="min-h-28 font-mono text-xs leading-5"
id="new-profile-soul"
onChange={event => setSoul(event.target.value)}
placeholder={p.soulPlaceholder(cloneFromDefault ? p.soulPlaceholderCloned : p.soulPlaceholderEmpty)}
placeholder={p.soulPlaceholder(cloneFrom ? p.soulPlaceholderCloned : p.soulPlaceholderEmpty)}
value={soul}
/>
</div>

View file

@ -12,6 +12,7 @@ import {
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import {
createProfile,
@ -82,14 +83,14 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
}, [profiles, selectedName])
const handleCreate = useCallback(
async (name: string, cloneFromDefault: boolean) => {
async (name: string, cloneFrom: null | string) => {
const trimmed = name.trim()
if (!isValidProfileName(trimmed)) {
throw new Error(p.nameHint)
}
await createProfile({ name: trimmed, clone_from_default: cloneFromDefault })
await createProfile({ name: trimmed, clone_from: cloneFrom })
notify({ kind: 'success', title: p.created, message: trimmed })
setSelectedName(trimmed)
await refresh()
@ -180,8 +181,9 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
<CreateProfileDialog
onClose={() => setCreateOpen(false)}
onCreate={async (name, cloneFromDefault) => handleCreate(name, cloneFromDefault)}
onCreate={async (name, cloneFrom) => handleCreate(name, cloneFrom)}
open={createOpen}
profiles={profiles ?? []}
/>
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
@ -453,16 +455,18 @@ function SoulEditor({ profileName }: { profileName: string }) {
function CreateProfileDialog({
onClose,
onCreate,
open
open,
profiles
}: {
onClose: () => void
onCreate: (name: string, cloneFromDefault: boolean) => Promise<void>
onCreate: (name: string, cloneFrom: null | string) => Promise<void>
open: boolean
profiles: ProfileInfo[]
}) {
const { t } = useI18n()
const p = t.profiles
const [name, setName] = useState('')
const [cloneFromDefault, setCloneFromDefault] = useState(true)
const [cloneFrom, setCloneFrom] = useState<null | string>('default')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<null | string>(null)
@ -472,7 +476,7 @@ function CreateProfileDialog({
}
setName('')
setCloneFromDefault(true)
setCloneFrom('default')
setError(null)
setSaving(false)
}, [open])
@ -493,7 +497,7 @@ function CreateProfileDialog({
setError(null)
try {
await onCreate(trimmed, cloneFromDefault)
await onCreate(trimmed, cloneFrom)
onClose()
} catch (err) {
setError(err instanceof Error ? err.message : p.failedCreate)
@ -528,18 +532,25 @@ function CreateProfileDialog({
</p>
</div>
<label className="flex cursor-pointer items-center gap-2 rounded-md border border-border/40 bg-background/50 px-3 py-2 text-sm">
<input
checked={cloneFromDefault}
className="size-4 accent-primary"
onChange={event => setCloneFromDefault(event.target.checked)}
type="checkbox"
/>
<span>
<span className="font-medium">{p.cloneFromDefault}</span>
<span className="ml-2 text-xs text-muted-foreground">{p.cloneFromDefaultDesc}</span>
</span>
</label>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="new-profile-clone-from">
{p.cloneFrom}
</label>
<Select onValueChange={value => setCloneFrom(value === '__none__' ? null : value)} value={cloneFrom ?? '__none__'}>
<SelectTrigger className="h-9 rounded-md" id="new-profile-clone-from">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">{p.cloneFromNone}</SelectItem>
{profiles.map(profile => (
<SelectItem key={profile.name} value={profile.name}>
{profile.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{p.cloneFromDesc}</p>
</div>
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">

View file

@ -903,6 +903,9 @@ export const en: Translations = {
deleting: 'Deleting...',
createDesc: 'Profiles are independent Hermes environments: separate config, skills, and SOUL.md.',
nameLabel: 'Name',
cloneFrom: 'Clone from',
cloneFromNone: 'None (blank)',
cloneFromDesc: 'Copies config, skills, and SOUL.md from the selected source profile.',
cloneFromDefault: 'Clone from default',
cloneFromDefaultDesc: 'Copy config, skills, and SOUL.md from your default profile.',
invalidName: hint => `Invalid name. ${hint}`,

View file

@ -1041,6 +1041,9 @@ export const ja = defineLocale({
deleting: '削除中...',
createDesc: 'プロファイルは独立した Hermes 環境です設定、スキル、SOUL.md が別々になります。',
nameLabel: '名前',
cloneFrom: '複製元',
cloneFromNone: 'なし(空)',
cloneFromDesc: '選択したプロファイルから設定、スキル、SOUL.md をコピーします。',
cloneFromDefault: 'デフォルトプロファイルから設定を複製',
cloneFromDefaultDesc: 'デフォルトプロファイルから設定、スキル、SOUL.md をコピーします。',
invalidName: hint => `無効なプロファイル名。${hint}`,

View file

@ -695,6 +695,9 @@ export interface Translations {
deleting: string
createDesc: string
nameLabel: string
cloneFrom: string
cloneFromNone: string
cloneFromDesc: string
cloneFromDefault: string
cloneFromDefaultDesc: string
invalidName: (hint: string) => string

View file

@ -999,6 +999,9 @@ export const zhHant = defineLocale({
deleting: '刪除中…',
createDesc: '設定檔是獨立的 Hermes 環境:各自擁有獨立的設定、技能和 SOUL.md。',
nameLabel: '名稱',
cloneFrom: '複製來源',
cloneFromNone: '無(空白)',
cloneFromDesc: '從選取的來源設定檔複製設定、技能和 SOUL.md。',
cloneFromDefault: '從預設設定檔複製設定',
cloneFromDefaultDesc: '從您的預設設定檔複製設定、技能和 SOUL.md。',
invalidName: hint => `設定檔名稱無效。${hint}`,

View file

@ -1092,6 +1092,9 @@ export const zh: Translations = {
deleting: '删除中…',
createDesc: '配置档案是相互独立的 Hermes 环境:各自拥有独立的配置、技能和 SOUL.md。',
nameLabel: '名称',
cloneFrom: '克隆来源',
cloneFromNone: '无(空白)',
cloneFromDesc: '从选中的来源配置档案复制配置、技能和 SOUL.md。',
cloneFromDefault: '从默认档案克隆',
cloneFromDefaultDesc: '从你的默认配置档案复制配置、技能和 SOUL.md。',
invalidName: hint => `名称无效。${hint}`,

View file

@ -470,7 +470,7 @@ export interface CronJobUpdates {
export interface ProfileCreatePayload {
clone_all?: boolean
clone_from?: string
clone_from?: null | string
clone_from_default?: boolean
name: string
no_skills?: boolean