diff --git a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt index 9b6e9d7..8d1d5de 100644 --- a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt +++ b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt @@ -1154,7 +1154,8 @@ class DesktopState(private val appDir: File = defaultAppDir()) { * endpoint as the upload path. [model] is sent for "transcribe" so a * model override switch persists on the server row. */ - fun reprocessInPlace(d0: DetailUi, job: String, model: String? = null) { + fun reprocessInPlace(d0: DetailUi, job: String, model: String? = null, + tone: String? = null) { val remoteId = d0.remoteId ?: return pollJob?.cancel() pollJob = scope.launch { @@ -1164,7 +1165,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) { d0.uploadProgress = null d0.error = null _detail.value = d0.copy() - runCatching { provider.reprocess(remoteId, job, model) }.onFailure { + runCatching { provider.reprocess(remoteId, job, model, tone) }.onFailure { if (job == "transcribe" && it is ProviderError.NotFound) { // Mapping points at a deleted recording: drop it so the // next attempt re-uploads instead of failing forever. @@ -1223,12 +1224,13 @@ class DesktopState(private val appDir: File = defaultAppDir()) { } /** "Summarize" button: regenerate the summary in place from the - * current transcript. Requires a known server recording. */ - fun summarize() { + * current transcript. Requires a known server recording. + * [tone] (e.g. "sarcastic") restyles the voice; null = neutral. */ + fun summarize(tone: String? = null) { val d = _detail.value ?: return val remoteId = d.remoteId ?: loadMapping(d.file)?.recordingId ?: return d.remoteId = remoteId - reprocessInPlace(d, job = "summarize") + reprocessInPlace(d, job = "summarize", tone = tone) } /** Server recording id for a library file, if it was ever uploaded. */ diff --git a/app/src/main/kotlin/com/shonar/desktop/Screens.kt b/app/src/main/kotlin/com/shonar/desktop/Screens.kt index 95b7dc1..47b88a0 100644 --- a/app/src/main/kotlin/com/shonar/desktop/Screens.kt +++ b/app/src/main/kotlin/com/shonar/desktop/Screens.kt @@ -28,6 +28,7 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator @@ -514,6 +515,16 @@ fun LibraryScreen(state: DesktopState) { // ---- detail ----------------------------------------------------------------- +/** Personalities offered beside Re-summarize: (label, tone sent to the + * engine). The tone string is interpolated into "in a tone of + * voice", so it must read naturally there. null = neutral house voice. */ +private val SUMMARIZE_PERSONAS = listOf( + "Neutral" to null, + "Sarcastic" to "sarcastic", + "Funny" to "funny", + "Dry wit" to "dry, witty", +) + @androidx.compose.runtime.Composable fun DetailScreen(state: DesktopState) { val detail = state.detail.collectAsState().value ?: run { @@ -525,6 +536,8 @@ fun DetailScreen(state: DesktopState) { var confirmDelete by remember { mutableStateOf(false) } var deleteError by remember { mutableStateOf(null) } var recQuery by remember { mutableStateOf("") } + // Voice for the next summarize: null = Neutral (server default). + var sumTone by remember(detail.file.name) { mutableStateOf(null) } val playingFile by state.playingFile.collectAsState() val playError by state.playError.collectAsState() val isPlaying = playingFile?.absolutePath == detail.file.absolutePath @@ -718,7 +731,16 @@ fun DetailScreen(state: DesktopState) { } detail.summary?.let { s -> if (s.short.isNotBlank()) { - Text("Summary", style = MaterialTheme.typography.titleMedium) + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Summary", style = MaterialTheme.typography.titleMedium) + // Honest tag: which voice this summary was written in. + s.tone?.let { + Spacer(Modifier.width(8.dp)) + Text("· in a $it voice", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary) + } + } Text(s.short) } } @@ -748,13 +770,32 @@ fun DetailScreen(state: DesktopState) { val hasSummary = detail.summary != null || (report?.contains("## Summary") == true) val remoteKnown = state.remoteIdFor(detail.file) != null + // Personality for the next summary: pick a voice, then hit + // Re-summarize. Neutral = the plain house voice. + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Summarize in the voice of", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SUMMARIZE_PERSONAS.forEach { (label, tone) -> + FilterChip( + selected = sumTone == tone, + onClick = { sumTone = tone }, + label = { Text(label) }, + ) + } + } + } Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { TextButton({ state.transcribe() }) { Text(if (hasTranscript) "Re-transcribe" else "Transcribe") } - TextButton({ state.summarize() }, + TextButton({ state.summarize(sumTone) }, enabled = remoteKnown && hasTranscript) { - Text(if (hasSummary) "Re-summarize" else "Summarize") + Text(buildString { + append(if (hasSummary) "Re-summarize" else "Summarize") + sumTone?.let { append(" · $it") } + }) } } if (!remoteKnown || !hasTranscript) { diff --git a/shared/com/shonar/provider/CustomShonarProvider.kt b/shared/com/shonar/provider/CustomShonarProvider.kt index 05a7500..19a94a5 100644 --- a/shared/com/shonar/provider/CustomShonarProvider.kt +++ b/shared/com/shonar/provider/CustomShonarProvider.kt @@ -311,11 +311,16 @@ class CustomShonarProvider( * per-recording override). 409 = stage already running or nothing to * summarize; surfaced as ProviderError.Transient with the server's * detail message. */ - suspend fun reprocess(recordingId: String, job: String, model: String? = null): String = + suspend fun reprocess(recordingId: String, job: String, model: String? = null, + tone: String? = null): String = withContext(Dispatchers.IO) { ensureConnected() val base = currentOrigin() - val q = if (model != null) "&model=$model" else "" + val q = buildString { + if (model != null) append("&model=$model") + if (tone != null) + append("&tone=").append(java.net.URLEncoder.encode(tone, "UTF-8")) + } executeAuthed(base) { token -> post(base, "/api/v1/recordings/$recordingId/reprocess?job=$job$q", token, "{}") }.use { resp -> diff --git a/shared/com/shonar/recording/AiContent.kt b/shared/com/shonar/recording/AiContent.kt index 4f60ff7..0e92cea 100644 --- a/shared/com/shonar/recording/AiContent.kt +++ b/shared/com/shonar/recording/AiContent.kt @@ -37,6 +37,8 @@ data class SummaryData( val version: Int, val provider: String = "", val model: String? = null, + /** Voice the summary was written in ("sarcastic", …); null = neutral. */ + val tone: String? = null, /** Raw content dict; known keys read via [summaryString]/[summaryList]. */ val content: Map = emptyMap(), val editedByUser: Boolean = false, @@ -112,6 +114,7 @@ fun parseSummary(raw: String): SummaryData? = runCatching { version = json.optInt("version", 0), provider = json.optString("provider", ""), model = json.optString("model", null).takeUnless { it.isNullOrBlank() }, + tone = json.optString("tone", null).takeUnless { it.isNullOrBlank() }, content = content, editedByUser = json.optBoolean("edited_by_user", false), )