feat(chat): wire sidebar to real conversations + persist history
Sidebar now lists the user's conversations from GET /api/v1/conversations (date-grouped, pinned first, active highlighted) instead of mock data. Sending creates a server conversation on the first message, persists each exchange via POST .../messages, and auto-titles new conversations. Tapping a chat loads its stored messages; new-chat/logout reset the active conversation. Linear model (no branching); locked chats are skipped (no in-app unlock yet).
This commit is contained in:
parent
d2f269f33a
commit
ea5e625f0f
4 changed files with 273 additions and 55 deletions
|
|
@ -18,6 +18,9 @@ data class Message(
|
||||||
val content: String,
|
val content: String,
|
||||||
val streaming: Boolean = false,
|
val streaming: Boolean = false,
|
||||||
val thinking: Boolean = false,
|
val thinking: Boolean = false,
|
||||||
|
// Stable server-side id (UUID) used for persistence + parentId chaining. Generated
|
||||||
|
// on creation; overwritten with the DB id when loading an existing conversation.
|
||||||
|
val wireId: String = java.util.UUID.randomUUID().toString(),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Empty-state suggestion pills (mirrors the web hero)
|
// Empty-state suggestion pills (mirrors the web hero)
|
||||||
|
|
|
||||||
|
|
@ -51,8 +51,10 @@ import androidx.compose.ui.unit.dp
|
||||||
import dev.kaizen.app.haptics.rememberHaptics
|
import dev.kaizen.app.haptics.rememberHaptics
|
||||||
import dev.kaizen.app.net.ChatHttpException
|
import dev.kaizen.app.net.ChatHttpException
|
||||||
import dev.kaizen.app.net.ChatSpeed
|
import dev.kaizen.app.net.ChatSpeed
|
||||||
|
import dev.kaizen.app.net.ConversationSummary
|
||||||
import dev.kaizen.app.net.KaizenApi
|
import dev.kaizen.app.net.KaizenApi
|
||||||
import dev.kaizen.app.net.KaizenModel
|
import dev.kaizen.app.net.KaizenModel
|
||||||
|
import dev.kaizen.app.net.SaveMessage
|
||||||
import dev.kaizen.app.net.SessionViewModel
|
import dev.kaizen.app.net.SessionViewModel
|
||||||
import dev.kaizen.app.net.WireMessage
|
import dev.kaizen.app.net.WireMessage
|
||||||
import dev.kaizen.app.settings.SettingsScreen
|
import dev.kaizen.app.settings.SettingsScreen
|
||||||
|
|
@ -93,9 +95,47 @@ fun ChatScreen(
|
||||||
// Model picker: catalog (best-effort fetch) + sheet visibility
|
// Model picker: catalog (best-effort fetch) + sheet visibility
|
||||||
var models by remember { mutableStateOf<List<KaizenModel>>(emptyList()) }
|
var models by remember { mutableStateOf<List<KaizenModel>>(emptyList()) }
|
||||||
var showModelSheet by remember { mutableStateOf(false) }
|
var showModelSheet by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Conversation persistence: the active server conversation + the sidebar list
|
||||||
|
var conversationId by remember { mutableStateOf<String?>(null) }
|
||||||
|
var conversations by remember { mutableStateOf<List<ConversationSummary>>(emptyList()) }
|
||||||
|
|
||||||
LaunchedEffect(session.config?.baseUrl, session.config?.token) {
|
LaunchedEffect(session.config?.baseUrl, session.config?.token) {
|
||||||
val cfg = session.config ?: return@LaunchedEffect
|
val cfg = session.config ?: return@LaunchedEffect
|
||||||
models = KaizenApi.fetchModels(cfg.baseUrl, cfg.token)
|
models = KaizenApi.fetchModels(cfg.baseUrl, cfg.token)
|
||||||
|
conversations = KaizenApi.fetchConversations(cfg.baseUrl, cfg.token)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshConversations() {
|
||||||
|
val cfg = session.config ?: return
|
||||||
|
scope.launch { conversations = KaizenApi.fetchConversations(cfg.baseUrl, cfg.token) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start a fresh chat — drops the active conversation, the next send creates a new one. */
|
||||||
|
fun newChat() {
|
||||||
|
messages.clear()
|
||||||
|
conversationId = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load a stored conversation into the linear message list. Locked ones are skipped (no in-app unlock yet). */
|
||||||
|
fun openConversation(summary: ConversationSummary) {
|
||||||
|
if (summary.locked) return
|
||||||
|
val cfg = session.config ?: return
|
||||||
|
scope.launch {
|
||||||
|
val stored = KaizenApi.fetchMessages(cfg.baseUrl, cfg.token, summary.id)
|
||||||
|
messages.clear()
|
||||||
|
stored.forEach { m ->
|
||||||
|
messages.add(
|
||||||
|
Message(
|
||||||
|
id = nextId++,
|
||||||
|
role = if (m.role == "assistant") Role.Assistant else Role.User,
|
||||||
|
content = m.content,
|
||||||
|
wireId = m.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
conversationId = summary.id
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun send(text: String) {
|
fun send(text: String) {
|
||||||
|
|
@ -103,11 +143,16 @@ fun ChatScreen(
|
||||||
if (trimmed.isEmpty() || isStreaming) return
|
if (trimmed.isEmpty() || isStreaming) return
|
||||||
val cfg = session.config ?: return
|
val cfg = session.config ?: return
|
||||||
haptics.click()
|
haptics.click()
|
||||||
messages.add(Message(nextId++, Role.User, trimmed))
|
// parentId chains the linear history: the user msg hangs off the last message.
|
||||||
|
val userParentId = messages.lastOrNull()?.wireId
|
||||||
|
val userMsg = Message(nextId++, Role.User, trimmed)
|
||||||
|
messages.add(userMsg)
|
||||||
input = ""
|
input = ""
|
||||||
val assistantId = nextId++
|
val assistantId = nextId++
|
||||||
messages.add(Message(assistantId, Role.Assistant, "", streaming = true, thinking = true))
|
val assistantWireId = java.util.UUID.randomUUID().toString()
|
||||||
|
messages.add(Message(assistantId, Role.Assistant, "", streaming = true, thinking = true, wireId = assistantWireId))
|
||||||
isStreaming = true
|
isStreaming = true
|
||||||
|
val wasNewConversation = conversationId == null
|
||||||
|
|
||||||
// Wire history = the visible conversation minus the streaming placeholder.
|
// Wire history = the visible conversation minus the streaming placeholder.
|
||||||
val history = messages
|
val history = messages
|
||||||
|
|
@ -118,6 +163,11 @@ fun ChatScreen(
|
||||||
haptics.thinkingStart()
|
haptics.thinkingStart()
|
||||||
var sawContent = false
|
var sawContent = false
|
||||||
try {
|
try {
|
||||||
|
// Ensure a server conversation exists before the exchange is persisted.
|
||||||
|
val convId = conversationId ?: KaizenApi.createConversation(cfg.baseUrl, cfg.token)?.also {
|
||||||
|
conversationId = it
|
||||||
|
}
|
||||||
|
|
||||||
KaizenApi.chat(cfg.baseUrl, cfg.token, cfg.model, history, cfg.speed).collect { visible ->
|
KaizenApi.chat(cfg.baseUrl, cfg.token, cfg.model, history, cfg.speed).collect { visible ->
|
||||||
val idx = messages.indexOfLast { it.id == assistantId }
|
val idx = messages.indexOfLast { it.id == assistantId }
|
||||||
if (idx < 0) return@collect
|
if (idx < 0) return@collect
|
||||||
|
|
@ -131,7 +181,21 @@ fun ChatScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val idx = messages.indexOfLast { it.id == assistantId }
|
val idx = messages.indexOfLast { it.id == assistantId }
|
||||||
|
val finalContent = if (idx >= 0) messages[idx].content else ""
|
||||||
if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false, thinking = false)
|
if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false, thinking = false)
|
||||||
|
|
||||||
|
// Persist the exchange (best-effort); auto-title + sidebar refresh on a new conversation.
|
||||||
|
if (convId != null && finalContent.isNotEmpty()) {
|
||||||
|
val saved = KaizenApi.saveMessages(
|
||||||
|
cfg.baseUrl, cfg.token, convId,
|
||||||
|
listOf(
|
||||||
|
SaveMessage(id = userMsg.wireId, parentId = userParentId, role = "user", content = trimmed),
|
||||||
|
SaveMessage(id = assistantWireId, parentId = userMsg.wireId, role = "assistant", content = finalContent, model = cfg.model),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (saved && wasNewConversation) KaizenApi.generateTitle(cfg.baseUrl, cfg.token, convId)
|
||||||
|
if (saved) refreshConversations()
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
val idx = messages.indexOfLast { it.id == assistantId }
|
val idx = messages.indexOfLast { it.id == assistantId }
|
||||||
if (idx >= 0) messages[idx] = messages[idx].copy(
|
if (idx >= 0) messages[idx] = messages[idx].copy(
|
||||||
|
|
@ -168,10 +232,17 @@ fun ChatScreen(
|
||||||
drawerContent = {
|
drawerContent = {
|
||||||
KaizenSidebar(
|
KaizenSidebar(
|
||||||
userName = userName,
|
userName = userName,
|
||||||
|
conversations = conversations,
|
||||||
|
currentId = conversationId,
|
||||||
onClose = { scope.launch { drawerState.close() } },
|
onClose = { scope.launch { drawerState.close() } },
|
||||||
onNewChat = {
|
onNewChat = {
|
||||||
haptics.tick()
|
haptics.tick()
|
||||||
messages.clear()
|
newChat()
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
},
|
||||||
|
onSelectConversation = { summary ->
|
||||||
|
haptics.tick()
|
||||||
|
openConversation(summary)
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
},
|
},
|
||||||
onOpenSettings = {
|
onOpenSettings = {
|
||||||
|
|
@ -181,7 +252,7 @@ fun ChatScreen(
|
||||||
},
|
},
|
||||||
onLogout = {
|
onLogout = {
|
||||||
haptics.tick()
|
haptics.tick()
|
||||||
messages.clear()
|
newChat()
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
session.logout()
|
session.logout()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,35 +46,35 @@ import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import dev.kaizen.app.net.ConversationSummary
|
||||||
import dev.kaizen.app.ui.theme.Amber
|
import dev.kaizen.app.ui.theme.Amber
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
// Mock sidebar history item representing a past conversation
|
/** Date-bucket header for a conversation, from its ISO `updatedAt` (Heute/Gestern/dd. MMM yyyy). */
|
||||||
data class ConvoHistoryItem(
|
private fun dateLabel(updatedAt: String?): String {
|
||||||
val id: String,
|
val instant = runCatching { Instant.parse(updatedAt) }.getOrNull() ?: return "FRÜHER"
|
||||||
val title: String,
|
val date = instant.atZone(ZoneId.systemDefault()).toLocalDate()
|
||||||
val dateGroup: String,
|
val today = LocalDate.now()
|
||||||
val locked: Boolean = false,
|
return when (date) {
|
||||||
val pinned: Boolean = false
|
today -> "HEUTE"
|
||||||
)
|
today.minusDays(1) -> "GESTERN"
|
||||||
|
else -> date.format(DateTimeFormatter.ofPattern("dd. MMM yyyy", Locale.GERMAN)).uppercase()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Authentically mirrored mock history from sidebar.png
|
/** Pinned first, then chronological date buckets — preserves the API's desc-updatedAt order. */
|
||||||
val mockConvoHistory = listOf(
|
private fun groupConversations(conversations: List<ConversationSummary>): Map<String, List<ConversationSummary>> {
|
||||||
ConvoHistoryItem("1", "Planetenübersicht P...", "16. JUNI 2026"),
|
val groups = LinkedHashMap<String, MutableList<ConversationSummary>>()
|
||||||
ConvoHistoryItem("2", "Orbitaltheorie in der ...", "16. JUNI 2026", locked = true),
|
conversations.filter { it.pinned }.takeIf { it.isNotEmpty() }?.let { groups["★ ANGEHEFTET"] = it.toMutableList() }
|
||||||
ConvoHistoryItem("3", "Wetter in Magdeburg", "16. JUNI 2026"),
|
conversations.filterNot { it.pinned }.forEach {
|
||||||
ConvoHistoryItem("4", "Aktuelle Uhrzeit in To...", "16. JUNI 2026"),
|
groups.getOrPut(dateLabel(it.updatedAt)) { mutableListOf() }.add(it)
|
||||||
|
}
|
||||||
ConvoHistoryItem("5", "📷 Aussehen und Styl...", "15. JUNI 2026"),
|
return groups
|
||||||
ConvoHistoryItem("6", "👤 Objektive Bewertu...", "15. JUNI 2026"),
|
}
|
||||||
|
|
||||||
ConvoHistoryItem("7", "Beziehungen und ihre...", "14. JUNI 2026"),
|
|
||||||
|
|
||||||
ConvoHistoryItem("8", "Online-Test erfolgreic...", "12. JUNI 2026"),
|
|
||||||
|
|
||||||
ConvoHistoryItem("9", "Hallo Gemini", "11. JUNI 2026"),
|
|
||||||
|
|
||||||
ConvoHistoryItem("10", "Gesperrter Chat", "10. JUNI 2026", locked = true)
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Floating glassmorphic sidebar drawer content.
|
* Floating glassmorphic sidebar drawer content.
|
||||||
|
|
@ -85,8 +85,11 @@ val mockConvoHistory = listOf(
|
||||||
@Composable
|
@Composable
|
||||||
fun KaizenSidebar(
|
fun KaizenSidebar(
|
||||||
userName: String,
|
userName: String,
|
||||||
|
conversations: List<ConversationSummary>,
|
||||||
|
currentId: String?,
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
onNewChat: () -> Unit,
|
onNewChat: () -> Unit,
|
||||||
|
onSelectConversation: (ConversationSummary) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
|
|
@ -212,27 +215,39 @@ fun KaizenSidebar(
|
||||||
|
|
||||||
// --- BODY Sektion: Scrollable Chats list grouped by Date ---
|
// --- BODY Sektion: Scrollable Chats list grouped by Date ---
|
||||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||||
val groupedHistory = remember { mockConvoHistory.groupBy { it.dateGroup } }
|
if (conversations.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "Noch keine Chats",
|
||||||
|
color = cs.onSurfaceVariant.copy(alpha = 0.5f),
|
||||||
|
fontSize = 14.sp,
|
||||||
|
modifier = Modifier.padding(start = 12.dp, top = 12.dp)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val grouped = remember(conversations) { groupConversations(conversations) }
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
groupedHistory.forEach { (dateGroup, items) ->
|
grouped.forEach { (dateGroup, items) ->
|
||||||
// Date header (e.g., 16. JUNI 2026)
|
// Date header (e.g., HEUTE, 16. JUN 2026)
|
||||||
item(key = "header_$dateGroup") {
|
item(key = "header_$dateGroup") {
|
||||||
Text(
|
Text(
|
||||||
text = dateGroup,
|
text = dateGroup,
|
||||||
color = cs.onSurfaceVariant.copy(alpha = 0.45f),
|
color = cs.onSurfaceVariant.copy(alpha = 0.45f),
|
||||||
fontSize = 11.sp,
|
fontSize = 11.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
modifier = Modifier.padding(start = 12.dp, top = 8.dp, bottom = 4.dp)
|
modifier = Modifier.padding(start = 12.dp, top = 8.dp, bottom = 4.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// History items
|
items(items, key = { it.id }) { item ->
|
||||||
items(items, key = { it.id }) { item ->
|
HistoryItemRow(
|
||||||
HistoryItemRow(item, onClick = { onClose() })
|
item = item,
|
||||||
|
selected = item.id == currentId,
|
||||||
|
onClick = { onSelectConversation(item) },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -321,12 +336,20 @@ fun KaizenSidebar(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun HistoryItemRow(item: ConvoHistoryItem, onClick: () -> Unit) {
|
private fun HistoryItemRow(item: ConversationSummary, selected: Boolean, onClick: () -> Unit) {
|
||||||
val cs = MaterialTheme.colorScheme
|
val cs = MaterialTheme.colorScheme
|
||||||
val isDark = isSystemInDarkTheme()
|
val isDark = isSystemInDarkTheme()
|
||||||
|
|
||||||
val itemBg = if (isDark) Color(0x0AFFFFFF) else Color(0x05000000)
|
val itemBg = when {
|
||||||
val itemBorder = if (isDark) Color.White.copy(alpha = 0.04f) else Color.Black.copy(alpha = 0.03f)
|
selected -> Amber.copy(alpha = 0.14f)
|
||||||
|
isDark -> Color(0x0AFFFFFF)
|
||||||
|
else -> Color(0x05000000)
|
||||||
|
}
|
||||||
|
val itemBorder = when {
|
||||||
|
selected -> Amber.copy(alpha = 0.35f)
|
||||||
|
isDark -> Color.White.copy(alpha = 0.04f)
|
||||||
|
else -> Color.Black.copy(alpha = 0.03f)
|
||||||
|
}
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,43 @@ data class KaizenModel(
|
||||||
|
|
||||||
@Serializable private data class ModelsResponse(val models: List<KaizenModel> = emptyList())
|
@Serializable private data class ModelsResponse(val models: List<KaizenModel> = emptyList())
|
||||||
|
|
||||||
|
/** A conversation as shown in the sidebar list. Extra fields from the API are ignored. */
|
||||||
|
@Serializable
|
||||||
|
data class ConversationSummary(
|
||||||
|
val id: String,
|
||||||
|
val title: String = "",
|
||||||
|
val locked: Boolean = false,
|
||||||
|
val pinned: Boolean = false,
|
||||||
|
val updatedAt: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable private data class ConversationsResponse(val conversations: List<ConversationSummary> = emptyList())
|
||||||
|
@Serializable private data class CreateConvoRequest(val title: String? = null)
|
||||||
|
@Serializable private data class CreatedConversation(val id: String)
|
||||||
|
|
||||||
|
/** One persisted message from GET /api/v1/conversations/[id]. */
|
||||||
|
@Serializable
|
||||||
|
data class StoredMessage(
|
||||||
|
val id: String,
|
||||||
|
val role: String,
|
||||||
|
val content: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable private data class ConversationDetail(val messages: List<StoredMessage> = emptyList())
|
||||||
|
|
||||||
|
/** A message to persist. `parentId` chains the linear history; `kind` is always "chat" for the app. */
|
||||||
|
@Serializable
|
||||||
|
data class SaveMessage(
|
||||||
|
val id: String,
|
||||||
|
val parentId: String?,
|
||||||
|
val role: String,
|
||||||
|
val content: String,
|
||||||
|
val kind: String = "chat",
|
||||||
|
val model: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable private data class SaveMessagesRequest(val messages: List<SaveMessage>)
|
||||||
|
|
||||||
/** Outcome of an in-app login attempt — surfaced as distinct UI states on the login screen. */
|
/** Outcome of an in-app login attempt — surfaced as distinct UI states on the login screen. */
|
||||||
sealed interface LoginResult {
|
sealed interface LoginResult {
|
||||||
data class Success(val token: String) : LoginResult
|
data class Success(val token: String) : LoginResult
|
||||||
|
|
@ -122,6 +159,90 @@ object KaizenApi {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** GET /api/v1/conversations — the sidebar list (best-effort, empty on any failure). */
|
||||||
|
suspend fun fetchConversations(baseUrl: String, token: String): List<ConversationSummary> =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val req = Request.Builder()
|
||||||
|
.url("$baseUrl/api/v1/conversations")
|
||||||
|
.header("Authorization", "Bearer $token")
|
||||||
|
.build()
|
||||||
|
try {
|
||||||
|
client.newCall(req).execute().use { resp ->
|
||||||
|
if (!resp.isSuccessful) return@use emptyList()
|
||||||
|
json.decodeFromString<ConversationsResponse>(resp.body!!.string()).conversations
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/v1/conversations — create one, returns its id (null on failure). */
|
||||||
|
suspend fun createConversation(baseUrl: String, token: String, title: String? = null): String? =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val body = json.encodeToString(CreateConvoRequest(title)).toRequestBody(jsonMedia)
|
||||||
|
val req = Request.Builder()
|
||||||
|
.url("$baseUrl/api/v1/conversations")
|
||||||
|
.post(body)
|
||||||
|
.header("Authorization", "Bearer $token")
|
||||||
|
.build()
|
||||||
|
try {
|
||||||
|
client.newCall(req).execute().use { resp ->
|
||||||
|
if (!resp.isSuccessful) return@use null
|
||||||
|
json.decodeFromString<CreatedConversation>(resp.body!!.string()).id
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/v1/conversations/[id] — the stored messages, in order (empty on failure/locked). */
|
||||||
|
suspend fun fetchMessages(baseUrl: String, token: String, id: String): List<StoredMessage> =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val req = Request.Builder()
|
||||||
|
.url("$baseUrl/api/v1/conversations/$id")
|
||||||
|
.header("Authorization", "Bearer $token")
|
||||||
|
.build()
|
||||||
|
try {
|
||||||
|
client.newCall(req).execute().use { resp ->
|
||||||
|
if (!resp.isSuccessful) return@use emptyList()
|
||||||
|
json.decodeFromString<ConversationDetail>(resp.body!!.string()).messages
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/v1/conversations/[id]/messages — persist an exchange. Returns success. */
|
||||||
|
suspend fun saveMessages(baseUrl: String, token: String, id: String, messages: List<SaveMessage>): Boolean =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val body = json.encodeToString(SaveMessagesRequest(messages)).toRequestBody(jsonMedia)
|
||||||
|
val req = Request.Builder()
|
||||||
|
.url("$baseUrl/api/v1/conversations/$id/messages")
|
||||||
|
.post(body)
|
||||||
|
.header("Authorization", "Bearer $token")
|
||||||
|
.build()
|
||||||
|
try {
|
||||||
|
client.newCall(req).execute().use { resp -> resp.isSuccessful }
|
||||||
|
} catch (e: IOException) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/v1/conversations/[id]/title — fire-and-forget auto-title after the first exchange. */
|
||||||
|
suspend fun generateTitle(baseUrl: String, token: String, id: String) =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val req = Request.Builder()
|
||||||
|
.url("$baseUrl/api/v1/conversations/$id/title")
|
||||||
|
.post("{}".toRequestBody(jsonMedia))
|
||||||
|
.header("Authorization", "Bearer $token")
|
||||||
|
.build()
|
||||||
|
try {
|
||||||
|
client.newCall(req).execute().use { }
|
||||||
|
} catch (e: IOException) {
|
||||||
|
// best-effort: a missing title is cosmetic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/v1/chat — streams the assistant reply. Emits the cumulative *visible*
|
* POST /api/v1/chat — streams the assistant reply. Emits the cumulative *visible*
|
||||||
* text after each network chunk (sentinels stripped), so the caller just assigns
|
* text after each network chunk (sentinels stripped), so the caller just assigns
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue