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:
1 parent
842ae5fd64
commit
07f394f5a2
18 files changed
+1013
-519
No files matched your search
@@ -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| {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -53,6 +53,7 @@ export const ja: typeof zhCN = {
|
||||
micLabel: '音声で質問',
|
||||
micStop: '録音を終了',
|
||||
selectionPreview: '選択テキスト:',
|
||||
linuxSelectionToolsMissing: 'Linux の選択範囲を読み取れません。wl-paste、xclip、xsel のいずれかをインストールしてください。',
|
||||
emptyTitle: 'ご用件は?',
|
||||
emptyDesc: 'テキストを選択して質問するか、下に直接入力してください。回答はここに表示され、続けて質問できます。',
|
||||
recordingHint: '録音中… {{recordHotkey}} をもう一度押して終了し、質問します',
|
||||
|
||||
@@ -53,6 +53,7 @@ export const ko: typeof zhCN = {
|
||||
micLabel: '음성으로 질문',
|
||||
micStop: '녹음 종료',
|
||||
selectionPreview: '선택된 텍스트 기반:',
|
||||
linuxSelectionToolsMissing: 'Linux 선택 영역을 읽으려면 wl-paste, xclip 또는 xsel 중 하나를 설치하세요.',
|
||||
emptyTitle: '무엇을 도와드릴까요?',
|
||||
emptyDesc: '텍스트를 선택해 질문하거나 아래에 직접 입력하세요. 답변이 여기에 표시되며 계속 이어서 질문할 수 있습니다.',
|
||||
recordingHint: '녹음 중… {{recordHotkey}} 를 다시 눌러 종료하고 질문',
|
||||
|
||||
@@ -49,6 +49,7 @@ export const zhCN = {
|
||||
micLabel: '语音提问',
|
||||
micStop: '结束录音',
|
||||
selectionPreview: '基于选中文本:',
|
||||
linuxSelectionToolsMissing: '无法读取 Linux 选区。请安装 wl-paste、xclip 或 xsel 后重试。',
|
||||
emptyTitle: '有什么可以帮你?',
|
||||
emptyDesc: '选中任意文字后开始追问,或直接在下方输入问题。回答会显示在这里,可以连续多轮。',
|
||||
recordingHint: '录音中…再按一次 {{recordHotkey}} 结束并提问',
|
||||
|
||||
@@ -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字面量 </selected_text> 与 </selected_text>\n</selected_text>\n\n# 我的问题\n保持原样',
|
||||
'字面量 </selected_text> 与 </selected_text>',
|
||||
),
|
||||
);
|
||||
assertEqual(
|
||||
escapedLiteral.selection,
|
||||
'字面量 </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');
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 事件时附带的本帧增量字符串。 */
|
||||
|
||||
@@ -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]);
|
||||
|
||||
Reference in new issue
Block a user