feat(dashboard): clone profiles from any source
This commit is contained in:
parent
3380563d94
commit
28bf8fb47d
31 changed files with 182 additions and 105 deletions
|
|
@ -284,6 +284,7 @@ export function ProfileRail() {
|
|||
selectProfile(name)
|
||||
}}
|
||||
open={createOpen}
|
||||
profiles={profiles}
|
||||
/>
|
||||
|
||||
<RenameProfileDialog
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue