fix(talk): align sessions and chat UI
This commit is contained in:
@@ -70,7 +70,7 @@ class NodeRuntime(context: Context) {
|
||||
payloadJson =
|
||||
buildJsonObject {
|
||||
put("message", JsonPrimitive(command))
|
||||
put("sessionKey", JsonPrimitive("main"))
|
||||
put("sessionKey", JsonPrimitive(mainSessionKey.value))
|
||||
put("thinking", JsonPrimitive(chatThinkingLevel.value))
|
||||
put("deliver", JsonPrimitive(false))
|
||||
}.toString(),
|
||||
@@ -104,6 +104,9 @@ class NodeRuntime(context: Context) {
|
||||
private val _statusText = MutableStateFlow("Offline")
|
||||
val statusText: StateFlow<String> = _statusText.asStateFlow()
|
||||
|
||||
private val _mainSessionKey = MutableStateFlow("main")
|
||||
val mainSessionKey: StateFlow<String> = _mainSessionKey.asStateFlow()
|
||||
|
||||
private val cameraHudSeq = AtomicLong(0)
|
||||
private val _cameraHud = MutableStateFlow<CameraHudState?>(null)
|
||||
val cameraHud: StateFlow<CameraHudState?> = _cameraHud.asStateFlow()
|
||||
@@ -161,6 +164,7 @@ class NodeRuntime(context: Context) {
|
||||
_remoteAddress.value = null
|
||||
_isConnected.value = false
|
||||
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
||||
_mainSessionKey.value = "main"
|
||||
chat.onDisconnected(message)
|
||||
showLocalCanvasOnDisconnect()
|
||||
}
|
||||
@@ -632,8 +636,12 @@ class NodeRuntime(context: Context) {
|
||||
val config = root?.get("config").asObjectOrNull()
|
||||
val ui = config?.get("ui").asObjectOrNull()
|
||||
val raw = ui?.get("seamColor").asStringOrNull()?.trim()
|
||||
val parsed = parseHexColorArgb(raw) ?: return
|
||||
_seamColorArgb.value = parsed
|
||||
val sessionCfg = config?.get("session").asObjectOrNull()
|
||||
val rawMainKey = sessionCfg?.get("mainKey").asStringOrNull()?.trim()
|
||||
_mainSessionKey.value = rawMainKey?.takeIf { it.isNotEmpty() } ?: "main"
|
||||
|
||||
val parsed = parseHexColorArgb(raw)
|
||||
_seamColorArgb.value = parsed ?: DEFAULT_SEAM_COLOR_ARGB
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
@@ -80,18 +80,19 @@ class TalkModeManager(
|
||||
private var interruptOnSpeech: Boolean = true
|
||||
private var voiceOverrideActive = false
|
||||
private var modelOverrideActive = false
|
||||
private var mainSessionKey: String = "main"
|
||||
|
||||
private var session: BridgeSession? = null
|
||||
private var pendingRunId: String? = null
|
||||
private var pendingFinal: CompletableDeferred<Boolean>? = null
|
||||
private var chatSubscribed = false
|
||||
private var chatSubscribedSessionKey: String? = null
|
||||
|
||||
private var player: MediaPlayer? = null
|
||||
private var currentAudioFile: File? = null
|
||||
|
||||
fun attachSession(session: BridgeSession) {
|
||||
this.session = session
|
||||
chatSubscribed = false
|
||||
chatSubscribedSessionKey = null
|
||||
}
|
||||
|
||||
fun setEnabled(enabled: Boolean) {
|
||||
@@ -173,7 +174,7 @@ class TalkModeManager(
|
||||
_isListening.value = false
|
||||
_statusText.value = "Off"
|
||||
stopSpeaking()
|
||||
chatSubscribed = false
|
||||
chatSubscribedSessionKey = null
|
||||
|
||||
mainHandler.post {
|
||||
recognizer?.cancel()
|
||||
@@ -281,18 +282,15 @@ class TalkModeManager(
|
||||
|
||||
try {
|
||||
val startedAt = System.currentTimeMillis().toDouble() / 1000.0
|
||||
subscribeChatIfNeeded(bridge = bridge, sessionKey = "main")
|
||||
Log.d(tag, "chat.send start chars=${prompt.length}")
|
||||
subscribeChatIfNeeded(bridge = bridge, sessionKey = mainSessionKey)
|
||||
Log.d(tag, "chat.send start sessionKey=${mainSessionKey.ifBlank { "main" }} chars=${prompt.length}")
|
||||
val runId = sendChat(prompt, bridge)
|
||||
Log.d(tag, "chat.send ok runId=$runId")
|
||||
val ok = waitForChatFinal(runId)
|
||||
if (!ok) {
|
||||
_statusText.value = "No reply"
|
||||
Log.w(tag, "chat final timeout runId=$runId")
|
||||
start()
|
||||
return
|
||||
Log.w(tag, "chat final timeout runId=$runId; attempting history fallback")
|
||||
}
|
||||
val assistant = waitForAssistantText(bridge, startedAt, 12_000)
|
||||
val assistant = waitForAssistantText(bridge, startedAt, if (ok) 12_000 else 25_000)
|
||||
if (assistant.isNullOrBlank()) {
|
||||
_statusText.value = "No reply"
|
||||
Log.w(tag, "assistant text timeout runId=$runId")
|
||||
@@ -312,12 +310,12 @@ class TalkModeManager(
|
||||
}
|
||||
|
||||
private suspend fun subscribeChatIfNeeded(bridge: BridgeSession, sessionKey: String) {
|
||||
if (chatSubscribed) return
|
||||
val key = sessionKey.trim()
|
||||
if (key.isEmpty()) return
|
||||
if (chatSubscribedSessionKey == key) return
|
||||
try {
|
||||
bridge.sendEvent("chat.subscribe", """{"sessionKey":"$key"}""")
|
||||
chatSubscribed = true
|
||||
chatSubscribedSessionKey = key
|
||||
Log.d(tag, "chat.subscribe ok sessionKey=$key")
|
||||
} catch (err: Throwable) {
|
||||
Log.w(tag, "chat.subscribe failed sessionKey=$key err=${err.message ?: err::class.java.simpleName}")
|
||||
@@ -342,7 +340,7 @@ class TalkModeManager(
|
||||
val runId = UUID.randomUUID().toString()
|
||||
val params =
|
||||
buildJsonObject {
|
||||
put("sessionKey", JsonPrimitive("main"))
|
||||
put("sessionKey", JsonPrimitive(mainSessionKey.ifBlank { "main" }))
|
||||
put("message", JsonPrimitive(message))
|
||||
put("thinking", JsonPrimitive("low"))
|
||||
put("timeoutMs", JsonPrimitive(30_000))
|
||||
@@ -396,7 +394,8 @@ class TalkModeManager(
|
||||
bridge: BridgeSession,
|
||||
sinceSeconds: Double? = null,
|
||||
): String? {
|
||||
val res = bridge.request("chat.history", "{\"sessionKey\":\"main\"}")
|
||||
val key = mainSessionKey.ifBlank { "main" }
|
||||
val res = bridge.request("chat.history", "{\"sessionKey\":\"$key\"}")
|
||||
val root = json.parseToJsonElement(res).asObjectOrNull() ?: return null
|
||||
val messages = root["messages"] as? JsonArray ?: return null
|
||||
for (item in messages.reversed()) {
|
||||
@@ -404,7 +403,7 @@ class TalkModeManager(
|
||||
if (obj["role"].asStringOrNull() != "assistant") continue
|
||||
if (sinceSeconds != null) {
|
||||
val timestamp = obj["timestamp"].asDoubleOrNull()
|
||||
if (timestamp != null && timestamp < sinceSeconds - 0.5) continue
|
||||
if (timestamp != null && !TalkModeRuntime.isMessageTimestampAfter(timestamp, sinceSeconds)) continue
|
||||
}
|
||||
val content = obj["content"] as? JsonArray ?: continue
|
||||
val text =
|
||||
@@ -438,16 +437,15 @@ class TalkModeManager(
|
||||
}
|
||||
}
|
||||
|
||||
val apiKey =
|
||||
apiKey?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: System.getenv("ELEVENLABS_API_KEY")?.trim()
|
||||
val voiceId = directive?.voiceId ?: currentVoiceId ?: defaultVoiceId
|
||||
if (voiceId.isNullOrBlank()) {
|
||||
_statusText.value = "Missing voice ID"
|
||||
Log.w(tag, "missing voiceId")
|
||||
return
|
||||
}
|
||||
|
||||
val apiKey =
|
||||
apiKey?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: System.getenv("ELEVENLABS_API_KEY")?.trim()
|
||||
if (apiKey.isNullOrEmpty()) {
|
||||
_statusText.value = "Missing ELEVENLABS_API_KEY"
|
||||
Log.w(tag, "missing ELEVENLABS_API_KEY")
|
||||
@@ -465,7 +463,8 @@ class TalkModeManager(
|
||||
ElevenLabsRequest(
|
||||
text = cleaned,
|
||||
modelId = directive?.modelId ?: currentModelId ?: defaultModelId,
|
||||
outputFormat = directive?.outputFormat ?: defaultOutputFormat,
|
||||
outputFormat =
|
||||
TalkModeRuntime.validatedOutputFormat(directive?.outputFormat ?: defaultOutputFormat),
|
||||
speed = TalkModeRuntime.resolveSpeed(directive?.speed, directive?.rateWpm),
|
||||
stability = TalkModeRuntime.validatedUnit(directive?.stability),
|
||||
similarity = TalkModeRuntime.validatedUnit(directive?.similarity),
|
||||
@@ -564,12 +563,15 @@ class TalkModeManager(
|
||||
val root = json.parseToJsonElement(res).asObjectOrNull()
|
||||
val config = root?.get("config").asObjectOrNull()
|
||||
val talk = config?.get("talk").asObjectOrNull()
|
||||
val sessionCfg = config?.get("session").asObjectOrNull()
|
||||
val mainKey = sessionCfg?.get("mainKey").asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: "main"
|
||||
val voice = talk?.get("voiceId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val model = talk?.get("modelId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val outputFormat = talk?.get("outputFormat")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val key = talk?.get("apiKey")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val interrupt = talk?.get("interruptOnSpeech")?.asBooleanOrNull()
|
||||
|
||||
mainSessionKey = mainKey
|
||||
defaultVoiceId = voice ?: envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() }
|
||||
if (!voiceOverrideActive) currentVoiceId = defaultVoiceId
|
||||
defaultModelId = model
|
||||
@@ -593,6 +595,8 @@ class TalkModeManager(
|
||||
val url = URL("https://api.elevenlabs.io/v1/text-to-speech/$voiceId")
|
||||
val conn = url.openConnection() as HttpURLConnection
|
||||
conn.requestMethod = "POST"
|
||||
conn.connectTimeout = 30_000
|
||||
conn.readTimeout = 30_000
|
||||
conn.setRequestProperty("Content-Type", "application/json")
|
||||
conn.setRequestProperty("Accept", "audio/mpeg")
|
||||
conn.setRequestProperty("xi-api-key", apiKey)
|
||||
@@ -689,6 +693,21 @@ class TalkModeManager(
|
||||
if (!normalized.all { it in 'a'..'z' }) return null
|
||||
return normalized
|
||||
}
|
||||
|
||||
fun validatedOutputFormat(value: String?): String? {
|
||||
val trimmed = value?.trim()?.lowercase() ?: return null
|
||||
if (trimmed.isEmpty()) return null
|
||||
return if (trimmed.startsWith("mp3_")) trimmed else null
|
||||
}
|
||||
|
||||
fun isMessageTimestampAfter(timestamp: Double, sinceSeconds: Double): Boolean {
|
||||
val sinceMs = sinceSeconds * 1000
|
||||
return if (timestamp > 10_000_000_000) {
|
||||
timestamp >= sinceMs - 500
|
||||
} else {
|
||||
timestamp >= sinceSeconds - 0.5
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureInterruptListener() {
|
||||
|
||||
@@ -4,18 +4,20 @@ import SwiftUI
|
||||
struct ChatSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var viewModel: ClawdisChatViewModel
|
||||
private let userAccent: Color?
|
||||
|
||||
init(bridge: BridgeSession, sessionKey: String = "main") {
|
||||
init(bridge: BridgeSession, sessionKey: String = "main", userAccent: Color? = nil) {
|
||||
let transport = IOSBridgeChatTransport(bridge: bridge)
|
||||
self._viewModel = State(
|
||||
initialValue: ClawdisChatViewModel(
|
||||
sessionKey: sessionKey,
|
||||
transport: transport))
|
||||
self.userAccent = userAccent
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ClawdisChatView(viewModel: self.viewModel)
|
||||
ClawdisChatView(viewModel: self.viewModel, userAccent: self.userAccent)
|
||||
.navigationTitle("Chat")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
|
||||
@@ -23,6 +23,7 @@ final class NodeAppModel {
|
||||
var bridgeRemoteAddress: String?
|
||||
var connectedBridgeID: String?
|
||||
var seamColorHex: String?
|
||||
var mainSessionKey: String = "main"
|
||||
|
||||
private let bridge = BridgeSession()
|
||||
private var bridgeTask: Task<Void, Never>?
|
||||
@@ -42,7 +43,7 @@ final class NodeAppModel {
|
||||
init() {
|
||||
self.voiceWake.configure { [weak self] cmd in
|
||||
guard let self else { return }
|
||||
let sessionKey = "main"
|
||||
let sessionKey = self.mainSessionKey
|
||||
do {
|
||||
try await self.sendVoiceTranscript(text: cmd, sessionKey: sessionKey)
|
||||
} catch {
|
||||
@@ -267,6 +268,7 @@ final class NodeAppModel {
|
||||
self.bridgeRemoteAddress = nil
|
||||
self.connectedBridgeID = nil
|
||||
self.seamColorHex = nil
|
||||
self.mainSessionKey = "main"
|
||||
self.showLocalCanvasOnDisconnect()
|
||||
}
|
||||
}
|
||||
@@ -283,6 +285,7 @@ final class NodeAppModel {
|
||||
self.bridgeRemoteAddress = nil
|
||||
self.connectedBridgeID = nil
|
||||
self.seamColorHex = nil
|
||||
self.mainSessionKey = "main"
|
||||
self.showLocalCanvasOnDisconnect()
|
||||
}
|
||||
|
||||
@@ -310,8 +313,12 @@ final class NodeAppModel {
|
||||
guard let config = json["config"] as? [String: Any] else { return }
|
||||
let ui = config["ui"] as? [String: Any]
|
||||
let raw = (ui?["seamColor"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let session = config["session"] as? [String: Any]
|
||||
let rawMainKey = (session?["mainKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let mainKey = rawMainKey.isEmpty ? "main" : rawMainKey
|
||||
await MainActor.run {
|
||||
self.seamColorHex = raw.isEmpty ? nil : raw
|
||||
self.mainSessionKey = mainKey
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
|
||||
@@ -51,7 +51,10 @@ struct RootCanvas: View {
|
||||
case .settings:
|
||||
SettingsTab()
|
||||
case .chat:
|
||||
ChatSheet(bridge: self.appModel.bridgeSession)
|
||||
ChatSheet(
|
||||
bridge: self.appModel.bridgeSession,
|
||||
sessionKey: self.appModel.mainSessionKey,
|
||||
userAccent: self.appModel.seamColor)
|
||||
}
|
||||
}
|
||||
.onAppear { self.updateIdleTimer() }
|
||||
|
||||
@@ -34,6 +34,7 @@ final class TalkModeManager: NSObject {
|
||||
private var defaultOutputFormat: String?
|
||||
private var apiKey: String?
|
||||
private var interruptOnSpeech: Bool = true
|
||||
private var mainSessionKey: String = "main"
|
||||
|
||||
private var bridge: BridgeSession?
|
||||
private let silenceWindow: TimeInterval = 0.7
|
||||
@@ -84,7 +85,7 @@ final class TalkModeManager: NSObject {
|
||||
self.isListening = true
|
||||
self.statusText = "Listening"
|
||||
self.startSilenceMonitor()
|
||||
await self.subscribeChatIfNeeded(sessionKey: "main")
|
||||
await self.subscribeChatIfNeeded(sessionKey: self.mainSessionKey)
|
||||
self.logger.info("listening")
|
||||
} catch {
|
||||
self.isListening = false
|
||||
@@ -227,25 +228,22 @@ final class TalkModeManager: NSObject {
|
||||
|
||||
do {
|
||||
let startedAt = Date().timeIntervalSince1970
|
||||
await self.subscribeChatIfNeeded(sessionKey: "main")
|
||||
self.logger.info("chat.send start chars=\(prompt.count, privacy: .public)")
|
||||
let sessionKey = self.mainSessionKey
|
||||
await self.subscribeChatIfNeeded(sessionKey: sessionKey)
|
||||
self.logger.info("chat.send start sessionKey=\(sessionKey, privacy: .public) chars=\(prompt.count, privacy: .public)")
|
||||
let runId = try await self.sendChat(prompt, bridge: bridge)
|
||||
self.logger.info("chat.send ok runId=\(runId, privacy: .public)")
|
||||
let completion = await self.waitForChatCompletion(runId: runId, bridge: bridge, timeoutSeconds: 120)
|
||||
guard completion == .final else {
|
||||
switch completion {
|
||||
case .timeout:
|
||||
self.statusText = "No reply"
|
||||
self.logger.warning("chat completion timeout runId=\(runId, privacy: .public)")
|
||||
case .aborted:
|
||||
self.statusText = "Aborted"
|
||||
self.logger.warning("chat completion aborted runId=\(runId, privacy: .public)")
|
||||
case .error:
|
||||
self.statusText = "Chat error"
|
||||
self.logger.warning("chat completion error runId=\(runId, privacy: .public)")
|
||||
case .final:
|
||||
break
|
||||
}
|
||||
if completion == .timeout {
|
||||
self.logger.warning("chat completion timeout runId=\(runId, privacy: .public); attempting history fallback")
|
||||
} else if completion == .aborted {
|
||||
self.statusText = "Aborted"
|
||||
self.logger.warning("chat completion aborted runId=\(runId, privacy: .public)")
|
||||
await self.start()
|
||||
return
|
||||
} else if completion == .error {
|
||||
self.statusText = "Chat error"
|
||||
self.logger.warning("chat completion error runId=\(runId, privacy: .public)")
|
||||
await self.start()
|
||||
return
|
||||
}
|
||||
@@ -253,7 +251,7 @@ final class TalkModeManager: NSObject {
|
||||
guard let assistantText = try await self.waitForAssistantText(
|
||||
bridge: bridge,
|
||||
since: startedAt,
|
||||
timeoutSeconds: 12)
|
||||
timeoutSeconds: completion == .final ? 12 : 25)
|
||||
else {
|
||||
self.statusText = "No reply"
|
||||
self.logger.warning("assistant text timeout runId=\(runId, privacy: .public)")
|
||||
@@ -338,7 +336,7 @@ final class TalkModeManager: NSObject {
|
||||
private func sendChat(_ message: String, bridge: BridgeSession) async throws -> String {
|
||||
struct SendResponse: Decodable { let runId: String }
|
||||
let payload: [String: Any] = [
|
||||
"sessionKey": "main",
|
||||
"sessionKey": self.mainSessionKey,
|
||||
"message": message,
|
||||
"thinking": "low",
|
||||
"timeoutMs": 30000,
|
||||
@@ -404,13 +402,15 @@ final class TalkModeManager: NSObject {
|
||||
private func fetchLatestAssistantText(bridge: BridgeSession, since: Double? = nil) async throws -> String? {
|
||||
let res = try await bridge.request(
|
||||
method: "chat.history",
|
||||
paramsJSON: "{\"sessionKey\":\"main\"}",
|
||||
paramsJSON: "{\"sessionKey\":\"\(self.mainSessionKey)\"}",
|
||||
timeoutSeconds: 15)
|
||||
guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return nil }
|
||||
guard let messages = json["messages"] as? [[String: Any]] else { return nil }
|
||||
for msg in messages.reversed() {
|
||||
guard (msg["role"] as? String) == "assistant" else { continue }
|
||||
if let since, let timestamp = msg["timestamp"] as? Double, timestamp < since - 0.5 {
|
||||
if let since, let timestamp = msg["timestamp"] as? Double,
|
||||
TalkModeRuntime.isMessageTimestampAfter(timestamp, sinceSeconds: since) == false
|
||||
{
|
||||
continue
|
||||
}
|
||||
guard let content = msg["content"] as? [[String: Any]] else { continue }
|
||||
@@ -560,6 +560,9 @@ final class TalkModeManager: NSObject {
|
||||
guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return }
|
||||
guard let config = json["config"] as? [String: Any] else { return }
|
||||
let talk = config["talk"] as? [String: Any]
|
||||
let session = config["session"] as? [String: Any]
|
||||
let rawMainKey = (session?["mainKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
self.mainSessionKey = rawMainKey.isEmpty ? "main" : rawMainKey
|
||||
self.defaultVoiceId = (talk?["voiceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !self.voiceOverrideActive {
|
||||
self.currentVoiceId = self.defaultVoiceId
|
||||
@@ -721,4 +724,12 @@ private enum TalkModeRuntime {
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
return trimmed.hasPrefix("mp3_") ? trimmed : nil
|
||||
}
|
||||
|
||||
static func isMessageTimestampAfter(_ timestamp: Double, sinceSeconds: Double) -> Bool {
|
||||
let sinceMs = sinceSeconds * 1000
|
||||
if timestamp > 10_000_000_000 {
|
||||
return timestamp >= sinceMs - 500
|
||||
}
|
||||
return timestamp >= sinceSeconds - 0.5
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,7 +378,7 @@ actor TalkModeRuntime {
|
||||
guard message.role == "assistant" else { return false }
|
||||
guard let since else { return true }
|
||||
guard let timestamp = message.timestamp else { return false }
|
||||
return timestamp >= since - 0.5
|
||||
return Self.isMessageTimestampAfter(timestamp, sinceSeconds: since)
|
||||
}
|
||||
guard let assistant else { return nil }
|
||||
let text = assistant.content.compactMap { $0.text }.joined(separator: "\n")
|
||||
@@ -739,6 +739,14 @@ actor TalkModeRuntime {
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
private static func isMessageTimestampAfter(_ timestamp: Double, sinceSeconds: Double) -> Bool {
|
||||
let sinceMs = sinceSeconds * 1000
|
||||
if timestamp > 10_000_000_000 {
|
||||
return timestamp >= sinceMs - 500
|
||||
}
|
||||
return timestamp >= sinceSeconds - 0.5
|
||||
}
|
||||
}
|
||||
|
||||
private struct ElevenLabsRequest {
|
||||
|
||||
@@ -155,7 +155,8 @@ final class WebChatSwiftUIWindowController {
|
||||
self.sessionKey = sessionKey
|
||||
self.presentation = presentation
|
||||
let vm = ClawdisChatViewModel(sessionKey: sessionKey, transport: transport)
|
||||
self.hosting = NSHostingController(rootView: ClawdisChatView(viewModel: vm))
|
||||
let accent = Self.color(fromHex: AppStateStore.shared.seamColorHex)
|
||||
self.hosting = NSHostingController(rootView: ClawdisChatView(viewModel: vm, userAccent: accent))
|
||||
self.contentController = Self.makeContentController(for: presentation, hosting: self.hosting)
|
||||
self.window = Self.makeWindow(for: presentation, contentViewController: self.contentController)
|
||||
}
|
||||
@@ -355,4 +356,15 @@ final class WebChatSwiftUIWindowController {
|
||||
window.setFrame(frame, display: false)
|
||||
}
|
||||
}
|
||||
|
||||
private static func color(fromHex raw: String?) -> Color? {
|
||||
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed
|
||||
guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil }
|
||||
let r = Double((value >> 16) & 0xFF) / 255.0
|
||||
let g = Double((value >> 8) & 0xFF) / 255.0
|
||||
let b = Double(value & 0xFF) / 255.0
|
||||
return Color(red: r, green: g, blue: b)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,9 +137,10 @@ private struct ChatBubbleShape: InsettableShape {
|
||||
struct ChatMessageBubble: View {
|
||||
let message: ClawdisChatMessage
|
||||
let style: ClawdisChatView.Style
|
||||
let userAccent: Color?
|
||||
|
||||
var body: some View {
|
||||
ChatMessageBody(message: self.message, isUser: self.isUser, style: self.style)
|
||||
ChatMessageBody(message: self.message, isUser: self.isUser, style: self.style, userAccent: self.userAccent)
|
||||
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: self.isUser ? .trailing : .leading)
|
||||
.frame(maxWidth: .infinity, alignment: self.isUser ? .trailing : .leading)
|
||||
.padding(.horizontal, 2)
|
||||
@@ -153,6 +154,7 @@ private struct ChatMessageBody: View {
|
||||
let message: ClawdisChatMessage
|
||||
let isUser: Bool
|
||||
let style: ClawdisChatView.Style
|
||||
let userAccent: Color?
|
||||
|
||||
var body: some View {
|
||||
let text = self.primaryText
|
||||
@@ -287,7 +289,7 @@ private struct ChatMessageBody: View {
|
||||
|
||||
private var bubbleFillColor: Color {
|
||||
if self.isUser {
|
||||
return ClawdisChatTheme.userBubble
|
||||
return self.userAccent ?? ClawdisChatTheme.userBubble
|
||||
}
|
||||
if self.style == .onboarding {
|
||||
return ClawdisChatTheme.onboardingAssistantBubble
|
||||
|
||||
@@ -101,11 +101,7 @@ enum ClawdisChatTheme {
|
||||
}
|
||||
|
||||
static var userBubble: Color {
|
||||
#if os(macOS)
|
||||
Color(nsColor: .systemBlue)
|
||||
#else
|
||||
Color(uiColor: .systemBlue)
|
||||
#endif
|
||||
Color(red: 127 / 255.0, green: 184 / 255.0, blue: 212 / 255.0)
|
||||
}
|
||||
|
||||
static var assistantBubble: Color {
|
||||
|
||||
@@ -13,6 +13,7 @@ public struct ClawdisChatView: View {
|
||||
@State private var hasPerformedInitialScroll = false
|
||||
private let showsSessionSwitcher: Bool
|
||||
private let style: Style
|
||||
private let userAccent: Color?
|
||||
|
||||
private enum Layout {
|
||||
#if os(macOS)
|
||||
@@ -37,11 +38,13 @@ public struct ClawdisChatView: View {
|
||||
public init(
|
||||
viewModel: ClawdisChatViewModel,
|
||||
showsSessionSwitcher: Bool = false,
|
||||
style: Style = .standard)
|
||||
style: Style = .standard,
|
||||
userAccent: Color? = nil)
|
||||
{
|
||||
self._viewModel = State(initialValue: viewModel)
|
||||
self.showsSessionSwitcher = showsSessionSwitcher
|
||||
self.style = style
|
||||
self.userAccent = userAccent
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
@@ -74,7 +77,7 @@ public struct ClawdisChatView: View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: Layout.messageSpacing) {
|
||||
ForEach(self.visibleMessages) { msg in
|
||||
ChatMessageBubble(message: msg, style: self.style)
|
||||
ChatMessageBubble(message: msg, style: self.style, userAccent: self.userAccent)
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
alignment: msg.role.lowercased() == "user" ? .trailing : .leading)
|
||||
|
||||
@@ -150,9 +150,36 @@ public final class ClawdisChatViewModel {
|
||||
}
|
||||
|
||||
private static func decodeMessages(_ raw: [AnyCodable]) -> [ClawdisChatMessage] {
|
||||
raw.compactMap { item in
|
||||
let decoded = raw.compactMap { item in
|
||||
(try? ChatPayloadDecoding.decode(item, as: ClawdisChatMessage.self))
|
||||
}
|
||||
return Self.dedupeMessages(decoded)
|
||||
}
|
||||
|
||||
private static func dedupeMessages(_ messages: [ClawdisChatMessage]) -> [ClawdisChatMessage] {
|
||||
var result: [ClawdisChatMessage] = []
|
||||
result.reserveCapacity(messages.count)
|
||||
var seen = Set<String>()
|
||||
|
||||
for message in messages {
|
||||
guard let key = Self.dedupeKey(for: message) else {
|
||||
result.append(message)
|
||||
continue
|
||||
}
|
||||
if seen.contains(key) { continue }
|
||||
seen.insert(key)
|
||||
result.append(message)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private static func dedupeKey(for message: ClawdisChatMessage) -> String? {
|
||||
guard let timestamp = message.timestamp else { return nil }
|
||||
let text = message.content.compactMap { $0.text }.joined(separator: "\n")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return nil }
|
||||
return "\(message.role)|\(timestamp)|\(text)"
|
||||
}
|
||||
|
||||
private func performSend() async {
|
||||
|
||||
@@ -117,6 +117,29 @@ private extension TestChatTransportState {
|
||||
}
|
||||
|
||||
@Suite struct ChatViewModelTests {
|
||||
@Test func dedupesDuplicateHistoryMessages() async throws {
|
||||
let ts = Date().timeIntervalSince1970 * 1000
|
||||
let duplicate = AnyCodable([
|
||||
"role": "assistant",
|
||||
"content": [["type": "text", "text": "Same message"]],
|
||||
"timestamp": ts,
|
||||
])
|
||||
let history = ClawdisChatHistoryPayload(
|
||||
sessionKey: "main",
|
||||
sessionId: "sess-main",
|
||||
messages: [duplicate, duplicate],
|
||||
thinkingLevel: "off")
|
||||
|
||||
let transport = TestChatTransport(historyResponses: [history])
|
||||
let vm = await MainActor.run { ClawdisChatViewModel(sessionKey: "main", transport: transport) }
|
||||
|
||||
await MainActor.run { vm.load() }
|
||||
try await waitUntil("bootstrap") { await MainActor.run { !vm.messages.isEmpty } }
|
||||
|
||||
#expect(await MainActor.run { vm.messages.count } == 1)
|
||||
#expect(await MainActor.run { vm.messages.first?.role } == "assistant")
|
||||
}
|
||||
|
||||
@Test func streamsAssistantAndClearsOnFinal() async throws {
|
||||
let sessionId = "sess-main"
|
||||
let history1 = ClawdisChatHistoryPayload(
|
||||
|
||||
Reference in New Issue
Block a user