Empty default API key

Warning message in TranscriptionTask when API key is empty
"Add API Key in Settings" button that opens settings modal
Error message when trying to test empty API key
No "Reset to Default" button for API key
Improved copy functionality with inline success message
Added fade-in animation for the success message
This commit is contained in:
TheMaddax 2025-01-28 12:01:49 -06:00
parent 54db9d1904
commit 436c8132d3
12 changed files with 127 additions and 80 deletions

Binary file not shown.

1
src-tauri/Cargo.lock generated
View file

@ -3888,6 +3888,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"base64 0.21.7",
"futures-util",
"image",
"reqwest 0.11.27",
"serde",

View file

@ -18,6 +18,7 @@ tauri = { version = "2.0.0", features = [] }
tokio = { version = "1.36", features = ["full"] }
anyhow = "1.0"
thiserror = "1.0"
futures-util = "0.3"
tauri-plugin-shell = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
tauri-plugin-dialog = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
tauri-plugin-fs = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }

Binary file not shown.

View file

@ -24,7 +24,13 @@
},
{
"identifier": "shell:allow-execute",
"description": "Allows executing FFmpeg commands"
"description": "Allows executing FFmpeg commands and sidecars",
"allow": [
{
"name": "gemini-query",
"sidecar": true
}
]
},
{
"identifier": "core:event:allow-emit",

View file

@ -2,7 +2,8 @@ use serde::{Deserialize, Serialize};
use std::process::{Command, Stdio};
use std::sync::Mutex;
use std::io::BufRead;
use tauri::{Runtime, State, Emitter, Manager};
use tauri::{Runtime, State, Emitter};
use tauri_plugin_shell::ShellExt;
use tempfile::TempDir;
use std::fs;
use base64::Engine;
@ -174,30 +175,17 @@ async fn query_gemini<R: Runtime>(
println!("Image Path: {}", path);
}
let executable_path = if cfg!(debug_assertions) {
// Development mode - use absolute path
std::path::PathBuf::from("/Users/chaulmark/homemade-scripts/video-subtitle-merger/subtitle-merge/gemini/gemini-query")
} else {
// Production mode - use resource path
app_handle.path().resource_dir()
.map_err(|e| Error::IoError(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("Resource directory not found: {}", e)
)))?
.join("gemini-query")
};
println!("Executable path: {:?}", executable_path);
let mut command = Command::new(executable_path);
command.arg(api_key)
.arg(prompt);
let sidecar = app_handle.shell().sidecar("gemini-query")
.map_err(|e| Error::GeminiError(format!("Failed to get sidecar: {}", e)))?;
let mut command = sidecar.arg(api_key).arg(prompt);
if let Some(path) = image_path {
command.arg(path);
command = command.arg(path);
}
println!("\nExecuting command: {:?}", command);
let output = command.output()
println!("\nExecuting sidecar command");
let output = command.output().await
.map_err(|e| Error::GeminiError(format!("Failed to execute Gemini query: {}", e)))?;
let response_text = String::from_utf8_lossy(&output.stdout);

View file

@ -28,7 +28,13 @@
},
"bundle": {
"active": true,
"targets": ["app"],
"targets": ["dmg", "app"],
"macOS": {
"frameworks": [],
"minimumSystemVersion": "10.13",
"signingIdentity": "Developer ID Application: Chris Haulmark (RJHWWWSF6Q)",
"providerShortName": null
},
"icon": [
"icons/32x32.png",
"icons/128x128.png",
@ -36,8 +42,8 @@
"icons/icon.icns",
"icons/icon.ico"
],
"resources": [
"../gemini/gemini-query"
"externalBin": [
"binaries/gemini-query"
]
}
}

View file

