feat(smart-actions): add prompt-result screen, capture sub-routes, Cmd+Shift+A shortcut, telemetry events (G16-G19)
This commit is contained in:
parent
63ee00037e
commit
6095f1d985
@ -8,6 +8,7 @@
|
||||
import { isFeatureEnabled } from './feature-flags.js';
|
||||
import { embedText, stripHtmlForEmbedding } from './embeddings.js';
|
||||
import { llm } from './llm.js';
|
||||
import { trackEvent } from './telemetry.js';
|
||||
import type { NoteDoc } from '../modules/notes/types.js';
|
||||
import { getCollection } from './datastore.js';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
@ -131,6 +132,7 @@ async function backgroundAutoSummarize(
|
||||
updatedBy: 'system',
|
||||
});
|
||||
|
||||
trackEvent('auto_summarize_triggered', note.userId, { wordCount: String(wordCount), noteId: note.id });
|
||||
log.info({ noteId: note.id, artifactId: artifact.id }, 'auto-summary generated');
|
||||
} catch (err) {
|
||||
log.warn({ noteId: note.id, err }, 'background auto-summarize failed');
|
||||
|
||||
@ -296,7 +296,11 @@ export async function notePromptRoutes(app: FastifyInstance): Promise<void> {
|
||||
}
|
||||
|
||||
duplicates.sort((a, b) => b.similarity - a.similarity);
|
||||
return { duplicates: duplicates.slice(0, input.limit) };
|
||||
const results = duplicates.slice(0, input.limit);
|
||||
for (const dup of results) {
|
||||
trackEvent('duplicate_detected', userId, { similarityScore: String(dup.similarity), noteId: id });
|
||||
}
|
||||
return { duplicates: results };
|
||||
});
|
||||
|
||||
// ── Suggest related notes to link (F9) ──────────────────────────
|
||||
|
||||
@ -243,6 +243,10 @@ export async function noteRoutes(app: RouteApp) {
|
||||
|
||||
const created = await repo.createNote(doc);
|
||||
trackEvent('note.created', auth.sub, { noteId: created.id, workspaceId: created.workspaceId });
|
||||
if (created.sourceType === 'voice') {
|
||||
const wordCount = (created.body ?? '').split(/\s+/).filter(Boolean).length;
|
||||
trackEvent('voice_capture_completed', auth.sub, { noteId: created.id, wordCount: String(wordCount) });
|
||||
}
|
||||
reply.code(201);
|
||||
return created;
|
||||
});
|
||||
|
||||
46
mobile/src/app/(tabs)/capture/scan.tsx
Normal file
46
mobile/src/app/(tabs)/capture/scan.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { useState } from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { colors } from '../../../theme';
|
||||
|
||||
export default function ScanCaptureScreen() {
|
||||
const router = useRouter();
|
||||
const [scanning, setScanning] = useState(false);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.heading}>Document Scan</Text>
|
||||
<Text style={styles.hint}>Use your camera to scan a multi-page document. Text will be extracted via OCR.</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.scanBtn, scanning && styles.scanBtnActive]}
|
||||
onPress={() => setScanning(!scanning)}
|
||||
accessibilityLabel={scanning ? 'Stop scanning' : 'Start scanning'}
|
||||
>
|
||||
<Text style={styles.scanIcon}>{scanning ? '⏹' : '📄'}</Text>
|
||||
<Text style={styles.scanLabel}>{scanning ? 'Stop' : 'Scan Document'}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Text style={styles.placeholder}>
|
||||
Camera-based document scanning will be available in a future release. For now, use the Text or URL capture modes.
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity style={styles.backBtn} onPress={() => router.back()}>
|
||||
<Text style={styles.backBtnText}>Go Back</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.bgCanvas, padding: 16, gap: 12 },
|
||||
heading: { fontSize: 18, fontWeight: '700', color: colors.textPrimary },
|
||||
hint: { fontSize: 13, color: colors.textSecondary },
|
||||
scanBtn: { alignSelf: 'center', alignItems: 'center', padding: 24, borderRadius: 60, backgroundColor: colors.surfaceCard, borderWidth: 2, borderColor: colors.borderDefault },
|
||||
scanBtnActive: { borderColor: colors.accentPrimary, backgroundColor: colors.surfaceMuted },
|
||||
scanIcon: { fontSize: 40 },
|
||||
scanLabel: { fontSize: 13, color: colors.textPrimary, marginTop: 4 },
|
||||
placeholder: { fontSize: 14, color: colors.textTertiary, textAlign: 'center', paddingHorizontal: 20, lineHeight: 20 },
|
||||
backBtn: { backgroundColor: colors.surfaceMuted, borderRadius: 8, paddingVertical: 8, paddingHorizontal: 16, alignItems: 'center' },
|
||||
backBtnText: { color: colors.textPrimary, fontWeight: '600', fontSize: 15 },
|
||||
});
|
||||
76
mobile/src/app/(tabs)/capture/url.tsx
Normal file
76
mobile/src/app/(tabs)/capture/url.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import { useState } from 'react';
|
||||
import { View, Text, TextInput, TouchableOpacity, StyleSheet, ActivityIndicator } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { extractFromUrl } from '../../../api/note-prompts';
|
||||
import { colors } from '../../../theme';
|
||||
|
||||
export default function UrlCaptureScreen() {
|
||||
const router = useRouter();
|
||||
const [url, setUrl] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [result, setResult] = useState<{ title: string; content: string } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleExtract() {
|
||||
if (!url.trim()) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await extractFromUrl(url.trim(), 'default');
|
||||
setResult({ title: res.title, content: res.content });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Extraction failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.heading}>URL Capture</Text>
|
||||
<Text style={styles.hint}>Paste a URL to extract and summarize its content.</Text>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="https://example.com/article"
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
value={url}
|
||||
onChangeText={setUrl}
|
||||
autoCapitalize="none"
|
||||
keyboardType="url"
|
||||
accessibilityLabel="URL input"
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.btn, busy && { opacity: 0.6 }]}
|
||||
disabled={busy || !url.trim()}
|
||||
onPress={() => void handleExtract()}
|
||||
accessibilityLabel="Extract content"
|
||||
>
|
||||
{busy ? <ActivityIndicator color="#fff" size="small" /> : <Text style={styles.btnText}>Extract</Text>}
|
||||
</TouchableOpacity>
|
||||
|
||||
{error && <Text style={styles.error}>{error}</Text>}
|
||||
|
||||
{result && (
|
||||
<View style={styles.resultCard}>
|
||||
<Text style={styles.resultTitle}>{result.title}</Text>
|
||||
<Text style={styles.resultContent} numberOfLines={20}>{result.content}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.bgCanvas, padding: 16, gap: 12 },
|
||||
heading: { fontSize: 18, fontWeight: '700', color: colors.textPrimary },
|
||||
hint: { fontSize: 13, color: colors.textSecondary },
|
||||
input: { backgroundColor: colors.surfaceCard, borderRadius: 8, padding: 12, borderWidth: 1, borderColor: colors.borderDefault, color: colors.textPrimary, fontSize: 15 },
|
||||
btn: { backgroundColor: colors.accentPrimary, borderRadius: 8, paddingVertical: 10, alignItems: 'center' },
|
||||
btnText: { color: '#fff', fontWeight: '600', fontSize: 15 },
|
||||
error: { color: colors.danger, fontSize: 13 },
|
||||
resultCard: { backgroundColor: colors.surfaceCard, borderRadius: 8, padding: 12, borderWidth: 1, borderColor: colors.borderDefault, gap: 8 },
|
||||
resultTitle: { fontSize: 16, fontWeight: '600', color: colors.textPrimary },
|
||||
resultContent: { fontSize: 14, color: colors.textSecondary, lineHeight: 20 },
|
||||
});
|
||||
51
mobile/src/app/(tabs)/capture/voice.tsx
Normal file
51
mobile/src/app/(tabs)/capture/voice.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import { useState } from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { colors } from '../../../theme';
|
||||
|
||||
export default function VoiceCaptureScreen() {
|
||||
const router = useRouter();
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [transcript, setTranscript] = useState('');
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.heading}>Voice Capture</Text>
|
||||
<Text style={styles.hint}>Tap the microphone to start recording. Speech will be transcribed automatically.</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.micBtn, recording && styles.micBtnActive]}
|
||||
onPress={() => setRecording(!recording)}
|
||||
accessibilityLabel={recording ? 'Stop recording' : 'Start recording'}
|
||||
>
|
||||
<Text style={styles.micIcon}>{recording ? '⏹' : '🎙'}</Text>
|
||||
<Text style={styles.micLabel}>{recording ? 'Stop' : 'Record'}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{transcript.length > 0 && (
|
||||
<>
|
||||
<View style={styles.transcriptBox}>
|
||||
<Text style={styles.transcriptText}>{transcript}</Text>
|
||||
</View>
|
||||
<TouchableOpacity style={styles.saveBtn} onPress={() => router.back()}>
|
||||
<Text style={styles.saveBtnText}>Save as Note</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.bgCanvas, padding: 16, gap: 12 },
|
||||
heading: { fontSize: 18, fontWeight: '700', color: colors.textPrimary },
|
||||
hint: { fontSize: 13, color: colors.textSecondary },
|
||||
micBtn: { alignSelf: 'center', alignItems: 'center', padding: 24, borderRadius: 60, backgroundColor: colors.surfaceCard, borderWidth: 2, borderColor: colors.borderDefault },
|
||||
micBtnActive: { borderColor: colors.accentPrimary, backgroundColor: colors.surfaceMuted },
|
||||
micIcon: { fontSize: 40 },
|
||||
micLabel: { fontSize: 13, color: colors.textPrimary, marginTop: 4 },
|
||||
transcriptBox: { backgroundColor: colors.surfaceCard, borderRadius: 8, padding: 12, borderWidth: 1, borderColor: colors.borderDefault },
|
||||
transcriptText: { fontSize: 15, color: colors.textPrimary, lineHeight: 22 },
|
||||
saveBtn: { backgroundColor: colors.accentPrimary, borderRadius: 8, paddingVertical: 8, paddingHorizontal: 16, alignItems: 'center' },
|
||||
saveBtnText: { color: '#fff', fontWeight: '600', fontSize: 15 },
|
||||
});
|
||||
130
mobile/src/app/prompt-result.tsx
Normal file
130
mobile/src/app/prompt-result.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import { View, Text, ScrollView, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||
import { usePromptStore } from '../store/prompt-store';
|
||||
import { colors } from '../theme';
|
||||
|
||||
export default function PromptResultScreen() {
|
||||
const router = useRouter();
|
||||
const { templateName } = useLocalSearchParams<{ templateName?: string }>();
|
||||
const { lastResult, clearResult } = usePromptStore();
|
||||
|
||||
if (!lastResult) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.empty}>No prompt result available.</Text>
|
||||
<TouchableOpacity style={styles.btn} onPress={() => router.back()}>
|
||||
<Text style={styles.btnText}>Go Back</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
<Text style={styles.heading}>{templateName ?? 'Prompt Result'}</Text>
|
||||
|
||||
{lastResult.model && (
|
||||
<Text style={styles.meta}>
|
||||
Model: {lastResult.model}
|
||||
{lastResult.usage ? ` · ${lastResult.usage.totalTokens} tokens` : ''}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<View style={styles.resultCard}>
|
||||
<Text style={styles.resultText}>{lastResult.content}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.badges}>
|
||||
<View style={styles.badge}>
|
||||
<Text style={styles.badgeText}>{lastResult.templateSlug}</Text>
|
||||
</View>
|
||||
<View style={styles.badge}>
|
||||
<Text style={styles.badgeText}>{lastResult.outputType}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<TouchableOpacity
|
||||
style={styles.btn}
|
||||
onPress={() => {
|
||||
clearResult();
|
||||
router.back();
|
||||
}}
|
||||
>
|
||||
<Text style={styles.btnText}>Dismiss</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgCanvas,
|
||||
},
|
||||
content: {
|
||||
padding: 16,
|
||||
gap: 12,
|
||||
},
|
||||
heading: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
meta: {
|
||||
fontSize: 13,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
resultCard: {
|
||||
backgroundColor: colors.surfaceCard,
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderDefault,
|
||||
},
|
||||
resultText: {
|
||||
fontSize: 15,
|
||||
color: colors.textPrimary,
|
||||
lineHeight: 22,
|
||||
},
|
||||
badges: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
badge: {
|
||||
backgroundColor: colors.surfaceMuted,
|
||||
borderRadius: 4,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderDefault,
|
||||
},
|
||||
badgeText: {
|
||||
fontSize: 12,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
marginTop: 12,
|
||||
},
|
||||
btn: {
|
||||
backgroundColor: colors.accentPrimary,
|
||||
borderRadius: 8,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
btnText: {
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
fontSize: 15,
|
||||
},
|
||||
empty: {
|
||||
fontSize: 15,
|
||||
color: colors.textSecondary,
|
||||
textAlign: 'center',
|
||||
marginTop: 24,
|
||||
},
|
||||
});
|
||||
@ -29,6 +29,13 @@ export function KeyboardShortcuts() {
|
||||
handler: () => router.push("/reviews"),
|
||||
description: "Go to reviews",
|
||||
},
|
||||
{
|
||||
key: "a",
|
||||
meta: true,
|
||||
shift: true,
|
||||
handler: () => router.push("/prompts"),
|
||||
description: "Open Smart Actions",
|
||||
},
|
||||
{
|
||||
key: "Escape",
|
||||
handler: () => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user