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 { isFeatureEnabled } from './feature-flags.js';
|
||||||
import { embedText, stripHtmlForEmbedding } from './embeddings.js';
|
import { embedText, stripHtmlForEmbedding } from './embeddings.js';
|
||||||
import { llm } from './llm.js';
|
import { llm } from './llm.js';
|
||||||
|
import { trackEvent } from './telemetry.js';
|
||||||
import type { NoteDoc } from '../modules/notes/types.js';
|
import type { NoteDoc } from '../modules/notes/types.js';
|
||||||
import { getCollection } from './datastore.js';
|
import { getCollection } from './datastore.js';
|
||||||
import type { FastifyBaseLogger } from 'fastify';
|
import type { FastifyBaseLogger } from 'fastify';
|
||||||
@ -131,6 +132,7 @@ async function backgroundAutoSummarize(
|
|||||||
updatedBy: 'system',
|
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');
|
log.info({ noteId: note.id, artifactId: artifact.id }, 'auto-summary generated');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.warn({ noteId: note.id, err }, 'background auto-summarize failed');
|
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);
|
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) ──────────────────────────
|
// ── Suggest related notes to link (F9) ──────────────────────────
|
||||||
|
|||||||
@ -243,6 +243,10 @@ export async function noteRoutes(app: RouteApp) {
|
|||||||
|
|
||||||
const created = await repo.createNote(doc);
|
const created = await repo.createNote(doc);
|
||||||
trackEvent('note.created', auth.sub, { noteId: created.id, workspaceId: created.workspaceId });
|
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);
|
reply.code(201);
|
||||||
return created;
|
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"),
|
handler: () => router.push("/reviews"),
|
||||||
description: "Go to reviews",
|
description: "Go to reviews",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "a",
|
||||||
|
meta: true,
|
||||||
|
shift: true,
|
||||||
|
handler: () => router.push("/prompts"),
|
||||||
|
description: "Open Smart Actions",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "Escape",
|
key: "Escape",
|
||||||
handler: () => {
|
handler: () => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user