@ -11,7 +11,7 @@ function App() {
const [selectedTask, setSelectedTask] = useState<TaskType>(null);
const [error, setError] = useState<string | null>(null);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [apiKey, setApiKey] = useState('AIzaSyAF825tPTh77oL0knsGFEyvsN0iPUO_bXc');
const [apiKey, setApiKey] = useState('');
const [visualPrompt, setVisualPrompt] = useState(() => {
const saved = localStorage.getItem('visualPrompt');
return saved || DEFAULT_VISUAL_PROMPT;
@ -110,6 +110,7 @@ function App() {
apiKey={apiKey}
visualPrompt={visualPrompt}
subtitlePrompt={subtitlePrompt}
onOpenSettings={() => setIsSettingsOpen(true)}
/>
)}

View file

@ -14,7 +14,7 @@ interface SettingsProps {
// Note: We keep subtitle prompt in Settings since it's still used by the merge task
export const DEFAULT_API_KEY = 'AIzaSyAF825tPTh77oL0knsGFEyvsN0iPUO_bXc';
export const DEFAULT_API_KEY = '';
export const DEFAULT_VISUAL_PROMPT = `Analyze this video frame from timestamp {} and provide a visual description focusing on:
1. The visual setup and environment
2. The people present, their appearance, and positioning
@ -83,10 +83,6 @@ export const Settings: React.FC<SettingsProps> = ({
setDisplayValue('*'.repeat(apiKey.length));
};
const resetApiKeyToDefault = () => {
onApiKeyChange(DEFAULT_API_KEY);
};
const resetVisualPromptToDefault = () => {
onVisualPromptChange(DEFAULT_VISUAL_PROMPT);
};
@ -98,6 +94,13 @@ export const Settings: React.FC<SettingsProps> = ({
const testApiKey = async () => {
setTesting(true);
setTestResult(null);
if (!apiKey.trim()) {
setTestResult('Error: API Key is required for testing');
setTesting(false);
return;
}
try {
await invoke('test_gemini_api', { apiKey });
setTestResult('API key is valid');
@ -145,6 +148,14 @@ export const Settings: React.FC<SettingsProps> = ({
</div>
<div className="flex space-x-3">
<a
href="https://aistudio.google.com/app/apikey"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-500 hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Get API Key
</a>
<button
onClick={testApiKey}
disabled={testing}
@ -152,15 +163,6 @@ export const Settings: React.FC<SettingsProps> = ({
>
{testing ? 'Testing...' : 'Test API Key'}
</button>
{apiKey !== DEFAULT_API_KEY && (
<button
onClick={resetApiKeyToDefault}
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md shadow-sm text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Reset to Default
</button>
)}
</div>
{testResult && (
@ -174,19 +176,14 @@ export const Settings: React.FC<SettingsProps> = ({
<label className="block text-sm font-medium text-gray-700">
Visual Description Prompt
</label>
<div className="relative">
<textarea
value={visualPrompt}
onChange={(e) => onVisualPromptChange(e.target.value)}
rows={4}
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:ring-blue-500 focus:border-blue-500 sm:text-sm font-mono resize-y"
placeholder="Enter prompt for visual description"
spellCheck={false}
/>
<div className="absolute bottom-2 right-2 text-xs text-gray-500">
{visualPrompt.length} characters
</div>
</div>
<textarea
value={visualPrompt}
onChange={(e) => onVisualPromptChange(e.target.value)}
rows={4}
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:ring-blue-500 focus:border-blue-500 sm:text-sm font-mono resize-y"
placeholder="Enter prompt for visual description"
spellCheck={false}
/>
{visualPrompt !== DEFAULT_VISUAL_PROMPT && (
<button
onClick={resetVisualPromptToDefault}
@ -201,19 +198,14 @@ export const Settings: React.FC<SettingsProps> = ({
<label className="block text-sm font-medium text-gray-700">
Subtitle Narrative Prompt
</label>
<div className="relative">
<textarea
value={subtitlePrompt}
onChange={(e) => onSubtitlePromptChange(e.target.value)}
rows={4}
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:ring-blue-500 focus:border-blue-500 sm:text-sm font-mono resize-y"
placeholder="Enter prompt for subtitle narrative"
spellCheck={false}
/>
<div className="absolute bottom-2 right-2 text-xs text-gray-500">
{subtitlePrompt.length} characters
</div>
</div>
<textarea
value={subtitlePrompt}
onChange={(e) => onSubtitlePromptChange(e.target.value)}
rows={4}
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:ring-blue-500 focus:border-blue-500 sm:text-sm font-mono resize-y"
placeholder="Enter prompt for subtitle narrative"
spellCheck={false}
/>
{subtitlePrompt !== DEFAULT_SUBTITLE_PROMPT && (
<button
onClick={resetSubtitlePromptToDefault}

View file

@ -30,12 +30,14 @@ interface TranscriptionTaskProps {
apiKey: string;
visualPrompt: string;
subtitlePrompt: string;
onOpenSettings?: () => void;
}
export const TranscriptionTask: React.FC<TranscriptionTaskProps> = ({
apiKey,
visualPrompt,
subtitlePrompt,
onOpenSettings
}) => {
const [videoFile, setVideoFile] = useState<FileState | null>(null);
const [subtitleFile, setSubtitleFile] = useState<FileState | null>(null);
@ -44,6 +46,7 @@ export const TranscriptionTask: React.FC<TranscriptionTaskProps> = ({
const [selectedFrames, setSelectedFrames] = useState<SelectedFrame[]>([]);
const [transcription, setTranscription] = useState<TranscriptionState | null>(null);
const [thumbnails, setThumbnails] = useState<Thumbnail[]>([]);
const [copySuccess, setCopySuccess] = useState(false);
const handleVideoSelect = (path: string) => {
const name = path.split('/').pop() || '';
@ -105,6 +108,28 @@ export const TranscriptionTask: React.FC<TranscriptionTaskProps> = ({
return (
<div className="space-y-6">
{!apiKey && (
<div className="bg-amber-50 border-l-4 border-amber-400 p-4 mb-4">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-amber-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<p className="text-sm text-amber-700">
A Gemini API Key is required to generate video descriptions.
<button
onClick={onOpenSettings}
className="ml-2 font-medium text-amber-700 underline hover:text-amber-600"
>
Add API Key in Settings
</button>
</p>
</div>
</div>
</div>
)}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex-1 mr-4">
@ -149,9 +174,32 @@ export const TranscriptionTask: React.FC<TranscriptionTaskProps> = ({
{transcription && (
<div className="mt-6 space-y-4">
<h3 className="text-lg font-semibold">Scene Narrative</h3>
<div className="h-[400px] overflow-y-auto p-4 bg-gray-50 rounded-lg text-sm">
<div className="bg-white p-4 rounded border border-gray-200">
<p className="whitespace-pre-wrap leading-relaxed">{transcription.narrative}</p>
<div className="space-y-2">
<div className="h-[400px] overflow-y-auto p-4 bg-gray-50 rounded-lg text-sm">
<div className="bg-white p-4 rounded border border-gray-200">
<p className="whitespace-pre-wrap leading-relaxed">{transcription.narrative}</p>
</div>
</div>
<div className="flex items-center space-x-3">
<button
onClick={(e) => {
e.preventDefault(); // Prevent any navigation
navigator.clipboard.writeText(transcription.narrative)
.then(() => {
setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 2000);
})
.catch(err => console.error('Failed to copy:', err));
}}
className="px-4 py-2 text-sm font-medium text-white bg-blue-500 rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Copy to Clipboard
</button>
{copySuccess && (
<span className="text-sm text-green-600 animate-fade-in">
Content copied successfully!
</span>
)}
</div>
</div>
</div>

View file

@ -21,19 +21,8 @@ interface SelectedFrame {
export const VideoTimeline: React.FC<VideoTimelineProps> = ({ videoPath, onFramesSelect, thumbnails }) => {
const [selectedFrames, setSelectedFrames] = useState<SelectedFrame[]>([]);
const [duration, setDuration] = useState<number>(0);
const [thumbnailUrls, setThumbnailUrls] = useState<{ [key: string]: string }>({});
useEffect(() => {
if (videoPath) {
invoke<number>('get_video_duration', { path: videoPath })
.then(videoDuration => setDuration(videoDuration))
.catch(error => console.error('Error getting video duration:', error));
} else {
setDuration(0);
}
}, [videoPath]);
useEffect(() => {
const loadThumbnails = async () => {
const urls: { [key: string]: string } = {};

View file

@ -21,3 +21,18 @@
/* Remove unused progress bar styles since we're using direct classes in App.tsx */
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@layer utilities {
.animate-fade-in {
animation: fadeIn 0.2s ease-in;
}
}