apolinario commited on
Commit
8b1baa1
·
1 Parent(s): 51089d0

dataset rename and add rules

Browse files
ui/src/app/api/datasets/rename/route.ts ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { getDatasetsRoot } from '@/server/settings';
5
+
6
+ const sanitizeSegment = (value: string): string => {
7
+ if (!value || typeof value !== 'string') {
8
+ return '';
9
+ }
10
+
11
+ return value
12
+ .toLowerCase()
13
+ .replace(/[^a-z0-9]+/g, '_')
14
+ .replace(/^_+|_+$/g, '');
15
+ };
16
+
17
+ export async function POST(request: Request) {
18
+ try {
19
+ const body = await request.json();
20
+ const { currentName, newName } = body ?? {};
21
+
22
+ if (!currentName || typeof currentName !== 'string') {
23
+ return NextResponse.json({ error: 'Current dataset name is required' }, { status: 400 });
24
+ }
25
+
26
+ if (!newName || typeof newName !== 'string') {
27
+ return NextResponse.json({ error: 'New dataset name is required' }, { status: 400 });
28
+ }
29
+
30
+ const datasetsRoot = await getDatasetsRoot();
31
+
32
+ const sanitizedCurrent = sanitizeSegment(currentName);
33
+ const currentPath = path.join(datasetsRoot, sanitizedCurrent);
34
+
35
+ if (!fs.existsSync(currentPath)) {
36
+ return NextResponse.json({ error: `Dataset '${currentName}' not found` }, { status: 404 });
37
+ }
38
+
39
+ const sanitizedNew = sanitizeSegment(newName);
40
+ if (!sanitizedNew) {
41
+ return NextResponse.json({ error: 'New dataset name is invalid' }, { status: 400 });
42
+ }
43
+
44
+ if (sanitizedCurrent === sanitizedNew) {
45
+ return NextResponse.json({ success: true, name: sanitizedCurrent, path: currentPath });
46
+ }
47
+
48
+ const newPath = path.join(datasetsRoot, sanitizedNew);
49
+ if (fs.existsSync(newPath)) {
50
+ return NextResponse.json({ error: `Dataset '${sanitizedNew}' already exists` }, { status: 409 });
51
+ }
52
+
53
+ fs.renameSync(currentPath, newPath);
54
+
55
+ return NextResponse.json({ success: true, name: sanitizedNew, path: newPath });
56
+ } catch (error: any) {
57
+ console.error('Dataset rename error:', error);
58
+ return NextResponse.json({ error: error?.message || 'Failed to rename dataset' }, { status: 500 });
59
+ }
60
+ }
ui/src/app/jobs/new/SimplifiedJob.tsx CHANGED
@@ -12,7 +12,7 @@ import HFJobsWorkflow from '@/components/HFJobsWorkflow';
12
  import { Button } from '@headlessui/react';
13
  import { ChevronDown, ChevronUp, Upload } from 'lucide-react';
14
  import { apiClient } from '@/utils/api';
15
- import { addUserDataset, updateUserDatasetPath } from '@/utils/storage/datasetStorage';
16
  import DatasetPreviewGrid from '@/components/DatasetPreviewGrid';
17
 
18
  type DatasetMode = 'upload' | 'existing';
@@ -54,6 +54,13 @@ const buildDatasetName = (base: string, suffix: string) => {
54
  return `${slug}${suffix}`;
55
  };
