mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-16 21:30:39 -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 { 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 { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -21,6 +21,7 @@ interface StoryChatItemProps {
|
||||
storyId: string;
|
||||
index: number;
|
||||
onRemove: () => void;
|
||||
onRegenerate?: () => void;
|
||||
currentTimeMs: number;
|
||||
isPlaying: boolean;
|
||||
dragHandleProps?: React.HTMLAttributes<HTMLButtonElement>;
|
||||
@@ -30,6 +31,7 @@ interface StoryChatItemProps {
|
||||
export function StoryChatItem({
|
||||
item,
|
||||
onRemove,
|
||||
onRegenerate,
|
||||
currentTimeMs,
|
||||
isPlaying,
|
||||
dragHandleProps,
|
||||
@@ -134,6 +136,12 @@ export function StoryChatItem({
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
{t('storyContent.itemActions.playFromHere')}
|
||||
</DropdownMenuItem>
|
||||
{onRegenerate && (
|
||||
<DropdownMenuItem onClick={onRegenerate}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
{t('storyContent.itemActions.regenerate')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={onRemove}
|
||||
className="text-destructive focus:text-destructive"
|
||||
|
||||
@@ -23,6 +23,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { useHistory } from '@/lib/hooks/useHistory';
|
||||
import {
|
||||
useAddStoryItem,
|
||||
@@ -47,6 +48,7 @@ export function StoryContent() {
|
||||
const { toast } = useToast();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const pendingCount = useGenerationStore((s) => s.pendingGenerationIds.size);
|
||||
const addPendingGeneration = useGenerationStore((s) => s.addPendingGeneration);
|
||||
|
||||
// Add generation popover state
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -142,6 +144,19 @@ export function StoryContent() {
|
||||
}
|
||||
}, [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) => {
|
||||
if (!story) return;
|
||||
|
||||
@@ -399,6 +414,7 @@ export function StoryContent() {
|
||||
storyId={story.id}
|
||||
index={index}
|
||||
onRemove={() => handleRemoveItem(item.id)}
|
||||
onRegenerate={() => handleRegenerate(item.generation_id)}
|
||||
currentTimeMs={currentTimeMs}
|
||||
isPlaying={isPlaying && playbackStoryId === story.id}
|
||||
/>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Pause,
|
||||
Play,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
Scissors,
|
||||
Square,
|
||||
Trash2,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
useTrimStoryItem,
|
||||
} from '@/lib/hooks/useStories';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { useGenerationStore } from '@/stores/generationStore';
|
||||
import { useStoryStore } from '@/stores/storyStore';
|
||||
|
||||
// Clip waveform component with trim support
|
||||
@@ -152,6 +154,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
const removeItem = useRemoveStoryItem();
|
||||
const setItemVersion = useSetStoryItemVersion();
|
||||
const { toast } = useToast();
|
||||
const addPendingGeneration = useGenerationStore((s) => s.addPendingGeneration);
|
||||
|
||||
// Selection state
|
||||
const selectedClipId = useStoryStore((state) => state.selectedClipId);
|
||||
@@ -630,6 +633,20 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
);
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -999,6 +1016,16 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</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 && (
|
||||
<>
|
||||
<div className="w-px h-4 bg-border mx-1" />
|
||||
|
||||
@@ -629,13 +629,15 @@
|
||||
},
|
||||
"itemActions": {
|
||||
"playFromHere": "Play from here",
|
||||
"regenerate": "Regenerate",
|
||||
"removeFromStory": "Remove from Story"
|
||||
},
|
||||
"toast": {
|
||||
"removeFailed": "Failed to remove item",
|
||||
"reorderFailed": "Failed to reorder items",
|
||||
"exportFailed": "Failed to export audio",
|
||||
"addFailed": "Failed to add generation"
|
||||
"addFailed": "Failed to add generation",
|
||||
"regenerateFailed": "Failed to regenerate"
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
|
||||
@@ -629,13 +629,15 @@
|
||||
},
|
||||
"itemActions": {
|
||||
"playFromHere": "ここから再生",
|
||||
"regenerate": "再生成",
|
||||
"removeFromStory": "ストーリーから削除"
|
||||
},
|
||||
"toast": {
|
||||
"removeFailed": "項目の削除に失敗しました",
|
||||
"reorderFailed": "項目の並び替えに失敗しました",
|
||||
"exportFailed": "オーディオのエクスポートに失敗しました",
|
||||
"addFailed": "生成の追加に失敗しました"
|
||||
"addFailed": "生成の追加に失敗しました",
|
||||
"regenerateFailed": "再生成に失敗しました"
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
|
||||
@@ -629,13 +629,15 @@
|
||||
},
|
||||
"itemActions": {
|
||||
"playFromHere": "从此处播放",
|
||||
"regenerate": "重新生成",
|
||||
"removeFromStory": "从故事中移除"
|
||||
},
|
||||
"toast": {
|
||||
"removeFailed": "移除项目失败",
|
||||
"reorderFailed": "重新排序项目失败",
|
||||
"exportFailed": "导出音频失败",
|
||||
"addFailed": "添加生成失败"
|
||||
"addFailed": "添加生成失败",
|
||||
"regenerateFailed": "重新生成失败"
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
|
||||
@@ -629,13 +629,15 @@
|
||||
},
|
||||
"itemActions": {
|
||||
"playFromHere": "從此處播放",
|
||||
"regenerate": "重新生成",
|
||||
"removeFromStory": "從故事中移除"
|
||||
},
|
||||
"toast": {
|
||||
"removeFailed": "移除項目失敗",
|
||||
"reorderFailed": "重新排序項目失敗",
|
||||
"exportFailed": "匯出音訊失敗",
|
||||
"addFailed": "新增生成失敗"
|
||||
"addFailed": "新增生成失敗",
|
||||
"regenerateFailed": "重新生成失敗"
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
|
||||
Reference in New Issue
Block a user