fix: include selected text in follow-up context (#830)

* fix: include selected text in follow-up context

* fix: address selection follow-up review

* chore: preserve beta CI workflow

* fix: invalidate closed QA turns

* fix: guard voice QA sessions

* fix: reset QA session tokens on reopen

* fix: guard stale QA end cleanup
This commit is contained in:
Chris233 authored and GitHub committed 2026-07-16 13:27:12 +08:00
1 parent 842ae5fd64
commit 07f394f5a2
18 files changed
+1013 -519

No files matched your search

+19 -11
View File
@@ -51,7 +51,6 @@ use crate::polish::{
};
use crate::qa_hotkey::{QaHotkeyError, QaHotkeyEvent, QaHotkeyMonitor};
use crate::recorder::{Recorder, RecorderError};
use crate::selection::capture_selection;
#[cfg(target_os = "windows")]
use crate::types::PasteShortcut;
use crate::types::{
@@ -107,9 +106,11 @@ use qa::{
#[cfg(test)]
use resources::discard_startup_resources_for_session;
use resources::{
acquire_recording_mute, cancel_active_asr, release_recording_mute,
selected_microphone_device_name, stop_microphone_preview_monitor, stop_qa_recorder,
take_asr_for_session, take_recorder_for_session, SessionResource, SharedRecordingMuteState,
acquire_recording_mute, cancel_active_asr, cancel_qa_asr_for_session, release_recording_mute,
selected_microphone_device_name, stop_microphone_preview_monitor,
stop_qa_recorder_for_session, store_qa_asr_for_session, store_qa_recorder_for_session,
take_asr_for_session, take_qa_asr_for_session, take_recorder_for_session, SessionResource,
SharedRecordingMuteState,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -498,9 +499,9 @@ struct Inner {
/// 流入)后置 false,光条"点亮"进入正式录音。begin_session 每次入场重置为 true。
capsule_warming: AtomicBool,
/// QA 用的 ASR 句柄。必须跟 active_asr_provider 保持一致,避免浮窗走不同入口。
qa_asr: Mutex<Option<ActiveAsr>>,
qa_asr: Mutex<Option<SessionResource<ActiveAsr>>>,
/// QA 用的 Recorder 句柄。
qa_recorder: Mutex<Option<Recorder>>,
qa_recorder: Mutex<Option<SessionResource<Recorder>>>,
/// QA SSE 流取消标志。begin_qa_session 重置为 false;cancel_qa_session 设 true;
/// polish::chat_completion_history_streaming 的 loop 每帧检查,true 时 break loop
/// 避免取消后 LLM 仍 drain HTTP body 烧 token。详见 issue #161。
@@ -2005,25 +2006,32 @@ fn raw_mode_uses_llm(style_system_prompt: &str) -> bool {
/// QA 录音 runtime error 监听器。镜像 `spawn_recorder_error_monitor` 的语义但走 QA
/// 收尾路径(`finish_qa_with_error` 替代 `abort_recording_with_error`)。
/// 用 qa_state.session_id 守卫 stale 事件。详见 issue #168。
fn spawn_qa_recorder_error_monitor(inner: &Arc<Inner>, rx: mpsc::Receiver<RecorderError>) {
let captured_session_id = inner.qa_state.lock().session_id;
fn spawn_qa_recorder_error_monitor(
inner: &Arc<Inner>,
session_id: SessionId,
rx: mpsc::Receiver<RecorderError>,
) {
let inner = Arc::clone(inner);
std::thread::Builder::new()
.name("openless-qa-recorder-error-monitor".into())
.spawn(move || {
if let Ok(err) = rx.recv() {
let current_session_id = inner.qa_state.lock().session_id;
if captured_session_id != current_session_id {
if session_id != current_session_id {
log::warn!(
"[coord] QA recorder error from stale session {} dropped (current={}, err={})",
captured_session_id,
session_id,
current_session_id,
err
);
return;
}
log::error!("[coord] QA recorder runtime error: {err}");
finish_qa_with_error(&inner, format!("录音设备异常: {err}"));
finish_qa_with_error_if_current(
&inner,
session_id,
format!("录音设备异常: {err}"),
);
}
})
.ok();
@@ -2,7 +2,7 @@ use std::sync::Arc;
use tauri::Emitter;
use crate::coordinator_state::{initial_session_id, SessionId, SessionPhase};
use crate::coordinator_state::{initial_session_id, new_session_id, SessionId, SessionPhase};
use crate::selection::SelectionContext;
use crate::types::CapsuleState;
@@ -80,7 +80,7 @@ pub(super) async fn handle_qa_option_edge(inner: &Arc<Inner>) {
}
pub(super) fn open_qa_panel(inner: &Arc<Inner>) {
{
let session_id = {
let mut state = inner.qa_state.lock();
state.panel_visible = true;
state.phase = QaPhase::Idle;
@@ -91,7 +91,9 @@ pub(super) fn open_qa_panel(inner: &Arc<Inner>) {
// 在 show_qa_window 抢前台之前抓一下:每次 begin_qa_session 抓选区时拿这个 HWND
// 临时把焦点还回去,让 simulate_copy 跑在用户原 app 上。issue #466 focus-dance。
state.qa_focus_target = capture_focus_target();
}
state.session_id = new_session_id();
state.session_id
};
// 主听写 phase 是 Idle 才需要 sweep capsule —— 这里的语义是清掉「上一次 dictation
// Done 状态残留」的 message / insertedChars,让 QA 自己的 capsule 状态从干净起跑
// (否则 capsule UI 会出现 "已粘贴这个 0" 之类把上一次 inserted_chars 错误复用的
@@ -102,16 +104,24 @@ pub(super) fn open_qa_panel(inner: &Arc<Inner>) {
if dictation_idle {
emit_capsule(inner, CapsuleState::Idle, 0.0, 0, None, None);
}
if let Some(app) = inner.app.lock().clone() {
crate::show_qa_window(&app, "idle");
let _ = app.emit_to(
qa_event_target(),
"qa:state",
serde_json::json!({
"kind": "idle",
"messages": Vec::<crate::types::QaChatMessage>::new(),
}),
);
{
let state = inner.qa_state.lock();
if !state.panel_visible || state.session_id != session_id {
return;
}
if let Some(app) = inner.app.lock().clone() {
crate::show_qa_window(&app, "idle");
let _ = app.emit_to(
qa_event_target(),
"qa:state",
serde_json::json!({
"kind": "idle",
"session_id": session_id,
"selection_warning": null,
"messages": Vec::<crate::types::QaChatMessage>::new(),
}),
);
}
}
log::info!("[coord] QA panel opened (awaiting Option to record)");
}
@@ -127,6 +137,8 @@ pub(super) fn close_qa_panel(inner: &Arc<Inner>) {
state.qa_focus_target = None;
state.phase = QaPhase::Idle;
state.cancelled = false;
// 让仍在阻塞选区捕获或 provider await 中的旧任务无法在关闭后写回状态。
state.session_id = new_session_id();
}
if let Some(app) = inner.app.lock().clone() {
crate::hide_qa_window(&app);
File diff suppressed because it is too large. Load diff
@@ -221,8 +221,34 @@ pub(super) fn release_recording_mute(inner: &Arc<Inner>, owner: &'static str) {
}
}
pub(super) fn stop_qa_recorder(inner: &Arc<Inner>) {
if let Some(rec) = inner.qa_recorder.lock().take() {
pub(super) fn store_qa_asr_for_session(inner: &Arc<Inner>, session_id: SessionId, asr: ActiveAsr) {
*inner.qa_asr.lock() = Some(SessionResource::new(session_id, asr));
}
pub(super) fn take_qa_asr_for_session(
inner: &Arc<Inner>,
session_id: SessionId,
) -> Option<ActiveAsr> {
take_session_resource(&mut inner.qa_asr.lock(), session_id)
}
pub(super) fn cancel_qa_asr_for_session(inner: &Arc<Inner>, session_id: SessionId) {
if let Some(asr) = take_qa_asr_for_session(inner, session_id) {
cancel_active_asr(asr);
}
}
pub(super) fn store_qa_recorder_for_session(
inner: &Arc<Inner>,
session_id: SessionId,
recorder: Recorder,
) {
*inner.qa_recorder.lock() = Some(SessionResource::new(session_id, recorder));
}
pub(super) fn stop_qa_recorder_for_session(inner: &Arc<Inner>, session_id: SessionId) {
let recorder = take_session_resource(&mut inner.qa_recorder.lock(), session_id);
if let Some(rec) = recorder {
rec.stop();
release_recording_mute(inner, "qa");
}
@@ -616,14 +616,17 @@ mod tests {
QaChatMessage {
role: "user".into(),
content: "选区是什么意思".into(),
selection_text: None,
},
QaChatMessage {
role: "assistant".into(),
content: "这是一段示例文本".into(),
selection_text: None,
},
QaChatMessage {
role: "user".into(),
content: "继续问".into(),
selection_text: None,
},
];
let contents = qa_messages_to_contents(&messages);
@@ -11,6 +11,18 @@ pub struct SelectionContext {
pub source_app: Option<String>,
}
pub struct SelectionCaptureOutcome {
pub selection: Option<SelectionContext>,
pub warning_code: Option<&'static str>,
}
pub fn capture_selection_with_status() -> SelectionCaptureOutcome {
SelectionCaptureOutcome {
selection: capture_selection(),
warning_code: None,
}
}
#[cfg(target_os = "android")]
pub fn capture_selection() -> Option<SelectionContext> {
let text = match crate::android::jni::android::with_android_env(|env, context| {
+1
View File
@@ -2202,6 +2202,7 @@ mod tests {
let messages = vec![QaChatMessage {
role: "user".into(),
content: "问题".into(),
selection_text: None,
}];
let deltas = StdMutex::new(String::new());
let output = provider
+136 -39
View File
@@ -32,11 +32,17 @@ pub struct SelectionContext {
pub source_app: Option<String>,
}
/// 尝试捕获当前选区文本。所有 IO 都在调用线程完成(短小、阻塞但 < 200ms)。
///
/// 返回 `None` 表示真的没拿到东西(用户没选 / 平台不支持 / 权限缺失)。
/// 返回 `Some(ctx)` 时 `ctx.text` **保证非空**。
pub fn capture_selection() -> Option<SelectionContext> {
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
const LINUX_SELECTION_TOOLS_MISSING_WARNING: &str = "linux_selection_tools_missing";
pub struct SelectionCaptureOutcome {
pub selection: Option<SelectionContext>,
pub warning_code: Option<&'static str>,
}
/// 捕获选区并返回可向用户展示的非阻断平台提醒。
/// 目前仅 Linux 在 `wl-paste`、`xclip`、`xsel` 均未安装时返回提醒码。
pub fn capture_selection_with_status() -> SelectionCaptureOutcome {
let source_app = current_front_app();
// 1. macOS AX 直读
@@ -52,10 +58,13 @@ pub fn capture_selection() -> Option<SelectionContext> {
.map(|a| format!(" front_app={a}"))
.unwrap_or_default()
);
return Some(SelectionContext {
text: truncate_selection(trimmed),
source_app,
});
return SelectionCaptureOutcome {
selection: Some(SelectionContext {
text: truncate_selection(trimmed),
source_app,
}),
warning_code: None,
};
}
}
@@ -72,18 +81,21 @@ pub fn capture_selection() -> Option<SelectionContext> {
.map(|a| format!(" front_app={a}"))
.unwrap_or_default()
);
return Some(SelectionContext {
text: truncate_selection(trimmed),
source_app,
});
return SelectionCaptureOutcome {
selection: Some(SelectionContext {
text: truncate_selection(trimmed),
source_app,
}),
warning_code: None,
};
}
}
// 3. Linux:best-effort 读 PRIMARY selection(wl-paste / xclip / xsel)。
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
if let Some(text) = linux_selection::read_selected_text() {
let trimmed = text.trim();
if !trimmed.is_empty() {
match linux_selection::read_selected_text() {
linux_selection::LinuxSelectionRead::Text(text) => {
let trimmed = text.trim();
log::info!(
"[selection] linux primary selection OK ({} chars){}",
trimmed.chars().count(),
@@ -92,14 +104,30 @@ pub fn capture_selection() -> Option<SelectionContext> {
.map(|a| format!(" front_app={a}"))
.unwrap_or_default()
);
return Some(SelectionContext {
text: truncate_selection(trimmed),
source_app,
});
return SelectionCaptureOutcome {
selection: Some(SelectionContext {
text: truncate_selection(trimmed),
source_app,
}),
warning_code: None,
};
}
linux_selection::LinuxSelectionRead::ToolsUnavailable => {
log::warn!(
"[selection] linux primary selection unavailable: install wl-paste, xclip, or xsel"
);
return SelectionCaptureOutcome {
selection: None,
warning_code: Some(LINUX_SELECTION_TOOLS_MISSING_WARNING),
};
}
linux_selection::LinuxSelectionRead::NoSelection => {}
}
None
SelectionCaptureOutcome {
selection: None,
warning_code: None,
}
}
/// 长度截断到首 + 尾 + 标记。
@@ -194,8 +222,9 @@ fn post_copy_shortcut() -> bool {
windows_paste::send_ctrl_c().is_ok()
}
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
#[cfg(any(all(not(target_os = "macos"), not(target_os = "windows")), test))]
mod linux_selection {
use std::io::ErrorKind;
use std::process::Command;
const PRIMARY_SELECTION_COMMANDS: &[(&str, &[&str])] = &[
@@ -204,29 +233,97 @@ mod linux_selection {
("xsel", &["--primary", "--output"]),
];
pub fn read_selected_text() -> Option<String> {
for (bin, args) in PRIMARY_SELECTION_COMMANDS {
if let Some(text) = run_capture(bin, args) {
return Some(text);
}
}
log::info!(
"[selection] linux primary selection unavailable (wl-paste/xclip/xsel all failed)"
);
None
#[derive(Debug, PartialEq, Eq)]
pub enum LinuxSelectionRead {
Text(String),
NoSelection,
ToolsUnavailable,
}
fn run_capture(bin: &str, args: &[&str]) -> Option<String> {
let output = Command::new(bin).args(args).output().ok()?;
if !output.status.success() {
return None;
#[derive(Debug, PartialEq, Eq)]
enum ReaderAttempt {
Text(String),
AvailableWithoutText,
Unavailable,
}
pub fn read_selected_text() -> LinuxSelectionRead {
read_selected_text_with(run_capture)
}
fn read_selected_text_with<F>(mut run: F) -> LinuxSelectionRead
where
F: FnMut(&str, &[&str]) -> ReaderAttempt,
{
let mut has_available_reader = false;
for (bin, args) in PRIMARY_SELECTION_COMMANDS {
match run(bin, args) {
ReaderAttempt::Text(text) => return LinuxSelectionRead::Text(text),
ReaderAttempt::AvailableWithoutText => has_available_reader = true,
ReaderAttempt::Unavailable => {}
}
}
let text = String::from_utf8(output.stdout).ok()?;
if has_available_reader {
LinuxSelectionRead::NoSelection
} else {
LinuxSelectionRead::ToolsUnavailable
}
}
fn run_capture(bin: &str, args: &[&str]) -> ReaderAttempt {
let output = match Command::new(bin).args(args).output() {
Ok(output) => output,
Err(error) if error.kind() == ErrorKind::NotFound => return ReaderAttempt::Unavailable,
Err(_) => return ReaderAttempt::AvailableWithoutText,
};
if !output.status.success() {
return ReaderAttempt::AvailableWithoutText;
}
let Ok(text) = String::from_utf8(output.stdout) else {
return ReaderAttempt::AvailableWithoutText;
};
let trimmed = text.trim();
if trimmed.is_empty() {
return None;
return ReaderAttempt::AvailableWithoutText;
}
ReaderAttempt::Text(trimmed.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reports_tools_unavailable_only_when_all_three_are_missing() {
let result = read_selected_text_with(|_, _| ReaderAttempt::Unavailable);
assert_eq!(result, LinuxSelectionRead::ToolsUnavailable);
let mut attempts = 0;
let result = read_selected_text_with(|_, _| {
attempts += 1;
if attempts == 2 {
ReaderAttempt::AvailableWithoutText
} else {
ReaderAttempt::Unavailable
}
});
assert_eq!(result, LinuxSelectionRead::NoSelection);
}
#[test]
fn returns_text_from_first_reader_that_has_a_selection() {
let result = read_selected_text_with(|bin, _| {
if bin == "xclip" {
ReaderAttempt::Text("selected text".to_string())
} else {
ReaderAttempt::Unavailable
}
});
assert_eq!(
result,
LinuxSelectionRead::Text("selected text".to_string())
);
}
Some(trimmed.to_string())
}
}
+3
View File
@@ -2864,6 +2864,9 @@ pub struct QaChatMessage {
/// "user" | "assistant" — 直接对应 OpenAI 消息 role 字段。
pub role: String,
pub content: String,
/// 仅用于前端安全展示选区原文;LLM 通道只读取 `role` / `content`。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selection_text: Option<String>,
}
#[cfg(test)]
+1
View File
@@ -51,6 +51,7 @@ export const en: typeof zhCN = {
micLabel: 'Ask by voice',
micStop: 'Stop recording',
selectionPreview: 'From selected text:',
linuxSelectionToolsMissing: 'Linux selection capture needs wl-paste, xclip, or xsel. Install one and try again.',
emptyTitle: 'How can I help?',
emptyDesc: 'Select any text to ask about it, or just type your question below. Answers appear here — ask as many follow-ups as you like.',
recordingHint: 'Recording… press {{recordHotkey}} again to submit',
+1
View File
@@ -53,6 +53,7 @@ export const ja: typeof zhCN = {
micLabel: '音声で質問',
micStop: '録音を終了',
selectionPreview: '選択テキスト:',
linuxSelectionToolsMissing: 'Linux の選択範囲を読み取れません。wl-paste、xclip、xsel のいずれかをインストールしてください。',
emptyTitle: 'ご用件は?',
emptyDesc: 'テキストを選択して質問するか、下に直接入力してください。回答はここに表示され、続けて質問できます。',
recordingHint: '録音中… {{recordHotkey}} をもう一度押して終了し、質問します',
+1
View File
@@ -53,6 +53,7 @@ export const ko: typeof zhCN = {
micLabel: '음성으로 질문',
micStop: '녹음 종료',
selectionPreview: '선택된 텍스트 기반:',
linuxSelectionToolsMissing: 'Linux 선택 영역을 읽으려면 wl-paste, xclip 또는 xsel 중 하나를 설치하세요.',
emptyTitle: '무엇을 도와드릴까요?',
emptyDesc: '텍스트를 선택해 질문하거나 아래에 직접 입력하세요. 답변이 여기에 표시되며 계속 이어서 질문할 수 있습니다.',
recordingHint: '녹음 중… {{recordHotkey}} 를 다시 눌러 종료하고 질문',
+1
View File
@@ -49,6 +49,7 @@ export const zhCN = {
micLabel: '语音提问',
micStop: '结束录音',
selectionPreview: '基于选中文本:',
linuxSelectionToolsMissing: '无法读取 Linux 选区。请安装 wl-paste、xclip 或 xsel 后重试。',
emptyTitle: '有什么可以帮你?',
emptyDesc: '选中任意文字后开始追问,或直接在下方输入问题。回答会显示在这里,可以连续多轮。',
recordingHint: '录音中…再按一次 {{recordHotkey}} 结束并提问',
+1
View File
@@ -51,6 +51,7 @@ export const zhTW: typeof zhCN = {
micLabel: '語音提問',
micStop: '結束錄音',
selectionPreview: '基於選中文本:',
linuxSelectionToolsMissing: '無法讀取 Linux 選區。請安裝 wl-paste、xclip 或 xsel 後重試。',
emptyTitle: '有什麼可以幫你?',
emptyDesc: '選中任意文字後開始追問,或直接在下方輸入問題。回答會顯示在這裏,可以連續多輪。',
recordingHint: '錄音中…再按一次 {{recordHotkey}} 結束並提問',
@@ -0,0 +1,83 @@
import { acceptQaSessionEvent, nextQaSelectionWarning, splitQaUserMessage } from './qaMessage';
import type { QaChatMessage, QaStatePayload } from './types';
function assertEqual<T>(actual: T, expected: T, message: string) {
if (actual !== expected) {
throw new Error(`${message}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
}
function userMessage(content: string, selectionText?: string): QaChatMessage {
return { role: 'user', content, selectionText };
}
const canonical = splitQaUserMessage(
userMessage(
'<selected_text>\n安全信封文本\n</selected_text>\n\n# 我的问题\n解释一下',
'原始 <tag> & 文本',
),
);
assertEqual(canonical.selection, '原始 <tag> & 文本', 'canonical messages use original display text');
assertEqual(canonical.question, '解释一下', 'canonical messages extract the question');
const escapedLiteral = splitQaUserMessage(
userMessage(
'<selected_text>\n字面量 &lt;/selected_text> 与 &lt;/selected_text>\n</selected_text>\n\n# 我的问题\n保持原样',
'字面量 &lt;/selected_text> 与 </selected_text>',
),
);
assertEqual(
escapedLiteral.selection,
'字面量 &lt;/selected_text> 与 </selected_text>',
'display text is not ambiguously decoded',
);
const legacy = splitQaUserMessage(
userMessage('# 选区原文\n旧选区\n\n# 我的问题\n旧问题'),
);
assertEqual(legacy.selection, '旧选区', 'legacy messages still expose their selection');
assertEqual(legacy.question, '旧问题', 'legacy messages still expose their question');
const warning: QaStatePayload['selection_warning'] = 'linux_selection_tools_missing';
assertEqual(
nextQaSelectionWarning('', { kind: 'recording', selection_warning: warning }),
warning,
'recording surfaces a Linux dependency warning',
);
assertEqual(
nextQaSelectionWarning(warning, { kind: 'idle' }),
'',
'an idle reset clears a stale warning even when the field is omitted',
);
assertEqual(
nextQaSelectionWarning(warning, { kind: 'idle', selection_warning: null }),
'',
'an explicit idle reset clears a stale warning',
);
assertEqual(
nextQaSelectionWarning(warning, { kind: 'thinking' }),
warning,
'a transitional event without a warning preserves the current warning',
);
assertEqual(
acceptQaSessionEvent('new-session', { kind: 'answer_delta', session_id: 'old-session' }).accepted,
false,
'a late delta from an invalidated session is rejected',
);
assertEqual(
acceptQaSessionEvent('old-session', { kind: 'recording', session_id: 'new-session' }).sessionId,
'new-session',
'a recording event activates the next turn token',
);
assertEqual(
acceptQaSessionEvent('old-session', {
kind: 'idle',
session_id: 'new-session',
selection_warning: null,
}).sessionId,
'new-session',
'an explicit panel-open reset activates the reopened panel token',
);
console.log('qaMessage.test.ts passed');
+60
View File
@@ -0,0 +1,60 @@
import type { QaChatMessage, QaStatePayload } from './types';
export function splitQaUserMessage(
message: QaChatMessage,
): { selection: string; question: string } {
const parsed = splitQaUserContent(message.content);
return {
selection: message.selectionText ?? parsed.selection,
question: parsed.question,
};
}
function splitQaUserContent(content: string): { selection: string; question: string } {
const envelope = content.match(
/^<selected_text>\n([\s\S]*?)\n<\/selected_text>\n\n# 我的问题\n([\s\S]+)$/,
);
if (envelope) {
return { selection: envelope[1].trim(), question: envelope[2].trim() };
}
// 兼容修复前已保存在当前会话中的旧格式。
const legacy = content.match(/^# 选区原文\n([\s\S]*?)\n\n# 我的问题\n([\s\S]+)$/);
if (legacy) {
return { selection: legacy[1].trim(), question: legacy[2].trim() };
}
return { selection: '', question: content };
}
export function nextQaSelectionWarning(
current: string,
payload: Pick<QaStatePayload, 'kind' | 'selection_warning'>,
): string {
if (payload.kind === 'idle' || payload.kind === 'recording') {
return payload.selection_warning ?? '';
}
if (payload.kind === 'loading' || payload.kind === 'thinking') {
return payload.selection_warning === undefined ? current : (payload.selection_warning ?? '');
}
return current;
}
export function acceptQaSessionEvent(
currentSessionId: string | null,
payload: Pick<QaStatePayload, 'kind' | 'session_id' | 'selection_warning'>,
): { accepted: boolean; sessionId: string | null } {
if (!payload.session_id) {
return { accepted: true, sessionId: currentSessionId };
}
const startsTurn = payload.kind === 'recording'
|| payload.kind === 'loading'
|| payload.kind === 'thinking'
|| (payload.kind === 'idle' && payload.selection_warning !== undefined);
if (currentSessionId && !startsTurn && currentSessionId !== payload.session_id) {
return { accepted: false, sessionId: currentSessionId };
}
return {
accepted: true,
sessionId: !currentSessionId || startsTurn ? payload.session_id : currentSessionId,
};
}
+6
View File
@@ -472,14 +472,20 @@ export type QaStateKind =
export interface QaChatMessage {
role: 'user' | 'assistant';
content: string;
/** 未经模型安全信封转义的选区原文,仅用于 UI 文本展示。 */
selectionText?: string;
}
export interface QaStatePayload {
kind: QaStateKind;
/** 后端会话 token;前端用它丢弃关闭/重开后迟到的旧轮事件。 */
session_id?: string;
/** 后端权威:当前已有的多轮对话历史(user → assistant 交替)。answer 事件带完整版。 */
messages?: QaChatMessage[];
/** recording 状态时附带的选区预览(前 60 字)。 */
selection_preview?: string | null;
/** Linux 选区工具缺失时的非阻断提醒码。 */
selection_warning?: 'linux_selection_tools_missing' | null;
/** error 状态时附带的提示。 */
error?: string;
/** answer_delta 事件时附带的本帧增量字符串。 */
+36 -10
View File
@@ -77,6 +77,7 @@ import {
qaToggleRecording,
qaWindowDismiss,
} from '../lib/ipc';
import { acceptQaSessionEvent, nextQaSelectionWarning, splitQaUserMessage } from '../lib/qaMessage';
import type { QaChatMessage, QaStatePayload } from '../lib/types';
import '../components/chat/chat.css';
@@ -97,7 +98,7 @@ function getPreviewMessages(): QaChatMessage[] {
{
role: 'user',
content:
'# 选区原文\nThe mitochondria is the powerhouse of the cell.\n\n# 我的问题\n这句话为什么会成为梗?',
'<selected_text>\nThe mitochondria is the powerhouse of the cell.\n</selected_text>\n\n# 我的问题\n这句话为什么会成为梗?',
},
{
role: 'assistant',
@@ -125,9 +126,11 @@ export function QaPanel({ embedded = false, onRequestClose }: QaPanelProps = {})
const [status, setStatus] = useState<Status>('idle');
const [errorMsg, setErrorMsg] = useState<string>('');
const [selectionPreview, setSelectionPreview] = useState<string>('');
const [selectionWarning, setSelectionWarning] = useState<string>('');
const [composerText, setComposerText] = useState<string>('');
/** 流式 LLM 答案:answer_delta 累积、answer 事件来时清空(最终内容已落到 messages)。 */
const [streamingAnswer, setStreamingAnswer] = useState<string>('');
const activeSessionIdRef = useRef<string | null>(null);
const { enterEpoch, closing } = useChatPanelLifecycle();
const tRef = useRef(t);
tRef.current = t;
@@ -151,9 +154,15 @@ export function QaPanel({ embedded = false, onRequestClose }: QaPanelProps = {})
const { listen } = await import('@tauri-apps/api/event');
const stateHandle = await listen<QaStatePayload>('qa:state', event => {
const payload = event.payload;
const sessionEvent = acceptQaSessionEvent(activeSessionIdRef.current, payload);
if (!sessionEvent.accepted) {
return;
}
activeSessionIdRef.current = sessionEvent.sessionId;
if (payload.messages) {
setMessages(payload.messages);
}
setSelectionWarning(current => nextQaSelectionWarning(current, payload));
switch (payload.kind) {
case 'idle':
setStatus('idle');
@@ -205,7 +214,9 @@ export function QaPanel({ embedded = false, onRequestClose }: QaPanelProps = {})
}
});
const dismissHandle = await listen<unknown>('qa:dismiss', () => {
activeSessionIdRef.current = null;
setSelectionPreview('');
setSelectionWarning('');
setComposerText('');
if (embeddedRef.current) {
onRequestCloseRef.current?.();
@@ -231,6 +242,18 @@ export function QaPanel({ embedded = false, onRequestClose }: QaPanelProps = {})
};
}, []);
useEffect(() => {
if (!closing) return;
activeSessionIdRef.current = null;
setMessages([]);
setStatus('idle');
setErrorMsg('');
setStreamingAnswer('');
setSelectionPreview('');
setSelectionWarning('');
setComposerText('');
}, [closing]);
// ── Esc 关闭 ────────────────────────────────────────────────────────
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
@@ -382,6 +405,16 @@ export function QaPanel({ embedded = false, onRequestClose }: QaPanelProps = {})
{status === 'recording' && selectionPreview && (
<SelectionChip text={selectionPreview} t={t} />
)}
{selectionWarning === 'linux_selection_tools_missing' && (
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="w-full rounded-md border border-amber-500/35 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200"
>
{t('qa.linuxSelectionToolsMissing')}
</div>
)}
<Composer
value={composerText}
status={status}
@@ -510,8 +543,8 @@ function MessageRow({
githubLogin: string;
}) {
if (message.role === 'user') {
// 第一轮可能含 "# 选区原文 ... # 我的问题 ..." → 抽出问题单独显示,选区作引用块淡显在上。
const { selection, question } = splitFirstTurnUser(message.content);
// 任意轮次都可能带选区信封:抽出问题单独显示,选区作引用块淡显在上。
const { selection, question } = splitQaUserMessage(message);
return (
// 用户消息 = 新轮次锚点行(MessageScrollerItem scrollAnchor),右挂 GitHub 头像。
<MessageScrollerItem messageId={`m${index}`} scrollAnchor className="olchat-enter">
@@ -547,13 +580,6 @@ function MessageRow({
);
}
function splitFirstTurnUser(content: string): { selection: string; question: string } {
// 后端拼法:`# 选区原文\n{sel}\n\n# 我的问题\n{q}`。简单 split,对齐 coordinator.rs 的写法。
const m = content.match(/^# 选区原文\n([\s\S]*?)\n\n# 我的问题\n([\s\S]+)$/);
if (!m) return { selection: '', question: content };
return { selection: m[1].trim(), question: m[2].trim() };
}
/** 录音时的选区上下文条:贴在输入区上方,让用户看到「在追问哪段文字」。 */
function SelectionChip({ text, t }: { text: string; t: ReturnType<typeof useTranslation>['t'] }) {
const truncated = useMemo(() => truncate(text, SELECTION_PREVIEW_MAX), [text]);