56
 
 
 
 
 
 
 
 
57
  type SimplifiedJobProps = {
58
  jobConfig: JobConfig;
59
  setJobConfig: (value: any, key: string) => void;
@@ -155,6 +162,8 @@ export default function SimplifiedJob({
155
  const [controlUploadError, setControlUploadError] = useState<string | null>(null);
156
  const [trainPreviewRefresh, setTrainPreviewRefresh] = useState(0);
157
  const [controlPreviewRefresh, setControlPreviewRefresh] = useState(0);
 
 
158
 
159
  const modelArch = useMemo(() => {
160
  return modelArchs.find(arch => arch.name === process.model.arch);
@@ -382,12 +391,10 @@ export default function SimplifiedJob({
382
  controlDatasetPath,
383
  controlDatasetResolvedName,
384
  ensureDataset,
385
- setControlPreviewRefresh,
386
  setJobConfig,
387
  trainDatasetName,
388
  trainDatasetPath,
389
  trainDatasetResolvedName,
390
- setTrainPreviewRefresh,
391
  ],
392
  );
393
 
@@ -498,7 +505,7 @@ export default function SimplifiedJob({
498
  return;
499
  }
500
  const currentFolder = dataset.folder_path;
501
- if (!currentFolder) {
502
  return;
503
  }
504
  const match = existingOptions.find(option => option.value === currentFolder);
@@ -521,6 +528,116 @@ export default function SimplifiedJob({
521
  }
522
  }, [existingOptions, dataset.control_path, controlDatasetResolvedName]);
523
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
524
  return (
525
  <form onSubmit={handleSubmit} className="space-y-8">
526
  <Card title="Training Setup">
@@ -675,6 +792,7 @@ export default function SimplifiedJob({
675
  setTrainDatasetNameTouched(true);
676
  }}
677
  placeholder="my-training-dataset"
 
678
  />
679
  <div
680
  {...trainDropzone.getRootProps({
@@ -765,15 +883,16 @@ export default function SimplifiedJob({
765
 
766
  {controlDatasetMode === 'upload' ? (
767
  <div className="space-y-4">
768
- <TextInput
769
- label="Control Dataset Name"
770
- value={controlDatasetName}
771
- onChange={value => {
772
- setControlDatasetName(value);
773
- setControlDatasetNameTouched(true);
774
- }}
775
- placeholder="my-control-data"
776
- />
 
777
  <div
778
  {...controlDropzone.getRootProps({
779
  className:
 
12
  import { Button } from '@headlessui/react';
13
  import { ChevronDown, ChevronUp, Upload } from 'lucide-react';
14
  import { apiClient } from '@/utils/api';
15
+ import { addUserDataset, updateUserDatasetPath, renameUserDataset } from '@/utils/storage/datasetStorage';
16
  import DatasetPreviewGrid from '@/components/DatasetPreviewGrid';
17
 
18
  type DatasetMode = 'upload' | 'existing';
 
54
  return `${slug}${suffix}`;
55
  };
56
 
57
+ const sanitizeDatasetName = (value: string) =>
58
+ value
59
+ .toLowerCase()
60
+ .replace(/[^a-z0-9]+/g, '_')
61
+ .replace(/^_+|_+$/g, '')
62
+ .slice(0, 128);
63
+
64
  type SimplifiedJobProps = {
65
  jobConfig: JobConfig;
66
  setJobConfig: (value: any, key: string) => void;
 
162
  const [controlUploadError, setControlUploadError] = useState<string | null>(null);
163
  const [trainPreviewRefresh, setTrainPreviewRefresh] = useState(0);
164
  const [controlPreviewRefresh, setControlPreviewRefresh] = useState(0);
165
+ const [isRenamingTrainDataset, setIsRenamingTrainDataset] = useState(false);
166
+ const [isRenamingControlDataset, setIsRenamingControlDataset] = useState(false);
167
 
168
  const modelArch = useMemo(() => {
169
  return modelArchs.find(arch => arch.name === process.model.arch);
 
391
  controlDatasetPath,
392
  controlDatasetResolvedName,
393
  ensureDataset,
 
394
  setJobConfig,
395
  trainDatasetName,
396
  trainDatasetPath,
397
  trainDatasetResolvedName,
 
398
  ],
399
  );
400
 
 
505
  return;
506
  }
507
  const currentFolder = dataset.folder_path;
508
+ if (!currentFolder || currentFolder === defaultDatasetConfig.folder_path) {
509
  return;
510
  }
511
  const match = existingOptions.find(option => option.value === currentFolder);
 
528
  }
529
  }, [existingOptions, dataset.control_path, controlDatasetResolvedName]);
530
 
531
+ const handleDatasetRename = useCallback(
532
+ async (target: 'train' | 'control') => {
533
+ const isTrain = target === 'train';
534
+ const resolvedName = isTrain ? trainDatasetResolvedName : controlDatasetResolvedName;
535
+ const desiredName = (isTrain ? trainDatasetName : controlDatasetName) || '';
536
+ const datasetPath = isTrain ? trainDatasetPath : controlDatasetPath;
537
+
538
+ if (!resolvedName || !datasetPath) {
539
+ return;
540
+ }
541
+
542
+ const trimmedDesired = desiredName.trim();
543
+ const sanitizedDesired = sanitizeDatasetName(trimmedDesired);
544
+
545
+ if (!trimmedDesired || !sanitizedDesired) {
546
+ if (isTrain) {
547
+ setTrainDatasetName(resolvedName);
548
+ } else {
549
+ setControlDatasetName(resolvedName);
550
+ }
551
+ return;
552
+ }
553
+
554
+ if (sanitizedDesired === resolvedName) {
555
+ if (isTrain && sanitizedDesired !== trainDatasetName) {
556
+ setTrainDatasetName(sanitizedDesired);
557
+ }
558
+ if (!isTrain && sanitizedDesired !== controlDatasetName) {
559
+ setControlDatasetName(sanitizedDesired);
560
+ }
561
+ return;
562
+ }
563
+
564
+ if ((isTrain && isRenamingTrainDataset) || (!isTrain && isRenamingControlDataset)) {
565
+ return;
566
+ }
567
+
568
+ try {
569
+ if (isTrain) {
570
+ setIsRenamingTrainDataset(true);
571
+ } else {
572
+ setIsRenamingControlDataset(true);
573
+ }
574
+
575
+ const response = await apiClient
576
+ .post('/api/datasets/rename', {
577
+ currentName: resolvedName,
578
+ newName: sanitizedDesired,
579
+ })
580
+ .then(res => res.data);
581
+
582
+ if (!response?.success) {
583
+ throw new Error(response?.error || 'Rename failed');
584
+ }
585
+
586
+ const updatedName = response.name as string;
587
+ const updatedPath = response.path as string;
588
+
589
+ if (isTrain) {
590
+ renameUserDataset(resolvedName, updatedName, updatedPath);
591
+ updateUserDatasetPath(updatedName, updatedPath);
592
+ setTrainDatasetResolvedName(updatedName);
593
+ setTrainDatasetPath(updatedPath);
594
+ setTrainDatasetName(updatedName);
595
+ setJobConfig(updatedPath, 'config.process[0].datasets[0].folder_path');
596
+ setTrainPreviewRefresh(prev => prev + 1);
597
+ setTrainUploadInfo(`Renamed dataset to ${updatedName}`);
598
+ setTrainUploadError(null);
599
+ } else {
600
+ renameUserDataset(resolvedName, updatedName, updatedPath);
601
+ updateUserDatasetPath(updatedName, updatedPath);
602
+ setControlDatasetResolvedName(updatedName);
603
+ setControlDatasetPath(updatedPath);
604
+ setControlDatasetName(updatedName);
605
+ setJobConfig(updatedPath, 'config.process[0].datasets[0].control_path');
606
+ setControlPreviewRefresh(prev => prev + 1);
607
+ setControlUploadInfo(`Renamed dataset to ${updatedName}`);
608
+ setControlUploadError(null);
609
+ }
610
+ } catch (error: any) {
611
+ console.error('Dataset rename failed:', error);
612
+ const message = error?.response?.data?.error || error?.message || 'Failed to rename dataset';
613
+ if (isTrain) {
614
+ setTrainDatasetName(resolvedName);
615
+ setTrainUploadError(message);
616
+ } else {
617
+ setControlDatasetName(resolvedName);
618
+ setControlUploadError(message);
619
+ }
620
+ } finally {
621
+ if (isTrain) {
622
+ setIsRenamingTrainDataset(false);
623
+ } else {
624
+ setIsRenamingControlDataset(false);
625
+ }
626
+ }
627
+ },
628
+ [
629
+ controlDatasetName,
630
+ controlDatasetPath,
631
+ controlDatasetResolvedName,
632
+ isRenamingControlDataset,
633
+ isRenamingTrainDataset,
634
+ setJobConfig,
635
+ trainDatasetName,
636
+ trainDatasetPath,
637
+ trainDatasetResolvedName,
638
+ ],
639
+ );
640
+
641
  return (
642
  <form onSubmit={handleSubmit} className="space-y-8">
643
  <Card title="Training Setup">
 
792
  setTrainDatasetNameTouched(true);
793
  }}
794
  placeholder="my-training-dataset"
795
+ onBlur={() => handleDatasetRename('train')}
796
  />
797
  <div
798
  {...trainDropzone.getRootProps({
 
883
 
884
  {controlDatasetMode === 'upload' ? (
885
  <div className="space-y-4">
886
+ <TextInput
887
+ label="Control Dataset Name"
888
+ value={controlDatasetName}
889
+ onChange={value => {
890
+ setControlDatasetName(value);
891
+ setControlDatasetNameTouched(true);
892
+ }}
893
+ placeholder="my-control-data"
894
+ onBlur={() => handleDatasetRename('control')}
895
+ />
896
  <div
897
  {...controlDropzone.getRootProps({
898
  className:
ui/src/components/DatasetImageCard.tsx CHANGED
@@ -210,6 +210,7 @@ const DatasetImageCard: React.FC<DatasetImageCardProps> = ({
210
  className="w-full bg-transparent resize-none outline-none focus:ring-0 focus:outline-none"
211
  value={caption}
212
  rows={3}
 
213
  onChange={e => setCaption(e.target.value)}
214
  onKeyDown={handleKeyDown}
215
  />
 
210
  className="w-full bg-transparent resize-none outline-none focus:ring-0 focus:outline-none"
211
  value={caption}
212
  rows={3}
213
+ placeholder="Type your caption"
214
  onChange={e => setCaption(e.target.value)}
215
  onKeyDown={handleKeyDown}
216
  />
ui/src/components/DatasetPreviewGrid.tsx CHANGED
@@ -88,10 +88,10 @@ export default function DatasetPreviewGrid({
88
  return (
89
  <div
90
  className={classNames(
91
- 'grid gap-4',
92
  compact
93
- ? 'grid-cols-1 sm:grid-cols-2'
94
- : 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
95
  )}
96
  >
97
  {items.map(item => (
 
88
  return (
89
  <div
90
  className={classNames(
91
+ 'grid gap-3 sm:gap-4',
92
  compact
93
+ ? 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5'
94
+ : 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6',
95
  )}
96
  >
97
  {items.map(item => (
ui/src/components/HFJobsWorkflow.tsx CHANGED
@@ -157,7 +157,7 @@ export default function HFJobsWorkflow({ jobConfig, onComplete, hackathonEligibl
157
  const [hardware, setHardware] = useState(settings.HF_JOBS_DEFAULT_HARDWARE || 'a100-large');
158
  const [namespace, setNamespace] = useState(settings.HF_JOBS_NAMESPACE || '');
159
  const [autoUpload, setAutoUpload] = useState(true);
160
- const [participateHackathon, setParticipateHackathon] = useState(() => hackathonEligible);
161
  const [participationTouched, setParticipationTouched] = useState(false);
162
 
163
  const requiresControlImages = (() => {
@@ -479,30 +479,37 @@ export default function HFJobsWorkflow({ jobConfig, onComplete, hackathonEligibl
479
  <Card title="Validate HF Token">
480
  <div className="space-y-4">
481
  {hackathonEligible && (
482
- <Checkbox
483
- label={
484
- <span>
485
- Participating in LoRA Frenzi? If so, keep your training at 5,000 steps or fewer and join this
486
- <a
487
- href="https://huggingface.co/organizations/lora-training-frenzi/share/kEyyVNQXBPWqmARdwHFVdIiFqqONHZPOtz"
488
- target="_blank"
489
- rel="noopener noreferrer"
490
- className="text-blue-400 underline mx-1"
491
- >
492
- organization
493
- </a>
494
- before starting your job.
495
- </span>
496
- }
497
- checked={participateHackathon}
498
- onChange={value => {
499
- setParticipationTouched(true);
500
- setParticipateHackathon(value);
501
- }}
502
- />
 
 
 
 
 
 
 
503
  )}
504
  <p className="text-sm text-gray-400">
505
- First, let's validate your Hugging Face token and get your username for dataset uploads.
506
  </p>
507
 
508
  {validationResult && (
@@ -515,10 +522,14 @@ export default function HFJobsWorkflow({ jobConfig, onComplete, hackathonEligibl
515
 
516
  <Button
517
  onClick={validateToken}
518
- disabled={loading || !(authToken || settings.HF_TOKEN)}
 
 
 
 
519
  className="w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded disabled:opacity-50"
520
  >
521
- {loading ? 'Validating...' : 'Validate Token'}
522
  </Button>
523
  </div>
524
  </Card>
 
157
  const [hardware, setHardware] = useState(settings.HF_JOBS_DEFAULT_HARDWARE || 'a100-large');
158
  const [namespace, setNamespace] = useState(settings.HF_JOBS_NAMESPACE || '');
159
  const [autoUpload, setAutoUpload] = useState(true);
160
+ const [participateHackathon, setParticipateHackathon] = useState(true);
161
  const [participationTouched, setParticipationTouched] = useState(false);
162
 
163
  const requiresControlImages = (() => {
 
479
  <Card title="Validate HF Token">
480
  <div className="space-y-4">
481
  {hackathonEligible && (
482
+ <div className="space-y-3">
483
+ <Checkbox
484
+ label="Participate in LoRA Frenzi"
485
+ checked={participateHackathon}
486
+ onChange={value => {
487
+ setParticipationTouched(true);
488
+ setParticipateHackathon(value);
489
+ }}
490
+ />
491
+ <ul className="text-xs text-gray-400 space-y-1 pl-4 list-disc">
492
+ <li>Maximum 5,000 training steps per run</li>
493
+ <li>Jobs longer than 6 hours will time out</li>
494
+ <li>Train only one LoRA simultaneously</li>
495
+ <li>Do not train on likenesses without consent or NSFW content</li>
496
+ </ul>
497
+ <p className="text-xs text-gray-400">
498
+ Join the
499
+ <a
500
+ href="https://huggingface.co/organizations/lora-training-frenzi/share/kEyyVNQXBPWqmARdwHFVdIiFqqONHZPOtz"
501
+ target="_blank"
502
+ rel="noopener noreferrer"
503
+ className="text-blue-400 underline mx-1"
504
+ >
505
+ LoRA Frenzi organization
506
+ </a>
507
+ before submitting your job.
508
+ </p>
509
+ </div>
510
  )}
511
  <p className="text-sm text-gray-400">
512
+ To continue, accept the rules above and we'll validate your Hugging Face token.
513
  </p>
514
 
515
  {validationResult && (
 
522
 
523
  <Button
524
  onClick={validateToken}
525
+ disabled={
526
+ loading ||
527
+ !(authToken || settings.HF_TOKEN) ||
528
+ (hackathonEligible && !participateHackathon)
529
+ }
530
  className="w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded disabled:opacity-50"
531
  >
532
+ {loading ? 'Validating...' : 'I accept the rules, get started'}
533
  </Button>
534
  </div>
535
  </Card>
ui/src/components/formInputs.tsx CHANGED
@@ -28,10 +28,22 @@ export interface TextInputProps extends InputProps {
28
  onChange: (value: string) => void;
29
  type?: 'text' | 'password';
30
  disabled?: boolean;
 
31
  }
32
 
33
  export const TextInput = forwardRef<HTMLInputElement, TextInputProps>((props: TextInputProps, ref) => {
34
- const { label, value, onChange, placeholder, required, disabled, type = 'text', className, docKey = null } = props;
 
 
 
 
 
 
 
 
 
 
 
35
  let { doc } = props;
36
  if (!doc && docKey) {
37
  doc = getDoc(docKey);
@@ -55,6 +67,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>((props: Te
55
  onChange={e => {
56
  if (!disabled) onChange(e.target.value);
57
  }}
 
58
  className={`${inputClasses} ${disabled ? 'opacity-30 cursor-not-allowed' : ''}`}
59
  placeholder={placeholder}
60
  required={required}
 
28
  onChange: (value: string) => void;
29
  type?: 'text' | 'password';
30
  disabled?: boolean;
31
+ onBlur?: () => void;
32
  }
33
 
34
  export const TextInput = forwardRef<HTMLInputElement, TextInputProps>((props: TextInputProps, ref) => {
35
+ const {
36
+ label,
37
+ value,
38
+ onChange,
39
+ placeholder,
40
+ required,
41
+ disabled,
42
+ onBlur,
43
+ type = 'text',
44
+ className,
45
+ docKey = null,
46
+ } = props;
47
  let { doc } = props;
48
  if (!doc && docKey) {
49
  doc = getDoc(docKey);
 
67
  onChange={e => {
68
  if (!disabled) onChange(e.target.value);
69
  }}
70
+ onBlur={onBlur}
71
  className={`${inputClasses} ${disabled ? 'opacity-30 cursor-not-allowed' : ''}`}
72
  placeholder={placeholder}
73
  required={required}
ui/src/utils/storage/datasetStorage.ts CHANGED
@@ -128,6 +128,37 @@ export const updateUserDatasetPath = (datasetName: string, datasetPath: string)
128
  writeDatasets(updated);
129
  };
130
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  export const removeUserDataset = (datasetName: string) => {
132
  if (!usingBrowserDb) {
133
  return;
 
128
  writeDatasets(updated);
129
  };
130
 
131
+ export const renameUserDataset = (currentName: string, newName: string, datasetPath: string) => {
132
+ if (!usingBrowserDb) {
133
+ return;
134
+ }
135
+ const normalizedCurrent = sanitizeName(currentName);
136
+ const normalizedNew = sanitizeName(newName);
137
+ if (!normalizedCurrent || !normalizedNew) {
138
+ return;
139
+ }
140
+
141
+ const entries = listUserDatasetEntries();
142
+ const updated = entries
143
+ .map(entry => {
144
+ if (sanitizeName(entry.name) === normalizedCurrent) {
145
+ return {
146
+ name: newName,
147
+ path: datasetPath || entry.path,
148
+ };
149
+ }
150
+ return entry;
151
+ })
152
+ .filter(entry => Boolean(entry?.name));
153
+
154
+ const hasNew = updated.some(entry => sanitizeName(entry.name) === normalizedNew);
155
+ if (!hasNew) {
156
+ updated.push({ name: newName, path: datasetPath || '' });
157
+ }
158
+
159
+ writeDatasets(updated);
160
+ };
161
+
162
  export const removeUserDataset = (datasetName: string) => {
163
  if (!usingBrowserDb) {
164
  return;