mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-10-03 09:05:17 -07:00
feat(stories): regenerate action on clips and the chat list dropdown
The track editor's clip toolbar now has a regenerate icon next to Delete; clicking it kicks a fresh take of the selected clip's underlying generation through the same /generate/{id}/regenerate path the History table uses, and pushes the id into the global pending set so the SSE watcher picks it up. The chat list's per-item dropdown gets the same action between Play-from-here and Remove. Translation keys added under storyContent.itemActions / storyContent.toast across all four locales.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { useSortable } from '@dnd-kit/sortable';
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { GripVertical, Mic, MoreHorizontal, Play, Trash2 } from 'lucide-react';
|
import { GripVertical, Mic, MoreHorizontal, Play, RotateCcw, Trash2 } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -21,6 +21,7 @@ interface StoryChatItemProps {
|
|||||||
storyId: string;
|
storyId: string;
|
||||||
index: number;
|
index: number;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
|
onRegenerate?: () => void;
|
||||||
currentTimeMs: number;
|
currentTimeMs: number;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
dragHandleProps?: React.HTMLAttributes<HTMLButtonElement>;
|
dragHandleProps?: React.HTMLAttributes<HTMLButtonElement>;
|
||||||
@@ -30,6 +31,7 @@ interface StoryChatItemProps {
|
|||||||
export function StoryChatItem({
|
export function StoryChatItem({
|
||||||
item,
|
item,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
onRegenerate,
|
||||||
currentTimeMs,
|
currentTimeMs,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
@@ -134,6 +136,12 @@ export function StoryChatItem({
|
|||||||
<Play className="mr-2 h-4 w-4" />
|
<Play className="mr-2 h-4 w-4" />
|
||||||
{t('storyContent.itemActions.playFromHere')}
|
{t('storyContent.itemActions.playFromHere')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
{onRegenerate && (
|
||||||
|
<DropdownMenuItem onClick={onRegenerate}>
|
||||||
|
<RotateCcw className="mr-2 h-4 w-4" />
|
||||||
|
{t('storyContent.itemActions.regenerate')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
className="text-destructive focus:text-destructive"
|
className="text-destructive focus:text-destructive"
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import { apiClient } from '@/lib/api/client';
|
||||||
import { useHistory } from '@/lib/hooks/useHistory';
|
import { useHistory } from '@/lib/hooks/useHistory';
|
||||||
import {
|
import {
|
||||||
useAddStoryItem,
|
useAddStoryItem,
|
||||||
@@ -47,6 +48,7 @@ export function StoryContent() {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const pendingCount = useGenerationStore((s) => s.pendingGenerationIds.size);
|
const pendingCount = useGenerationStore((s) => s.pendingGenerationIds.size);
|
||||||
|
const addPendingGeneration = useGenerationStore((s) => s.addPendingGeneration);
|
||||||
|
|
||||||
// Add generation popover state
|
// Add generation popover state
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
@@ -142,6 +144,19 @@ export function StoryContent() {
|
|||||||
}
|
}
|
||||||
}, [isPlaying]);
|
}, [isPlaying]);
|
||||||
|
|
||||||
|
const handleRegenerate = async (generationId: string) => {
|
||||||
|
try {
|
||||||
|
await apiClient.regenerateGeneration(generationId);
|
||||||
|
addPendingGeneration(generationId);
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: t('storyContent.toast.regenerateFailed'),
|
||||||
|
description: error instanceof Error ? error.message : String(error),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleRemoveItem = (itemId: string) => {
|
const handleRemoveItem = (itemId: string) => {
|
||||||
if (!story) return;
|
if (!story) return;
|
||||||
|
|
||||||
@@ -399,6 +414,7 @@ export function StoryContent() {
|
|||||||
storyId={story.id}
|
storyId={story.id}
|
||||||
index={index}
|
index={index}
|
||||||
onRemove={() => handleRemoveItem(item.id)}
|
onRemove={() => handleRemoveItem(item.id)}
|
||||||
|
onRegenerate={() => handleRegenerate(item.generation_id)}
|
||||||
currentTimeMs={currentTimeMs}
|
currentTimeMs={currentTimeMs}
|
||||||
isPlaying={isPlaying && playbackStoryId === story.id}
|
isPlaying={isPlaying && playbackStoryId === story.id}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Pause,
|
Pause,
|
||||||
Play,
|
Play,
|
||||||
Plus,
|
Plus,
|
||||||
|
RotateCcw,
|
||||||
Scissors,
|
Scissors,
|
||||||
Square,
|
Square,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -32,6 +33,7 @@ import {
|
|||||||
useTrimStoryItem,
|
useTrimStoryItem,
|
||||||
} from '@/lib/hooks/useStories';
|
} from '@/lib/hooks/useStories';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { useGenerationStore } from '@/stores/generationStore';
|
||||||
import { useStoryStore } from '@/stores/storyStore';
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
|
|
||||||
// Clip waveform component with trim support
|
// Clip waveform component with trim support
|
||||||
@@ -152,6 +154,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
const removeItem = useRemoveStoryItem();
|
const removeItem = useRemoveStoryItem();
|
||||||
const setItemVersion = useSetStoryItemVersion();
|
const setItemVersion = useSetStoryItemVersion();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const addPendingGeneration = useGenerationStore((s) => s.addPendingGeneration);
|
||||||
|
|
||||||
// Selection state
|
// Selection state
|
||||||
const selectedClipId = useStoryStore((state) => state.selectedClipId);
|
const selectedClipId = useStoryStore((state) => state.selectedClipId);
|
||||||
@@ -630,6 +633,20 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
);
|
);
|
||||||
}, [selectedClipId, storyId, removeItem, toast, setSelectedClipId]);
|
}, [selectedClipId, storyId, removeItem, toast, setSelectedClipId]);
|
||||||
|
|
||||||
|
const handleRegenerate = useCallback(async () => {
|
||||||
|
if (!selectedItem) return;
|
||||||
|
try {
|
||||||
|
await apiClient.regenerateGeneration(selectedItem.generation_id);
|
||||||
|
addPendingGeneration(selectedItem.generation_id);
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to regenerate',
|
||||||
|
description: error instanceof Error ? error.message : String(error),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [selectedItem, addPendingGeneration, toast]);
|
||||||
|
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
@@ -999,6 +1016,16 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={handleRegenerate}
|
||||||
|
title="Regenerate"
|
||||||
|
aria-label="Regenerate clip"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
{hasMultipleVersions && (
|
{hasMultipleVersions && (
|
||||||
<>
|
<>
|
||||||
<div className="w-px h-4 bg-border mx-1" />
|
<div className="w-px h-4 bg-border mx-1" />
|
||||||
|
|||||||
@@ -629,13 +629,15 @@
|
|||||||
},
|
},
|
||||||
"itemActions": {
|
"itemActions": {
|
||||||
"playFromHere": "Play from here",
|
"playFromHere": "Play from here",
|
||||||
|
"regenerate": "Regenerate",
|
||||||
"removeFromStory": "Remove from Story"
|
"removeFromStory": "Remove from Story"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"removeFailed": "Failed to remove item",
|
"removeFailed": "Failed to remove item",
|
||||||
"reorderFailed": "Failed to reorder items",
|
"reorderFailed": "Failed to reorder items",
|
||||||
"exportFailed": "Failed to export audio",
|
"exportFailed": "Failed to export audio",
|
||||||
"addFailed": "Failed to add generation"
|
"addFailed": "Failed to add generation",
|
||||||
|
"regenerateFailed": "Failed to regenerate"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"history": {
|
"history": {
|
||||||
|
|||||||
@@ -629,13 +629,15 @@
|
|||||||
},
|
},
|
||||||
"itemActions": {
|
"itemActions": {
|
||||||
"playFromHere": "ここから再生",
|
"playFromHere": "ここから再生",
|
||||||
|
"regenerate": "再生成",
|
||||||
"removeFromStory": "ストーリーから削除"
|
"removeFromStory": "ストーリーから削除"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"removeFailed": "項目の削除に失敗しました",
|
"removeFailed": "項目の削除に失敗しました",
|
||||||
"reorderFailed": "項目の並び替えに失敗しました",
|
"reorderFailed": "項目の並び替えに失敗しました",
|
||||||
"exportFailed": "オーディオのエクスポートに失敗しました",
|
"exportFailed": "オーディオのエクスポートに失敗しました",
|
||||||
"addFailed": "生成の追加に失敗しました"
|
"addFailed": "生成の追加に失敗しました",
|
||||||
|
"regenerateFailed": "再生成に失敗しました"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"history": {
|
"history": {
|
||||||
|
|||||||
@@ -629,13 +629,15 @@
|
|||||||
},
|
},
|
||||||
"itemActions": {
|
"itemActions": {
|
||||||
"playFromHere": "从此处播放",
|
"playFromHere": "从此处播放",
|
||||||
|
"regenerate": "重新生成",
|
||||||
"removeFromStory": "从故事中移除"
|
"removeFromStory": "从故事中移除"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"removeFailed": "移除项目失败",
|
"removeFailed": "移除项目失败",
|
||||||
"reorderFailed": "重新排序项目失败",
|
"reorderFailed": "重新排序项目失败",
|
||||||
"exportFailed": "导出音频失败",
|
"exportFailed": "导出音频失败",
|
||||||
"addFailed": "添加生成失败"
|
"addFailed": "添加生成失败",
|
||||||
|
"regenerateFailed": "重新生成失败"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"history": {
|
"history": {
|
||||||
|
|||||||
@@ -629,13 +629,15 @@
|
|||||||
},
|
},
|
||||||
"itemActions": {
|
"itemActions": {
|
||||||
"playFromHere": "從此處播放",
|
"playFromHere": "從此處播放",
|
||||||
|
"regenerate": "重新生成",
|
||||||
"removeFromStory": "從故事中移除"
|
"removeFromStory": "從故事中移除"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"removeFailed": "移除項目失敗",
|
"removeFailed": "移除項目失敗",
|
||||||
"reorderFailed": "重新排序項目失敗",
|
"reorderFailed": "重新排序項目失敗",
|
||||||
"exportFailed": "匯出音訊失敗",
|
"exportFailed": "匯出音訊失敗",
|
||||||
"addFailed": "新增生成失敗"
|
"addFailed": "新增生成失敗",
|
||||||
|
"regenerateFailed": "重新生成失敗"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"history": {
|
"history": {
|
||||||
|
|||||||
Reference in New Issue
Block a user