From f672fc9b12795007bcd3bae08cb547905e2dffff Mon Sep 17 00:00:00 2001 From: Hai Phuc Nguyen <3423575+haiphucnguyen@users.noreply.github.com> Date: Mon, 17 Aug 2026 00:46:39 -0700 Subject: [PATCH] feat(chat): Enhance message synchronization logic and improve internationalization - Implements pre-syncing for messages created via the Askimo Team proxy context. - This allows messages to be marked as 'synced' immediately upon local insertion, preventing redundant sync pushes. - Updates local message repository to accept an optional `syncedAt` parameter for insertion. - Improves user experience with better, localized feedback forms (e.g., distinguishing between "unhappy" and "neutral" feedback prompts). - Updates the desktop application's sentiment tracking to improve accuracy. - Increases localization coverage across multiple languages. --- .../ConversationSyncRepositoryIT.kt | 34 ++++++ .../io/askimo/ui/session/SessionManager.kt | 101 +++++++++++------- .../io/askimo/ui/shell/StarPromptDialog.kt | 68 ++++++++++-- .../main/resources/i18n/messages.properties | 11 +- .../resources/i18n/messages_de.properties | 11 +- .../resources/i18n/messages_es.properties | 11 +- .../resources/i18n/messages_fr.properties | 11 +- .../resources/i18n/messages_ja_JP.properties | 11 +- .../resources/i18n/messages_ko_KR.properties | 11 +- .../resources/i18n/messages_pt_BR.properties | 11 +- .../resources/i18n/messages_vi_VN.properties | 11 +- .../resources/i18n/messages_zh_CN.properties | 11 +- .../resources/i18n/messages_zh_TW.properties | 11 +- .../src/main/kotlin/io/askimo/desktop/Main.kt | 10 +- .../chat/repository/ChatMessageRepository.kt | 13 ++- .../core/chat/service/ChatSessionService.kt | 36 +++++-- .../askimo/core/providers/ProxyChatContext.kt | 53 +++++++++ 17 files changed, 361 insertions(+), 64 deletions(-) create mode 100644 shared/src/main/kotlin/io/askimo/core/providers/ProxyChatContext.kt diff --git a/cli/src/test/kotlin/io/askimo/core/chat/repository/ConversationSyncRepositoryIT.kt b/cli/src/test/kotlin/io/askimo/core/chat/repository/ConversationSyncRepositoryIT.kt index 48a978452..eb11d9d3c 100644 --- a/cli/src/test/kotlin/io/askimo/core/chat/repository/ConversationSyncRepositoryIT.kt +++ b/cli/src/test/kotlin/io/askimo/core/chat/repository/ConversationSyncRepositoryIT.kt @@ -309,6 +309,40 @@ class ConversationSyncRepositoryIT { assertFalse(unsynced.any { it.id == msg.id }) } + @Test + fun `message inserted with syncedAt is excluded from push queue`() { + // This is the proxy-path invariant: messages saved via the Askimo Team proxy + // are pre-marked synced at INSERT time so the sync push never re-uploads them. + val session = createSession() + val syncTime = Instant.now() + + val msg = messageRepository.addMessage( + ChatMessage(id = "", sessionId = session.id, role = MessageRole.USER, content = "Proxy user message"), + syncedAt = syncTime, + ) + + assertFalse( + messageRepository.getUnsyncedMessages(session.id).any { it.id == msg.id }, + "A message inserted with syncedAt must not appear in the unsynced push queue", + ) + } + + @Test + fun `message inserted without syncedAt still appears in push queue`() { + // Non-proxy messages (failed, local-only, non-ASKIMO_PRO) must still be pushed. + val session = createSession() + + val msg = messageRepository.addMessage( + ChatMessage(id = "", sessionId = session.id, role = MessageRole.ASSISTANT, content = "Regular message"), + syncedAt = null, + ) + + assertTrue( + messageRepository.getUnsyncedMessages(session.id).any { it.id == msg.id }, + "A message inserted without syncedAt must appear in the unsynced push queue", + ) + } + @Test fun `respects limit parameter`() { val session = createSession() diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/session/SessionManager.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/session/SessionManager.kt index 5fc87c626..48c94725f 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/session/SessionManager.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/session/SessionManager.kt @@ -21,6 +21,8 @@ import io.askimo.core.exception.ContextLengthException import io.askimo.core.exception.ExceptionHandler import io.askimo.core.logging.logger import io.askimo.core.providers.ConfigurationErrorException +import io.askimo.core.providers.ModelProvider +import io.askimo.core.providers.ProxyChatContext import io.askimo.core.providers.isContextLengthError import io.askimo.core.providers.sendStreamingMessageWithCallback import io.askimo.core.vision.ImageProcessor @@ -41,6 +43,7 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import java.time.Instant +import java.util.UUID import java.util.concurrent.ConcurrentHashMap /** @@ -295,47 +298,73 @@ class SessionManager( var capturedTotalTokens: Int? = null var capturedDurationMs: Long? = null - val fullResponse = chatSessionService - .getOrCreateClientForSession(sessionId) - .sendStreamingMessageWithCallback( - projectId = projectId, - userContents = promptWithContext, - enabledServerIds = enabledServerIds, - onToken = { token -> - streamingScope.launch { - thread.appendChunk(token) - } - }, - onFollowUpSuggestion = { suggestion -> - log.debug("Follow-up suggestion for session $sessionId: ${suggestion.question}") - }, - onTokenUsage = { input, output, total, durationMs -> - capturedInputTokens = input - capturedOutputTokens = output - capturedTotalTokens = total - capturedDurationMs = durationMs - log.debug("Token usage for session $sessionId: input=$input, output=$output, total=$total, duration=${durationMs}ms") - }, - onToolStarted = { toolName, arguments -> - streamingScope.launch { - thread.markToolRunning(toolName, arguments) - } - }, - onToolFinished = { toolName, arguments, result, hasFailed -> - streamingScope.launch { - thread.markToolDone(toolName, arguments, result, hasFailed) - } - }, - onThinkingToken = { token -> - streamingScope.launch { - thread.appendThinkingChunk(token) - } - }, + // Pre-generate the assistant message ID and set the ProxyChatContext + // ThreadLocal on this thread so the correlating HTTP client can inject + // the tracking headers into the outgoing proxy request. + // The ThreadLocal is safe here because sendStreamingMessageWithCallback is + // a blocking call that stays on this thread until the stream is complete. + val needsMessageCorrelation = AppContext.getInstance().getActiveProvider() == ModelProvider.ASKIMO_PRO + val assistantMessageId = if (needsMessageCorrelation) UUID.randomUUID().toString() else "" + if (needsMessageCorrelation) { + ProxyChatContext.set( + ProxyChatContext.Context( + sessionId = sessionId, + userMessageId = userMessage.id!!, + assistantMessageId = assistantMessageId, + ), ) + } + + val fullResponse = try { + chatSessionService + .getOrCreateClientForSession(sessionId) + .sendStreamingMessageWithCallback( + projectId = projectId, + userContents = promptWithContext, + enabledServerIds = enabledServerIds, + onToken = { token -> + streamingScope.launch { + thread.appendChunk(token) + } + }, + onFollowUpSuggestion = { suggestion -> + log.debug("Follow-up suggestion for session $sessionId: ${suggestion.question}") + }, + onTokenUsage = { input, output, total, durationMs -> + capturedInputTokens = input + capturedOutputTokens = output + capturedTotalTokens = total + capturedDurationMs = durationMs + log.debug("Token usage for session $sessionId: input=$input, output=$output, total=$total, duration=${durationMs}ms") + }, + onToolStarted = { toolName, arguments -> + streamingScope.launch { + thread.markToolRunning(toolName, arguments) + } + }, + onToolFinished = { toolName, arguments, result, hasFailed -> + streamingScope.launch { + thread.markToolDone(toolName, arguments, result, hasFailed) + } + }, + onThinkingToken = { token -> + streamingScope.launch { + thread.appendThinkingChunk(token) + } + }, + ) + } finally { + // Always clear the ThreadLocal — prevents leaking context into + // subsequent requests on the same thread from the pool. + if (needsMessageCorrelation) ProxyChatContext.clear() + } val savedMessage = chatSessionService.saveAiResponse( sessionId = sessionId, response = fullResponse, + // Pass the pre-generated ID so the server and local DB agree on + // the same message identity, and saveAiResponse can mark it synced. + messageId = assistantMessageId, inputTokens = capturedInputTokens, outputTokens = capturedOutputTokens, totalTokens = capturedTotalTokens, diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/shell/StarPromptDialog.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/shell/StarPromptDialog.kt index 502d46c32..6ea2ca076 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/shell/StarPromptDialog.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/shell/StarPromptDialog.kt @@ -71,8 +71,12 @@ enum class FeedbackReason(val emoji: String, val i18nKey: String) { * * Tracks sentiment via [Analytics.track] — respects the user's analytics opt-in. * - Happy → [onHappy] caller shows the star/share prompt - * - Neutral → [onNeutral] caller dismisses - * - Unhappy → [onUnhappy] caller opens the contact/feedback page in the browser + * - Neutral → [onNeutral] caller shows [feedbackPromptDialog] + * `USER_SENTIMENT_NEUTRAL` is NOT fired here — deferred to [feedbackPromptDialog] + * and only fired when the user submits with a comment. + * - Unhappy → [onUnhappy] caller shows [feedbackPromptDialog] in unhappy path mode. + * `USER_SENTIMENT_UNHAPPY` is NOT fired here — deferred to [feedbackPromptDialog] + * and only fired when the user submits with a comment. */ @Composable fun happinessGateDialog( @@ -124,14 +128,12 @@ fun happinessGateDialog( sentimentButton( label = stringResource("happiness.gate.neutral"), onClick = { - Analytics.track(AnalyticsEvent.USER_SENTIMENT_NEUTRAL) onNeutral() }, ) sentimentButton( label = stringResource("happiness.gate.unhappy"), onClick = { - Analytics.track(AnalyticsEvent.USER_SENTIMENT_UNHAPPY) onUnhappy() }, ) @@ -186,7 +188,12 @@ fun feedbackPromptDialog( onClose: () -> Unit, onSnooze: () -> Unit, showReminderOnSkip: Boolean = true, + /** `"unhappy"`, `"neutral"`, or `null` (menu-opened). Controls copy and analytics gating. */ + pathSentiment: String? = null, ) { + val isUnhappyPath = pathSentiment == "unhappy" + val isNeutralPath = pathSentiment == "neutral" + val isWeakSentimentPath = isUnhappyPath || isNeutralPath var selectedReasons by remember { mutableStateOf(emptySet()) } var comment by remember { mutableStateOf("") } var email by remember { mutableStateOf("") } @@ -261,12 +268,24 @@ fun feedbackPromptDialog( verticalArrangement = Arrangement.spacedBy(Spacing.small), ) { Text( - text = stringResource("feedback.dialog.title"), + text = stringResource( + when { + isUnhappyPath -> "feedback.unhappy.dialog.title" + isNeutralPath -> "feedback.neutral.dialog.title" + else -> "feedback.dialog.title" + }, + ), style = AppTextStyles.sectionTitle, textAlign = TextAlign.Center, ) Text( - text = stringResource("feedback.dialog.subtitle"), + text = stringResource( + when { + isUnhappyPath -> "feedback.unhappy.dialog.subtitle" + isNeutralPath -> "feedback.neutral.dialog.subtitle" + else -> "feedback.dialog.subtitle" + }, + ), style = AppTextStyles.bodySecondary, textAlign = TextAlign.Center, ) @@ -299,24 +318,47 @@ fun feedbackPromptDialog( } } } - // ── Optional comment (required when MISSING_FEATURE or OTHER is selected) ──── + // ── Comment field — encouraged on weak-sentiment paths, optional otherwise ── OutlinedTextField( value = comment, onValueChange = { comment = it }, modifier = Modifier.fillMaxWidth(), label = { Text( - text = stringResource("feedback.comment.label"), + text = stringResource( + when { + isUnhappyPath -> "feedback.comment.label.unhappy" + isNeutralPath -> "feedback.comment.label.neutral" + else -> "feedback.comment.label" + }, + ), style = AppTextStyles.caption, ) }, placeholder = { Text( - text = stringResource("feedback.comment.placeholder"), + text = stringResource( + when { + isUnhappyPath -> "feedback.comment.placeholder.unhappy" + isNeutralPath -> "feedback.comment.placeholder.neutral" + else -> "feedback.comment.placeholder" + }, + ), style = AppTextStyles.caption, ) }, - minLines = 3, + supportingText = if (isWeakSentimentPath) { + { + Text( + text = stringResource("feedback.comment.encourage.hint"), + style = AppTextStyles.caption, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + null + }, + minLines = if (isWeakSentimentPath) 4 else 3, maxLines = 5, shape = MaterialTheme.shapes.medium, ) @@ -357,6 +399,12 @@ fun feedbackPromptDialog( } Button( onClick = { + if (comment.isNotBlank()) { + when { + isUnhappyPath -> Analytics.track(AnalyticsEvent.USER_SENTIMENT_UNHAPPY) + isNeutralPath -> Analytics.track(AnalyticsEvent.USER_SENTIMENT_NEUTRAL) + } + } onSubmit(selectedReasons, comment.trim(), email.trim()) submitted = true }, diff --git a/desktop-shared/src/main/resources/i18n/messages.properties b/desktop-shared/src/main/resources/i18n/messages.properties index f5e540f28..81c238af5 100644 --- a/desktop-shared/src/main/resources/i18n/messages.properties +++ b/desktop-shared/src/main/resources/i18n/messages.properties @@ -525,7 +525,7 @@ action.refresh=Refresh # Happiness Gate (shown before Star Prompt) happiness.gate.title=How are you finding Askimo? -happiness.gate.subtitle=Let us know how we're doing +happiness.gate.subtitle=Your honest take helps us keep improving happiness.gate.happy=Yes, I'm loving it! 😍 happiness.gate.neutral=It's okay 🙂 happiness.gate.unhappy=Not really 😕 @@ -535,6 +535,15 @@ feedback.dialog.title=What's not working for you? feedback.dialog.subtitle=Select all that apply — your input goes straight to the team. feedback.comment.label=Tell us more feedback.comment.placeholder=Describe the issue or share your thoughts… +feedback.unhappy.dialog.title=We'd love to know what's not working +feedback.unhappy.dialog.subtitle=Tell us about your experience with Askimo so we can improve it. +feedback.comment.label.unhappy=What's not working for you? +feedback.comment.placeholder.unhappy=e.g. The session history is hard to find, responses feel slow… +feedback.neutral.dialog.title=What would make Askimo better for you? +feedback.neutral.dialog.subtitle=Your ideas help us figure out what to build next. +feedback.comment.label.neutral=What could we improve? +feedback.comment.placeholder.neutral=e.g. I wish it had better keyboard shortcuts, faster model switching… +feedback.comment.encourage.hint=A few words make a big difference — your feedback goes straight to the team. feedback.email.label=Your email (optional) feedback.email.placeholder=you@example.com feedback.email.hint=Only used to follow up on your feedback. We'll never spam you. diff --git a/desktop-shared/src/main/resources/i18n/messages_de.properties b/desktop-shared/src/main/resources/i18n/messages_de.properties index a090b2f43..2f6dea619 100644 --- a/desktop-shared/src/main/resources/i18n/messages_de.properties +++ b/desktop-shared/src/main/resources/i18n/messages_de.properties @@ -525,7 +525,7 @@ action.refresh=Aktualisieren # Happiness Gate (shown before Star Prompt) happiness.gate.title=Wie gefällt Ihnen Askimo? -happiness.gate.subtitle=Sagen Sie uns, wie wir uns machen +happiness.gate.subtitle=Ihr ehrliches Feedback hilft uns, uns stetig zu verbessern happiness.gate.happy=Ich liebe es! 😍 happiness.gate.neutral=Es geht 🙂 happiness.gate.unhappy=Nicht wirklich 😕 @@ -535,6 +535,15 @@ feedback.dialog.title=Was funktioniert für dich nicht? feedback.dialog.subtitle=Wähle alle zutreffenden Punkte aus — dein Feedback geht direkt an das Team. feedback.comment.label=Erzähl uns mehr feedback.comment.placeholder=Beschreibe das Problem oder teile uns deine Gedanken mit… +feedback.unhappy.dialog.title=Wir würden gerne wissen, was nicht funktioniert +feedback.unhappy.dialog.subtitle=Erzähl uns von deiner Erfahrung mit Askimo, damit wir es verbessern können. +feedback.comment.label.unhappy=Was funktioniert für dich nicht? +feedback.comment.placeholder.unhappy=z.B. Der Sitzungsverlauf ist schwer zu finden, Antworten fühlen sich langsam an… +feedback.neutral.dialog.title=Was würde Askimo für dich besser machen? +feedback.neutral.dialog.subtitle=Deine Ideen helfen uns zu entscheiden, was wir als Nächstes bauen. +feedback.comment.label.neutral=Was könnten wir verbessern? +feedback.comment.placeholder.neutral=z.B. Ich wünschte mir bessere Tastaturkürzel, schnellere Modellwechsel… +feedback.comment.encourage.hint=Ein paar Worte machen einen großen Unterschied — dein Feedback geht direkt an das Team. feedback.email.label=Deine E-Mail (optional) feedback.email.placeholder=you@example.com feedback.email.hint=Wird nur verwendet, um bei deinem Feedback nachzufassen. Wir werden dich niemals zuspammen. diff --git a/desktop-shared/src/main/resources/i18n/messages_es.properties b/desktop-shared/src/main/resources/i18n/messages_es.properties index 5b6c91c87..72d4ee679 100644 --- a/desktop-shared/src/main/resources/i18n/messages_es.properties +++ b/desktop-shared/src/main/resources/i18n/messages_es.properties @@ -525,7 +525,7 @@ action.refresh=Actualizar # Happiness Gate (shown before Star Prompt) happiness.gate.title=¿Cómo te está yendo con Askimo? -happiness.gate.subtitle=Cuéntanos cómo nos estamos desenvolviendo +happiness.gate.subtitle=Tu opinión sincera nos ayuda a seguir mejorando happiness.gate.happy=¡Me encanta! 😍 happiness.gate.neutral=Está bien 🙂 happiness.gate.unhappy=No mucho 😕 @@ -535,6 +535,15 @@ feedback.dialog.title=¿Qué no te está funcionando? feedback.dialog.subtitle=Selecciona todo lo que aplique — tu opinión llegará directamente al equipo. feedback.comment.label=Cuéntanos más feedback.comment.placeholder=Describe el problema o comparte tus pensamientos… +feedback.unhappy.dialog.title=Nos encantaría saber qué no está funcionando +feedback.unhappy.dialog.subtitle=Cuéntanos tu experiencia con Askimo para que podamos mejorarla. +feedback.comment.label.unhappy=¿Qué no está funcionando para ti? +feedback.comment.placeholder.unhappy=ej. El historial de sesiones es difícil de encontrar, las respuestas son lentas… +feedback.neutral.dialog.title=¿Qué haría que Askimo fuera mejor para ti? +feedback.neutral.dialog.subtitle=Tus ideas nos ayudan a decidir qué construir a continuación. +feedback.comment.label.neutral=¿Qué podríamos mejorar? +feedback.comment.placeholder.neutral=ej. Quisiera mejores atajos de teclado, cambio de modelo más rápido… +feedback.comment.encourage.hint=Unas pocas palabras marcan una gran diferencia — tu opinión va directamente al equipo. feedback.email.label=Tu correo electrónico (opcional) feedback.email.placeholder=you@example.com feedback.email.hint=Solo se usará para hacer seguimiento de tu feedback. Nunca te enviaremos spam. diff --git a/desktop-shared/src/main/resources/i18n/messages_fr.properties b/desktop-shared/src/main/resources/i18n/messages_fr.properties index e6bc2f88a..2646913b4 100644 --- a/desktop-shared/src/main/resources/i18n/messages_fr.properties +++ b/desktop-shared/src/main/resources/i18n/messages_fr.properties @@ -525,7 +525,7 @@ action.refresh=Rafraîchir # Happiness Gate (shown before Star Prompt) happiness.gate.title=Comment trouvez-vous Askimo ? -happiness.gate.subtitle=Dites-nous comment nous nous en sortons +happiness.gate.subtitle=Votre avis honnête nous aide à continuer à nous améliorer happiness.gate.happy=Je l'adore ! 😍 happiness.gate.neutral=Ça va 🙂 happiness.gate.unhappy=Pas vraiment 😕 @@ -535,6 +535,15 @@ feedback.dialog.title=Qu'est-ce qui ne vous convient pas ? feedback.dialog.subtitle=Sélectionnez toutes les options correspondantes — votre retour sera envoyé directement à l'équipe. feedback.comment.label=Dites-nous en plus feedback.comment.placeholder=Décrivez le problème ou partagez vos réflexions… +feedback.unhappy.dialog.title=Nous adorerions savoir ce qui ne fonctionne pas +feedback.unhappy.dialog.subtitle=Parlez-nous de votre expérience avec Askimo pour que nous puissions l'améliorer. +feedback.comment.label.unhappy=Qu'est-ce qui ne fonctionne pas pour vous ? +feedback.comment.placeholder.unhappy=ex. : L'historique de session est difficile à trouver, les réponses semblent lentes… +feedback.neutral.dialog.title=Qu'est-ce qui rendrait Askimo meilleur pour vous ? +feedback.neutral.dialog.subtitle=Vos idées nous aident à déterminer ce que nous devons créer ensuite. +feedback.comment.label.neutral=Qu'est-ce que nous pourrions améliorer ? +feedback.comment.placeholder.neutral=ex. : Je voudrais de meilleurs raccourcis clavier, une commutation de modèle plus rapide… +feedback.comment.encourage.hint=Quelques mots font une grande différence — votre feedback va directement à l'équipe. feedback.email.label=Votre e-mail (facultatif) feedback.email.placeholder=you@example.com feedback.email.hint=Utilisé uniquement pour assurer le suivi de votre retour. Nous ne vous enverrons jamais de spam. diff --git a/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties b/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties index faa8ef02d..4b5d13894 100644 --- a/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties +++ b/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties @@ -525,7 +525,7 @@ action.refresh=更新 # Happiness Gate (shown before Star Prompt) happiness.gate.title=Askimoはいかがですか? -happiness.gate.subtitle=ご意見をお聞かせください +happiness.gate.subtitle=率直なご意見が、継続的な改善に役立ちます happiness.gate.happy=とても気に入っています!😍 happiness.gate.neutral=まあまあです 🙂 happiness.gate.unhappy=あまりよくないです 😕 @@ -535,6 +535,15 @@ feedback.dialog.title=何かお困りですか? feedback.dialog.subtitle=当てはまるものをすべて選択してください。ご意見は直接チームに届きます。 feedback.comment.label=詳細 feedback.comment.placeholder=問題の説明やご意見をお聞かせください… +feedback.unhappy.dialog.title=何が問題なのか、ぜひお聞かせください +feedback.unhappy.dialog.subtitle=Askimoに関するご経験をお聞かせください。改善に役立てます。 +feedback.comment.label.unhappy=何がうまくいっていませんか? +feedback.comment.placeholder.unhappy=例:セッション履歴が見つけにくい、返答が遅いなど… +feedback.neutral.dialog.title=Askimoをより良くするためには? +feedback.neutral.dialog.subtitle=皆さんのアイデアが、次に何を作るかを決めるのに役立ちます。 +feedback.comment.label.neutral=改善できることは何ですか? +feedback.comment.placeholder.neutral=例:より良いキーボードショートカット、より速いモデル切り替えがあればいいのに… +feedback.comment.encourage.hint=数語でも大きな違いを生みます — フィードバックはチームに直接届きます。 feedback.email.label=メールアドレス(任意) feedback.email.placeholder=you@example.com feedback.email.hint=フィードバックのフォローアップのみに使用します。スパムメールを送ることはありません。 diff --git a/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties b/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties index d219c6b8f..fb00e44a7 100644 --- a/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties +++ b/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties @@ -525,7 +525,7 @@ action.refresh=새로고침 # Happiness Gate (shown before Star Prompt) happiness.gate.title=Askimo는 어떠신가요? -happiness.gate.subtitle=어떻게 사용하고 계신지 알려주세요 +happiness.gate.subtitle=솔직한 의견이 저희의 지속적인 개선에 도움이 됩니다 happiness.gate.happy=정말 마음에 들어요! 😍 happiness.gate.neutral=괜찮아요 🙂 happiness.gate.unhappy=별로예요 😕 @@ -535,6 +535,15 @@ feedback.dialog.title=어떤 점이 불편하신가요? feedback.dialog.subtitle=해당하는 모든 항목을 선택해주세요. 귀하의 의견은 팀에 직접 전달됩니다. feedback.comment.label=상세 내용 feedback.comment.placeholder=문제에 대한 설명이나 의견을 공유해주세요… +feedback.unhappy.dialog.title=무엇이 문제인지 알고 싶습니다 +feedback.unhappy.dialog.subtitle=Askimo에 대한 경험을 알려주시면 개선하겠습니다. +feedback.comment.label.unhappy=어떤 점이 불편하셨나요? +feedback.comment.placeholder.unhappy=예: 세션 기록을 찾기 어렵거나, 응답이 느린 것 같습니다… +feedback.neutral.dialog.title=Askimo가 어떻게 개선되면 더 좋을까요? +feedback.neutral.dialog.subtitle=여러분의 아이디어가 다음 개발 방향을 결정하는 데 도움이 됩니다. +feedback.comment.label.neutral=어떤 점을 개선할 수 있을까요? +feedback.comment.placeholder.neutral=예: 더 나은 단축키나 더 빠른 모델 전환이 있으면 좋겠습니다… +feedback.comment.encourage.hint=몇 마디만으로도 큰 차이를 만들 수 있습니다 — 여러분의 피드백은 팀에게 직접 전달됩니다. feedback.email.label=이메일 (선택 사항) feedback.email.placeholder=you@example.com feedback.email.hint=피드백에 대한 후속 조치를 위해서만 사용됩니다. 스팸 메일은 절대 보내지 않습니다. diff --git a/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties b/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties index b49241ddb..4b2a72f05 100644 --- a/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties +++ b/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties @@ -525,7 +525,7 @@ action.refresh=Atualizar # Happiness Gate (shown before Star Prompt) happiness.gate.title=Como você está achando o Askimo? -happiness.gate.subtitle=Nos diga como estamos indo +happiness.gate.subtitle=Sua opinião honesta nos ajuda a continuar melhorando happiness.gate.happy=Estou adorando! 😍 happiness.gate.neutral=Está ok 🙂 happiness.gate.unhappy=Não muito 😕 @@ -535,6 +535,15 @@ feedback.dialog.title=O que não está a funcionar para si? feedback.dialog.subtitle=Selecione tudo o que se aplicar — o seu feedback vai direto para a equipa. feedback.comment.label=Conte-nos mais feedback.comment.placeholder=Descreva o problema ou partilhe os seus pensamentos… +feedback.unhappy.dialog.title=Adoraríamos saber o que não está funcionando +feedback.unhappy.dialog.subtitle=Conte-nos sobre sua experiência com o Askimo para que possamos melhorá-lo. +feedback.comment.label.unhappy=O que não está funcionando para você? +feedback.comment.placeholder.unhappy=ex. O histórico de sessões é difícil de encontrar, as respostas parecem lentas… +feedback.neutral.dialog.title=O que tornaria o Askimo melhor para você? +feedback.neutral.dialog.subtitle=Suas ideias nos ajudam a decidir o que construir a seguir. +feedback.comment.label.neutral=O que poderíamos melhorar? +feedback.comment.placeholder.neutral=ex. Gostaria de melhores atalhos de teclado, troca de modelo mais rápida… +feedback.comment.encourage.hint=Algumas palavras fazem uma grande diferença — seu feedback vai direto para a equipe. feedback.email.label=O seu e-mail (opcional) feedback.email.placeholder=you@example.com feedback.email.hint=Usado apenas para acompanhar o seu feedback. Nunca enviaremos spam. diff --git a/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties b/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties index 194067a3f..e72f15b4f 100644 --- a/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties +++ b/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties @@ -525,7 +525,7 @@ action.refresh=Tải lại # Happiness Gate (shown before Star Prompt) happiness.gate.title=Bạn thấy Askimo thế nào? -happiness.gate.subtitle=Cho chúng tôi biết trải nghiệm của bạn +happiness.gate.subtitle=Ý kiến thành thật của bạn giúp chúng tôi tiếp tục cải thiện happiness.gate.happy=Tôi rất thích! 😍 happiness.gate.neutral=Ổn 🙂 happiness.gate.unhappy=Chưa thực sự ưng 😕 @@ -535,6 +535,15 @@ feedback.dialog.title=Điều gì đang làm bạn chưa hài lòng? feedback.dialog.subtitle=Chọn tất cả những mục phù hợp — phản hồi của bạn sẽ được gửi trực tiếp đến nhóm của chúng tôi. feedback.comment.label=Cho chúng tôi biết thêm feedback.comment.placeholder=Mô tả vấn đề hoặc chia sẻ suy nghĩ của bạn… +feedback.unhappy.dialog.title=Chúng tôi muốn biết điều gì đang không hoạt động +feedback.unhappy.dialog.subtitle=Hãy cho chúng tôi biết trải nghiệm của bạn với Askimo để chúng tôi cải thiện. +feedback.comment.label.unhappy=Điều gì không hoạt động với bạn? +feedback.comment.placeholder.unhappy=ví dụ: Lịch sử phiên khó tìm, phản hồi cảm thấy chậm… +feedback.neutral.dialog.title=Điều gì sẽ làm Askimo tốt hơn cho bạn? +feedback.neutral.dialog.subtitle=Ý tưởng của bạn giúp chúng tôi quyết định xây dựng gì tiếp theo. +feedback.comment.label.neutral=Chúng tôi có thể cải thiện điều gì? +feedback.comment.placeholder.neutral=ví dụ: Tôi ước có phím tắt tốt hơn, chuyển đổi mô hình nhanh hơn… +feedback.comment.encourage.hint=Vài từ là đủ để tạo ra sự khác biệt — phản hồi của bạn sẽ đến thẳng đội ngũ. feedback.email.label=Email của bạn (tùy chọn) feedback.email.placeholder=you@example.com feedback.email.hint=Chỉ được sử dụng để theo dõi phản hồi của bạn. Chúng tôi sẽ không bao giờ gửi thư rác. diff --git a/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties b/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties index afe97e337..0550276a1 100644 --- a/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties +++ b/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties @@ -525,7 +525,7 @@ action.refresh=刷新 # Happiness Gate (shown before Star Prompt) happiness.gate.title=您觉得 Askimo 怎么样? -happiness.gate.subtitle=让我们知道您的使用体验 +happiness.gate.subtitle=您的真实反馈帮助我们不断改进 happiness.gate.happy=非常喜欢!😍 happiness.gate.neutral=还不错 🙂 happiness.gate.unhappy=不太好 😕 @@ -535,6 +535,15 @@ feedback.dialog.title=什么地方没能满足您的需求? feedback.dialog.subtitle=请勾选所有适用项 — 您的反馈将直接发送给团队。 feedback.comment.label=详细说明 feedback.comment.placeholder=描述问题或分享您的想法… +feedback.unhappy.dialog.title=我们很想知道哪里出了问题 +feedback.unhappy.dialog.subtitle=告诉我们您使用 Askimo 的体验,以便我们改进。 +feedback.comment.label.unhappy=哪里对您不起作用? +feedback.comment.placeholder.unhappy=例如:会话历史难以找到,回复感觉很慢… +feedback.neutral.dialog.title=Askimo 可以怎样做得更好? +feedback.neutral.dialog.subtitle=您的想法帮助我们决定下一步构建什么。 +feedback.comment.label.neutral=我们可以改进什么? +feedback.comment.placeholder.neutral=例如:我希望有更好的键盘快捷键,更快的模型切换… +feedback.comment.encourage.hint=几句话就能带来很大的改变 — 您的反馈将直接传达给团队。 feedback.email.label=您的电子邮箱(可选) feedback.email.placeholder=you@example.com feedback.email.hint=仅用于跟进您的反馈。我们绝不会发送垃圾邮件。 diff --git a/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties b/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties index 42c8bb975..4eea7942b 100644 --- a/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties +++ b/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties @@ -525,7 +525,7 @@ action.refresh=重新整理 # Happiness Gate (shown before Star Prompt) happiness.gate.title=您覺得 Askimo 怎麼樣? -happiness.gate.subtitle=讓我們知道您的使用體驗 +happiness.gate.subtitle=您的真實反饋幫助我們持續改進 happiness.gate.happy=非常喜歡!😍 happiness.gate.neutral=還不錯 🙂 happiness.gate.unhappy=不太好 😕 @@ -535,6 +535,15 @@ feedback.dialog.title=什麼地方沒能滿足您的需求? feedback.dialog.subtitle=請勾選所有適用項 — 您的意見將直接發送給團隊。 feedback.comment.label=詳細說明 feedback.comment.placeholder=描述問題或分享您的想法… +feedback.unhappy.dialog.title=我們很想知道哪裡出了問題 +feedback.unhappy.dialog.subtitle=告訴我們您使用 Askimo 的體驗,以便我們改進。 +feedback.comment.label.unhappy=哪裡對您不起作用? +feedback.comment.placeholder.unhappy=例如:會話歷史難以找到,回覆感覺很慢… +feedback.neutral.dialog.title=Askimo 可以怎樣做得更好? +feedback.neutral.dialog.subtitle=您的想法幫助我們決定下一步要構建什麼。 +feedback.comment.label.neutral=我們可以改進什麼? +feedback.comment.placeholder.neutral=例如:我希望有更好的鍵盤快捷鍵,更快的模型切換… +feedback.comment.encourage.hint=幾句話就能帶來很大的改變 — 您的反饋將直接傳達給團隊。 feedback.email.label=您的電子郵件(選填) feedback.email.placeholder=you@example.com feedback.email.hint=僅用於跟進您的意見。我們絕對不會發送垃圾郵件。 diff --git a/desktop/src/main/kotlin/io/askimo/desktop/Main.kt b/desktop/src/main/kotlin/io/askimo/desktop/Main.kt index 92dcdcd00..ac35827bc 100644 --- a/desktop/src/main/kotlin/io/askimo/desktop/Main.kt +++ b/desktop/src/main/kotlin/io/askimo/desktop/Main.kt @@ -1818,8 +1818,15 @@ fun app(frameWindowScope: FrameWindowScope? = null, windowState: WindowState? = if (showFeedbackPromptDialog) { feedbackPromptDialog( onSubmit = { reasons, comment, email -> + val effectiveSentiment = if ( + feedbackSentiment in listOf("unhappy", "neutral") && comment.isBlank() + ) { + "no_comment" + } else { + feedbackSentiment + } Analytics.sendFeedbackDirect( - sentiment = feedbackSentiment, + sentiment = effectiveSentiment, reasons = reasons.joinToString(",") { it.name.lowercase() }, comment = comment, email = email.trim(), @@ -1836,6 +1843,7 @@ fun app(frameWindowScope: FrameWindowScope? = null, windowState: WindowState? = showFeedbackPromptDialog = false }, showReminderOnSkip = !feedbackOpenedFromMenu, + pathSentiment = if (feedbackOpenedFromMenu) null else feedbackSentiment, ) } diff --git a/shared/src/main/kotlin/io/askimo/core/chat/repository/ChatMessageRepository.kt b/shared/src/main/kotlin/io/askimo/core/chat/repository/ChatMessageRepository.kt index 67db7763c..b14b4b2fb 100644 --- a/shared/src/main/kotlin/io/askimo/core/chat/repository/ChatMessageRepository.kt +++ b/shared/src/main/kotlin/io/askimo/core/chat/repository/ChatMessageRepository.kt @@ -94,7 +94,15 @@ class ChatMessageRepository internal constructor( private val log = logger() - fun addMessage(message: ChatMessage): ChatMessage { + /** + * Inserts [message] into the local database. + * + * @param syncedAt When non-null, the `syncedAt` column is set during the INSERT so the + * message is immediately invisible to [getUnsyncedMessages]. Use this for messages that + * are already persisted server-side (e.g. via the Askimo Team proxy call) to avoid a + * separate UPDATE round-trip and a redundant sync push. + */ + fun addMessage(message: ChatMessage, syncedAt: Instant? = null): ChatMessage { val messageWithInjectedFields = message.copy( id = message.id.ifBlank { UUID.randomUUID().toString() }, ) @@ -114,6 +122,9 @@ class ChatMessageRepository internal constructor( it[ChatMessagesTable.outputTokens] = messageWithInjectedFields.outputTokens it[ChatMessagesTable.totalTokens] = messageWithInjectedFields.totalTokens it[ChatMessagesTable.durationMs] = messageWithInjectedFields.durationMs + // Set syncedAt during INSERT when the message is already on the server — + // avoids a separate markSynced() UPDATE call. + if (syncedAt != null) it[ChatMessagesTable.syncedAt] = syncedAt.toString() } // Save attachments if any diff --git a/shared/src/main/kotlin/io/askimo/core/chat/service/ChatSessionService.kt b/shared/src/main/kotlin/io/askimo/core/chat/service/ChatSessionService.kt index a936536b3..40bffd92b 100644 --- a/shared/src/main/kotlin/io/askimo/core/chat/service/ChatSessionService.kt +++ b/shared/src/main/kotlin/io/askimo/core/chat/service/ChatSessionService.kt @@ -39,6 +39,7 @@ import io.askimo.core.logging.logger import io.askimo.core.memory.MemoryMessage import io.askimo.core.memory.TokenAwareSummarizingMemory import io.askimo.core.providers.ChatClient +import io.askimo.core.providers.ModelProvider import io.askimo.core.rag.RagUtils import io.askimo.core.util.formatFileSize import io.askimo.core.vision.toUserMessage @@ -572,10 +573,12 @@ class ChatSessionService( * Add a message to a session and update the session's timestamp. * * @param message The message to add + * @param syncedAt When non-null, the message is pre-marked synced at insert time (single DB call). + * Pass [java.time.Instant.now] when the message is already persisted server-side so the sync push skips it. * @return The created message with generated ID */ - fun addMessage(message: ChatMessage): ChatMessage { - val createdMessage = messageRepository.addMessage(message) + fun addMessage(message: ChatMessage, syncedAt: java.time.Instant? = null): ChatMessage { + val createdMessage = messageRepository.addMessage(message, syncedAt) sessionRepository.touchSession(message.sessionId) EventBus.post(PushDataToServerEvent(reason = "message written")) return createdMessage @@ -584,15 +587,29 @@ class ChatSessionService( fun saveAiResponse( sessionId: String, response: String, + /** + * Pre-generated ID for the assistant message. + * + * When supplied, this ID is used as-is so the client and server agree on the same + * stable message identity. Leaving this blank (default) causes a new UUID to be + * generated at insert time, preserving backward-compatible behaviour for callers + * that do not pre-generate IDs. + */ + messageId: String = "", isFailed: Boolean = false, inputTokens: Int? = null, outputTokens: Int? = null, totalTokens: Int? = null, durationMs: Long? = null, ): ChatMessage { - val message = addMessage( + // When a stable messageId was supplied and the message is not a failure, the server + // already persisted the assistant message. Pre-mark synced at INSERT time so no + // separate markSynced() UPDATE is needed — single DB call. + val isPreSynced = messageId.isNotBlank() && !isFailed && + appContext.getActiveProvider() == ModelProvider.ASKIMO_PRO + return addMessage( ChatMessage( - id = "", + id = messageId, sessionId = sessionId, role = MessageRole.ASSISTANT, content = response, @@ -602,9 +619,8 @@ class ChatSessionService( totalTokens = totalTokens, durationMs = durationMs, ), + syncedAt = if (isPreSynced) Instant.now() else null, ) - - return message } /** @@ -836,6 +852,13 @@ class ChatSessionService( willSaveUserMessage: Boolean, ): List { if (willSaveUserMessage) { + // Pre-mark the user message as synced at INSERT time when the active provider + // persists messages server-side — single DB call, no separate UPDATE needed. + val preSyncedAt = if (appContext.getActiveProvider() == ModelProvider.ASKIMO_PRO) { + Instant.now() + } else { + null + } messageRepository.addMessage( ChatMessage( id = userMessage.id!!, @@ -844,6 +867,7 @@ class ChatSessionService( content = userMessage.content, attachments = userMessage.attachments.toDomain(sessionId), ), + syncedAt = preSyncedAt, ) } diff --git a/shared/src/main/kotlin/io/askimo/core/providers/ProxyChatContext.kt b/shared/src/main/kotlin/io/askimo/core/providers/ProxyChatContext.kt new file mode 100644 index 000000000..b4cd04a69 --- /dev/null +++ b/shared/src/main/kotlin/io/askimo/core/providers/ProxyChatContext.kt @@ -0,0 +1,53 @@ +/* SPDX-License-Identifier: AGPLv3 + * + * Copyright (c) 2026 Askimo + */ +package io.askimo.core.providers + +/** + * Thread-local holder for the identifiers the proxy server needs to correlate and + * persist chat messages during a proxied streaming call. + * + * **Lifecycle** + * 1. The session manager pre-generates [Context.assistantMessageId] and calls [set] on + * the coroutine thread immediately before invoking the streaming send function. + * 2. The correlating HTTP client builder calls [get] on every HTTP `send()` and merges + * [Context.toHeaderMap] into the outgoing request headers. + * 3. The session manager calls [clear] in a `finally` block after the streaming call returns. + * + * The `ThreadLocal` approach is safe here because the streaming send is a blocking call + * (backed by a [java.util.concurrent.CountDownLatch]) that does not suspend or switch + * threads between setting the context and making the HTTP request. + * + * Only populated when a proxy-backed provider is active — for direct providers the + * headers are absent and the context is never set. + */ +object ProxyChatContext { + + data class Context( + val sessionId: String, + val userMessageId: String, + val assistantMessageId: String, + ) { + /** + * Returns the HTTP headers the proxy server reads to correlate and persist + * chat messages during the streaming call. + */ + fun toHeaderMap(): Map = mapOf( + "X-Session-Id" to sessionId, + "X-User-Message-Id" to userMessageId, + "X-Assistant-Message-Id" to assistantMessageId, + ) + } + + private val current: ThreadLocal = ThreadLocal.withInitial { null } + + /** Set the proxy context for the current thread. Must be cleared in a `finally` block. */ + fun set(context: Context) = current.set(context) + + /** Returns the current thread's proxy context, or `null` if not in a proxy call. */ + fun get(): Context? = current.get() + + /** Clears the proxy context for the current thread. */ + fun clear() = current.remove() +}