Files
openless/openless-all/app/linux-egui/src/main.rs
T
sim 4ec44b2b0b merge: 汇入 origin/beta 第二轮(#1048 根证书指纹核验、#1049 录音保亮屏)
- 五语 PC/手机页文案取云端指纹核验版;de/es/fr 全套补译(指纹 6 键、保亮屏 4 键、恢复 4 键、中断/离线 2 键)
- index.html:帮助块改为指纹核验流程 + 录音屏新增保亮屏开关(默认开)
- RemoteInputStatus 补 caFingerprintSha256;Linux egui 面板移植指纹显示与复制
- remote-input-audio-queue 测试取云端(#1049 音频队列改造),我方仅格式差异
2026-09-10 17:16:58 +08:00

3135 lines
138 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#[cfg(any(target_os = "linux", test))]
mod ui_state;
#[cfg(not(target_os = "linux"))]
fn main() {
eprintln!("openless-linux-egui is only available on Linux");
}
#[cfg(target_os = "linux")]
mod linux_app {
use super::ui_state::{Navigation, Page};
use std::future::Future;
use std::sync::mpsc;
use std::sync::Arc;
use std::time::Duration;
use eframe::egui;
use openless_core::{
BackendConfig, BackendError, BackendEvent, BackendEventKind, BackendSnapshot,
DictationPhase, HistoryInsertStatus, HostAction, LessComputerEventKind, LocalAsrModel,
LocalAsrRuntime, QaStateEvent, QaStateKind, SelectionPhase, SelectionSnapshot,
TranscriptAccumulator, UserPreferences,
};
use openless_linux_egui::{
drain_events, ensure_fcitx5_plugin_installed, EventDrainOutcome, Fcitx5HotkeyListener,
FcitxPluginInstallPlan, FcitxPluginStatus, LinuxBackendBuilder, LinuxCapabilitySnapshot,
LinuxDesktopSession, LinuxLaunchIntent, LinuxNativeRuntime, LinuxPackageKind,
LinuxResourceLayout, SingleInstanceBroker, SingleInstanceRole,
};
enum UiResult {
Environment(LinuxCapabilitySnapshot),
Message(String),
Models(Result<Vec<LocalAsrModel>, String>),
Remote(Result<(openless_core::RemoteInputStatus, String), String>),
Providers(Result<ProviderPanel, String>),
ProviderEditor {
kind: openless_core::ChannelKind,
channel_id: String,
result: Box<Result<ProviderEditor, String>>,
},
ProviderModels {
kind: openless_core::ChannelKind,
channel_id: String,
result: Result<Vec<String>, String>,
},
ProviderMutation(Result<String, String>),
}
#[derive(Clone)]
enum ModelsState {
Loading,
Loaded(Vec<LocalAsrModel>),
Failed(String),
}
#[derive(Clone)]
struct ProviderPanel {
kind: openless_core::ChannelKind,
descriptors: Vec<openless_core::ProviderDescriptor>,
channels: Vec<openless_core::ChannelSummary>,
active_provider: String,
}
#[derive(Clone)]
enum ProvidersState {
Loading,
Loaded(ProviderPanel),
Failed(String),
}
#[derive(Clone)]
struct ProviderEditor {
kind: openless_core::ChannelKind,
channel: openless_core::ChannelSummary,
descriptor: openless_core::ProviderDescriptor,
name: String,
endpoint: String,
model: String,
auth_mode: String,
resource_id: String,
app_id: String,
// Secret inputs are intentionally write-only. Loading an editor never
// exposes an existing key into egui state, logs or screenshots.
primary_secret: String,
secondary_secret: String,
}
#[derive(Clone)]
enum ProviderEditorState {
Idle,
Loading {
kind: openless_core::ChannelKind,
channel_id: String,
},
Loaded(Box<ProviderEditor>),
Failed(String),
}
pub struct OpenLessEguiApp {
navigation: Navigation,
environment: Option<LinuxCapabilitySnapshot>,
environment_refreshing: bool,
plugin_check: Option<Result<FcitxPluginStatus, String>>,
less_computer_running: bool,
remote_error: Option<String>,
tokio: Arc<tokio::runtime::Runtime>,
native: Option<LinuxNativeRuntime>,
subscription: Option<openless_core::EventSubscription>,
snapshot: Option<BackendSnapshot>,
preferences: Option<UserPreferences>,
models: ModelsState,
transcript: String,
transcript_state: TranscriptAccumulator,
transcript_session: Option<openless_core::SessionId>,
last_event_sequence: u64,
less_computer_input: String,
less_computer_output: String,
less_computer_turn_start: usize,
less_computer_session: Option<openless_core::SessionId>,
pending_approval: Option<(String, String)>,
qa_visible: bool,
qa_input: String,
qa_state: Option<QaStateEvent>,
selection_preview_visible: bool,
selection_draft: String,
selection: Option<SelectionSnapshot>,
remote_access: Option<(openless_core::RemoteInputStatus, String)>,
provider_kind: openless_core::ChannelKind,
providers: ProvidersState,
selected_channel_id: Option<String>,
provider_editor: ProviderEditorState,
provider_models: Vec<String>,
new_provider_type: String,
new_channel_name: String,
pending_channel_delete: Option<String>,
status: String,
startup_error: Option<String>,
tx: mpsc::Sender<UiResult>,
rx: mpsc::Receiver<UiResult>,
}
impl OpenLessEguiApp {
fn new(
tokio: Arc<tokio::runtime::Runtime>,
native: Result<LinuxNativeRuntime, String>,
) -> Self {
let (tx, rx) = mpsc::channel();
match native {
Ok(native) => {
let backend = native.host().backend();
let snapshot = backend.snapshot();
let preferences = backend.get_preferences();
let subscription = backend.subscribe();
let app = Self {
navigation: Navigation::default(),
environment: None,
environment_refreshing: false,
plugin_check: None,
less_computer_running: false,
remote_error: None,
tokio,
native: Some(native),
subscription: Some(subscription),
snapshot: Some(snapshot),
preferences: Some(preferences),
models: ModelsState::Loading,
transcript: String::new(),
transcript_state: TranscriptAccumulator::default(),
transcript_session: None,
last_event_sequence: 0,
less_computer_input: String::new(),
less_computer_output: String::new(),
less_computer_turn_start: 0,
less_computer_session: None,
pending_approval: None,
qa_visible: false,
qa_input: String::new(),
qa_state: None,
selection_preview_visible: false,
selection_draft: String::new(),
selection: None,
remote_access: None,
provider_kind: openless_core::ChannelKind::Asr,
providers: ProvidersState::Loading,
selected_channel_id: None,
provider_editor: ProviderEditorState::Idle,
provider_models: Vec::new(),
new_provider_type: String::new(),
new_channel_name: String::new(),
pending_channel_delete: None,
status: "Core 2.0 已启动".to_string(),
startup_error: None,
tx,
rx,
};
app.load_models();
app.load_remote_status();
app.load_providers(openless_core::ChannelKind::Asr);
app
}
Err(error) => Self {
navigation: Navigation::default(),
environment: None,
environment_refreshing: false,
plugin_check: None,
less_computer_running: false,
remote_error: None,
tokio,
native: None,
subscription: None,
snapshot: None,
preferences: None,
models: ModelsState::Loading,
transcript: String::new(),
transcript_state: TranscriptAccumulator::default(),
transcript_session: None,
last_event_sequence: 0,
less_computer_input: String::new(),
less_computer_output: String::new(),
less_computer_turn_start: 0,
less_computer_session: None,
pending_approval: None,
qa_visible: false,
qa_input: String::new(),
qa_state: None,
selection_preview_visible: false,
selection_draft: String::new(),
selection: None,
remote_access: None,
provider_kind: openless_core::ChannelKind::Asr,
providers: ProvidersState::Loading,
selected_channel_id: None,
provider_editor: ProviderEditorState::Idle,
provider_models: Vec::new(),
new_provider_type: String::new(),
new_channel_name: String::new(),
pending_channel_delete: None,
status: "启动失败".to_string(),
startup_error: Some(error),
tx,
rx,
},
}
}
fn backend(&self) -> Option<Arc<openless_core::OpenLessBackend>> {
self.native
.as_ref()
.map(|native| Arc::clone(native.host().backend()))
}
fn spawn<F>(&self, future: F)
where
F: Future<Output = Result<String, BackendError>> + Send + 'static,
{
let tx = self.tx.clone();
self.tokio.spawn(async move {
let message = future.await.unwrap_or_else(|error| error.to_string());
let _ = tx.send(UiResult::Message(message));
});
}
fn load_models(&self) {
let Some(backend) = self.backend() else {
return;
};
let tx = self.tx.clone();
self.tokio.spawn(async move {
let models = backend
.services()
.local_asr
.list_models(LocalAsrRuntime::Generic)
.await
.map_err(|error| error.to_string());
let _ = tx.send(UiResult::Models(models));
});
}
fn load_remote_status(&self) {
let Some(backend) = self.backend() else {
return;
};
let tx = self.tx.clone();
self.tokio.spawn(async move {
let result = async {
let status = backend.services().remote_input.status()?;
let pin = if status.enabled {
backend
.services()
.remote_input
.read_pairing_pin()
.await?
.into_exposed()
} else {
String::new()
};
Ok::<_, BackendError>((status, pin))
}
.await
.map_err(|error| error.to_string());
let _ = tx.send(UiResult::Remote(result));
});
}
fn load_providers(&self, kind: openless_core::ChannelKind) {
let Some(backend) = self.backend() else {
return;
};
let tx = self.tx.clone();
self.tokio.spawn(async move {
let result = async {
let provider_kind = provider_kind(kind);
let mut channels = backend.list_channels(kind).await?;
channels.sort_by_key(|channel| channel.order);
Ok::<_, BackendError>(ProviderPanel {
kind,
descriptors: openless_core::provider_rules::provider_descriptors(
provider_kind,
),
channels,
active_provider: backend.active_provider(provider_slot(kind)).await?,
})
}
.await
.map_err(|error| error.to_string());
let _ = tx.send(UiResult::Providers(result));
});
}
fn load_provider_editor(
&self,
kind: openless_core::ChannelKind,
channel: openless_core::ChannelSummary,
descriptor: openless_core::ProviderDescriptor,
) {
let Some(backend) = self.backend() else {
return;
};
let tx = self.tx.clone();
let channel_id = channel.id.clone();
self.tokio.spawn(async move {
let result = load_provider_editor(backend, kind, channel, descriptor)
.await
.map_err(|error| error.to_string());
let _ = tx.send(UiResult::ProviderEditor {
kind,
channel_id,
result: Box::new(result),
});
});
}
fn spawn_provider_mutation<F>(&self, future: F)
where
F: Future<Output = Result<String, BackendError>> + Send + 'static,
{
let tx = self.tx.clone();
self.tokio.spawn(async move {
let _ = tx.send(UiResult::ProviderMutation(
future.await.map_err(|error| error.to_string()),
));
});
}
fn request_provider_models(&self, kind: openless_core::ChannelKind, channel_id: String) {
let Some(backend) = self.backend() else {
return;
};
let tx = self.tx.clone();
self.tokio.spawn(async move {
let result = backend
.services()
.provider
.list_models(openless_core::ProviderRequest {
thinking_enabled: backend.get_preferences().llm_thinking_enabled,
kind: provider_kind(kind),
channel_id: Some(channel_id.clone()),
})
.await
.map(|models| models.models)
.map_err(|error| error.to_string());
let _ = tx.send(UiResult::ProviderModels {
kind,
channel_id,
result,
});
});
}
fn apply_event(&mut self, event: BackendEvent) {
if event.sequence <= self.last_event_sequence {
return;
}
self.last_event_sequence = event.sequence;
let session_id = event.session_id;
match event.kind {
BackendEventKind::DictationStateChanged(state) => {
self.navigation.notify(Page::Dictation);
if state.phase == DictationPhase::Starting {
self.transcript_state = TranscriptAccumulator::default();
self.transcript.clear();
self.transcript_session = state.session_id;
}
self.status = format!("听写:{:?}", state.phase);
}
BackendEventKind::TranscriptDelta(delta)
if session_id == self.transcript_session =>
{
if self.transcript_state.apply(&delta).is_ok() {
self.transcript = self.transcript_state.text().to_string();
}
}
BackendEventKind::PolishDelta(delta) if delta.is_final => {
self.transcript = delta.text;
}
BackendEventKind::DictationCompleted(result) => {
self.navigation.notify(Page::Dictation);
self.transcript = result.polished_text;
self.status = format!("听写完成:{:?}", result.inserted);
}
BackendEventKind::RecordingControlRequested(request) => {
if let Some(backend) = self.backend() {
self.spawn(async move {
match request.action {
openless_core::RecordingControlAction::Stop => {
backend.stop_dictation_session(request.session_id).await?;
}
openless_core::RecordingControlAction::Cancel => {
backend.cancel_dictation(Some(request.session_id)).await?;
}
}
Ok("录音已自动结束".to_string())
});
}
}
BackendEventKind::LessComputerEvent(event) => {
// Voice capture has its own session, preceding a chat User
// turn. Keep a navigation notice without assigning it to
// the current chat or inventing microphone readiness.
if matches!(&event.kind, LessComputerEventKind::VoiceState { .. }) {
self.navigation.notify(Page::Agent);
return;
}
// Less Computer events may complete after a newer turn has
// already started. Session ownership, not arrival time,
// decides whether a delta/terminal may mutate this view.
if let LessComputerEventKind::User { text, fresh } = &event.kind {
// Every User starts a new turn UUID, including a
// continuation. `fresh` describes conversation history,
// never whether this turn is allowed to receive output.
self.less_computer_session = session_id;
self.less_computer_running = true;
self.pending_approval = None;
if *fresh {
self.less_computer_output.clear();
} else if !self.less_computer_output.is_empty() {
self.less_computer_output.push_str("\n\n");
}
self.less_computer_turn_start = self.less_computer_output.len();
self.less_computer_input = text.clone();
} else if session_id != self.less_computer_session {
return;
}
self.navigation.notify(Page::Agent);
match event.kind {
// Linux已有独立录音显示;新typed反馈供接手Host/UI团队继续接入。
LessComputerEventKind::VoiceState { .. } => {}
LessComputerEventKind::User { .. } => {}
LessComputerEventKind::Started => {
self.less_computer_running = true;
self.status = "Less Computer 正在运行".to_string();
}
LessComputerEventKind::Delta { text } => {
self.less_computer_output.push_str(&text);
}
LessComputerEventKind::Tool { name } => {
self.status = format!("Less Computer 正在使用工具:{name}");
}
LessComputerEventKind::Compaction => {
self.status = "Less Computer 已压缩上下文".to_string();
}
LessComputerEventKind::Completed { text, .. } => {
self.less_computer_running = false;
// A terminal is authoritative even for final-only
// providers or after a missed partial event.
self.less_computer_output
.truncate(self.less_computer_turn_start);
self.less_computer_output.push_str(&text);
self.pending_approval = None;
self.status = "Less Computer 已完成".to_string();
}
LessComputerEventKind::Approval { token, command, .. } => {
self.pending_approval = Some((token, command));
self.status = "Less Computer 等待审批".to_string();
}
LessComputerEventKind::Error { message } => {
self.less_computer_running = false;
self.pending_approval = None;
self.status = message;
}
LessComputerEventKind::Cancelled => {
self.less_computer_running = false;
self.pending_approval = None;
self.status = "Less Computer 已取消".to_string();
}
}
}
BackendEventKind::LocalAsrDownloadProgress(progress) => {
self.navigation.notify(Page::Models);
self.status = format!(
"模型 {}:{:?} {}/{}",
progress.model_id,
progress.phase,
progress.bytes_downloaded,
progress.bytes_total
);
if matches!(
progress.phase,
openless_core::LocalAsrDownloadPhase::Finished
| openless_core::LocalAsrDownloadPhase::Failed
| openless_core::LocalAsrDownloadPhase::Cancelled
) {
self.models = ModelsState::Loading;
self.load_models();
}
}
BackendEventKind::PreferencesChanged(_) => {
if let Some(backend) = self.backend() {
self.preferences = Some(backend.get_preferences());
}
self.load_remote_status();
}
BackendEventKind::QaState(state) => {
if state.kind == QaStateKind::AnswerDelta {
if let Some(current) = self
.qa_state
.as_mut()
.filter(|current| current.session_id == state.session_id)
{
self.navigation.notify(Page::Qa);
// Core deltas deliberately omit messages. Preserve
// the conversation and append only this turn's text;
// the following Answer replaces it with Core history.
current.kind = state.kind;
current
.chunk
.get_or_insert_with(String::new)
.push_str(state.chunk.as_deref().unwrap_or_default());
}
} else if matches!(
state.kind,
QaStateKind::Idle
| QaStateKind::Loading
| QaStateKind::Thinking
| QaStateKind::Recording
) || self
.qa_state
.as_ref()
.is_none_or(|current| current.session_id == state.session_id)
{
self.navigation.notify(Page::Qa);
self.qa_state = Some(state);
}
}
BackendEventKind::SelectionStateChanged(snapshot) => {
self.navigation.notify(Page::Selection);
if snapshot.phase == SelectionPhase::Preview {
self.selection_draft = snapshot.preview_text.clone().unwrap_or_default();
self.selection_preview_visible = true;
}
self.selection = Some(snapshot);
}
BackendEventKind::RemoteInputStatusChanged(_)
| BackendEventKind::RemoteInputFailed(_) => {
self.navigation.notify(Page::Remote);
self.load_remote_status();
}
_ => {}
}
}
fn poll(&mut self, ctx: &egui::Context) {
if let Some(native) = &self.native {
let (launch_intents, hotkey_events, errors) = native.drain_native_events();
let host = native.host_arc();
for intent in launch_intents {
let host = Arc::clone(&host);
self.spawn(async move {
host.dispatch_launch_intent(intent).await?;
Ok("已处理启动请求".to_string())
});
}
for event in hotkey_events {
let host = Arc::clone(&host);
self.spawn(async move {
host.dispatch_hotkey_event(event).await?;
Ok("已处理快捷键".to_string())
});
}
if let Some(error) = errors.last() {
self.status = error.to_string();
}
let mut actions = Vec::new();
native.host_actions().drain(|action| actions.push(action));
// HostAction controls only native visibility/focus/effects.
// QA and Selection contents and terminal ownership always come
// back through sequenced Core events handled above.
for action in actions {
match action {
HostAction::ShowMain => {
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true));
}
HostAction::ShowLessComputer => {
self.navigation.open(Page::Agent);
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true));
}
HostAction::FocusMain => {
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
}
HostAction::Notify(message) => self.status = message,
HostAction::OpenExternalUrl(url) | HostAction::OpenSystemSettings(url) => {
std::thread::spawn(move || {
let _ = std::process::Command::new("xdg-open").arg(url).status();
});
}
HostAction::RequestRestart => {
self.status = "请手动重启 OpenLess".to_string();
}
HostAction::ShowSelectionPreview => {
self.navigation.open(Page::Selection);
self.selection_preview_visible = true;
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true));
}
HostAction::HideSelectionPreview => {
self.selection_preview_visible = false;
}
HostAction::ShowQa => {
self.navigation.open(Page::Qa);
self.qa_visible = true;
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true));
}
HostAction::HideQa => self.qa_visible = false,
HostAction::ShowDictationFeedback | HostAction::HideDictationFeedback => {}
}
}
}
let mut events = Vec::new();
let drain = self
.subscription
.as_mut()
.map(|subscription| drain_events(subscription, |event| events.push(event)));
for event in events {
self.apply_event(event);
}
if let Some(EventDrainOutcome::Lagged { dropped, .. }) = drain {
if let Some(backend) = self.backend() {
// Broadcast lag does not imply Core lost the events. Replay
// from the last applied sequence first; duplicate delivery
// from the live receiver is rejected by apply_event above.
let replay = backend.replay_events_after(self.last_event_sequence);
let snapshot = backend.snapshot();
if replay.truncated {
// The bounded tail cannot reconstruct derived text/UI
// state. Reset it before applying the authoritative tail
// so no stale transcript, approval or preview survives.
self.transcript_state = TranscriptAccumulator::default();
self.transcript.clear();
self.transcript_session = snapshot.dictation.session_id;
self.less_computer_input.clear();
self.less_computer_output.clear();
self.less_computer_turn_start = 0;
self.less_computer_session = None;
self.less_computer_running = false;
self.pending_approval = None;
self.qa_state = None;
self.qa_visible = false;
self.selection = None;
self.selection_draft.clear();
self.selection_preview_visible = false;
}
self.snapshot = Some(snapshot);
for event in replay.events {
self.apply_event(event);
}
self.status = if replay.truncated {
format!("事件积压 {dropped} 条,已重置派生界面并重放可用事件")
} else {
format!("事件积压 {dropped} 条,已从 Core 重放补齐")
};
}
}
while let Ok(result) = self.rx.try_recv() {
match result {
UiResult::Environment(environment) => {
self.environment = Some(environment);
self.environment_refreshing = false;
}
UiResult::Message(message) => self.status = message,
UiResult::Models(Ok(models)) => self.models = ModelsState::Loaded(models),
UiResult::Models(Err(error)) => {
self.models = ModelsState::Failed(error.clone());
self.status = error;
}
UiResult::Remote(Ok(remote)) => {
self.remote_error = None;
self.remote_access = Some(remote);
}
UiResult::Remote(Err(error)) => {
self.remote_access = None;
self.remote_error = Some(error.clone());
self.status = error;
}
UiResult::Providers(Ok(panel)) => {
if panel.kind != self.provider_kind {
continue;
}
if !panel.descriptors.iter().any(|descriptor| {
descriptor.provider_type.as_str() == self.new_provider_type
}) {
self.new_provider_type = panel
.descriptors
.first()
.map(|descriptor| descriptor.provider_type.as_str().to_string())
.unwrap_or_default();
}
let selected = self
.selected_channel_id
.as_ref()
.filter(|id| panel.channels.iter().any(|channel| &channel.id == *id))
.cloned()
.or_else(|| {
panel
.channels
.iter()
.find(|channel| channel.id == panel.active_provider)
.map(|channel| channel.id.clone())
})
.or_else(|| panel.channels.first().map(|channel| channel.id.clone()));
self.selected_channel_id = selected.clone();
if self.pending_channel_delete.as_ref().is_some_and(|id| {
!panel.channels.iter().any(|channel| &channel.id == id)
}) {
self.pending_channel_delete = None;
}
self.providers = ProvidersState::Loaded(panel.clone());
self.provider_models.clear();
if let Some(channel_id) = selected {
if let Some((channel, descriptor)) =
provider_channel_descriptor(&panel, &channel_id)
{
self.provider_editor = ProviderEditorState::Loading {
kind: panel.kind,
channel_id,
};
self.load_provider_editor(panel.kind, channel, descriptor);
}
} else {
self.provider_editor = ProviderEditorState::Idle;
}
}
UiResult::Providers(Err(error)) => {
self.providers = ProvidersState::Failed(error.clone());
self.status = error;
}
UiResult::ProviderEditor {
kind,
channel_id,
result,
} => {
if kind != self.provider_kind
|| self.selected_channel_id.as_deref() != Some(channel_id.as_str())
{
continue;
}
match *result {
Ok(editor) => {
// Reads race with channel switching and mutation
// refreshes. Only the still-selected channel may install
// its editor, otherwise late credential data is ignored.
self.provider_editor =
ProviderEditorState::Loaded(Box::new(editor));
}
Err(error) => {
self.provider_editor = ProviderEditorState::Failed(error.clone());
self.status = error;
}
}
}
UiResult::ProviderModels {
kind,
channel_id,
result,
} => {
if kind == self.provider_kind
&& self.selected_channel_id.as_deref() == Some(channel_id.as_str())
{
match result {
Ok(models) => {
self.status = format!("已读取 {} 个模型", models.len());
self.provider_models = models;
}
Err(error) => self.status = error,
}
}
}
UiResult::ProviderMutation(result) => {
match result {
Ok(message) => self.status = message,
Err(error) => self.status = error,
}
self.providers = ProvidersState::Loading;
self.provider_editor = ProviderEditorState::Idle;
self.provider_models.clear();
self.load_providers(self.provider_kind);
}
}
}
if let Some(backend) = self.backend() {
self.snapshot = Some(backend.snapshot());
}
}
fn dictation_ui(&mut self, ui: &mut egui::Ui) {
ui.heading("听写");
let phase = self
.snapshot
.as_ref()
.map(|snapshot| snapshot.dictation.phase)
.unwrap_or(DictationPhase::Idle);
ui.horizontal(|ui| {
if ui
.add_enabled(phase == DictationPhase::Idle, egui::Button::new("开始"))
.clicked()
{
if let Some(backend) = self.backend() {
self.transcript.clear();
self.spawn(async move {
backend.start_dictation().await?;
Ok("正在录音".to_string())
});
}
}
if ui
.add_enabled(
phase == DictationPhase::Recording,
egui::Button::new("停止"),
)
.clicked()
{
if let Some(backend) = self.backend() {
self.spawn(async move {
let result = backend.stop_dictation().await?;
Ok(format!("完成:{} 字", result.polished_text.chars().count()))
});
}
}
if ui
.add_enabled(phase != DictationPhase::Idle, egui::Button::new("取消"))
.clicked()
{
if let Some(backend) = self.backend() {
self.spawn(async move {
backend.cancel_dictation(None).await?;
Ok("听写已取消".to_string())
});
}
}
});
ui.label(if self.transcript.is_empty() {
"尚无转写结果"
} else {
&self.transcript
});
}
fn less_computer_ui(&mut self, ui: &mut egui::Ui) {
ui.heading("Less Computer");
ui.text_edit_multiline(&mut self.less_computer_input);
ui.horizontal(|ui| {
if ui.button("运行").clicked() && !self.less_computer_input.trim().is_empty() {
if let Some(backend) = self.backend() {
let prompt = self.less_computer_input.clone();
self.less_computer_output.clear();
self.spawn(async move {
backend.submit_less_computer(prompt).await?;
Ok("Less Computer 已完成".to_string())
});
}
}
if ui.button("取消").clicked() {
if let Some(backend) = self.backend() {
self.spawn(async move {
backend.cancel_less_computer(None).await?;
Ok("Less Computer 已取消".to_string())
});
}
}
});
ui.label(if self.less_computer_output.is_empty() {
"尚无 Agent 输出"
} else {
&self.less_computer_output
});
}
fn agent_approval_ui(&mut self, ui: &mut egui::Ui) {
if let Some((token, command)) = self.pending_approval.clone() {
egui::ScrollArea::vertical()
.id_salt("approval_command")
.max_height(72.0)
.show(ui, |ui| {
ui.label(format!("请求执行:{command}"));
});
ui.horizontal(|ui| {
for (label, approved) in [("允许", true), ("拒绝", false)] {
if ui.button(label).clicked() {
if let Some(backend) = self.backend() {
let token = token.clone();
self.pending_approval = None;
self.spawn(async move {
backend
.services()
.less_computer
.approve(token, approved)
.await?;
Ok("审批已提交".to_string())
});
}
}
}
});
}
}
fn qa_ui(&mut self, ui: &mut egui::Ui) {
ui.heading("问答");
if !self.qa_visible {
ui.label("打开问答后可文字提问或语音提问。切换页面会保留当前会话;关闭会话使用下方的关闭操作。");
if ui.button("打开问答").clicked() {
if let Some(backend) = self.backend() {
self.spawn(async move {
backend.services().qa.show().await?;
Ok("问答已打开".to_string())
});
}
}
return;
}
if let Some(state) = &self.qa_state {
if let Some(messages) = &state.messages {
for message in messages {
ui.label(format!("{}:{}", message.role, message.content));
}
}
if let Some(chunk) = &state.chunk {
ui.label(chunk);
}
if let Some(error) = &state.error {
ui.colored_label(egui::Color32::RED, error);
}
}
ui.text_edit_multiline(&mut self.qa_input);
ui.horizontal(|ui| {
let recording = self
.qa_state
.as_ref()
.is_some_and(|state| state.kind == QaStateKind::Recording);
if ui
.button(if recording {
"结束录音"
} else {
"语音提问"
})
.clicked()
{
if let Some(backend) = self.backend() {
self.spawn(async move {
backend.services().qa.toggle_recording().await?;
Ok("问答录音状态已更新".to_string())
});
}
}
if ui.button("发送").clicked() && !self.qa_input.trim().is_empty() {
if let Some(backend) = self.backend() {
let text = std::mem::take(&mut self.qa_input);
self.spawn(async move {
backend.services().qa.submit_text(text).await?;
Ok("问答已提交".to_string())
});
}
}
if ui.button("关闭").clicked() {
if let Some(backend) = self.backend() {
self.spawn(async move {
backend.services().qa.dismiss().await?;
Ok("问答已关闭".to_string())
});
}
}
if ui.button("取消本轮").clicked() {
if let Some(backend) = self.backend() {
let session_id = self
.qa_state
.as_ref()
.and_then(|state| state.session_id.as_deref())
.and_then(|id| uuid::Uuid::parse_str(id).ok())
.map(openless_core::SessionId::from_uuid);
self.spawn(async move {
backend.services().qa.cancel(session_id).await?;
Ok("问答本轮已取消".to_string())
});
}
}
});
}
fn selection_ui(&mut self, ui: &mut egui::Ui) {
ui.heading("选区润色");
let Some(selection) = self.selection.clone() else {
ui.label("先在目标应用中选中文字,再使用已配置的选区润色快捷键。预览会在此显示,确认前可以编辑或取消。");
ui.small("此入口是现有 Selection polish;Selection Voice 的完整意图路由尚未接入。");
return;
};
ui.label(format!("当前状态:{:?}", selection.phase));
if self.selection_preview_visible && selection.phase == SelectionPhase::Preview {
ui.strong("选区预览");
ui.text_edit_multiline(&mut self.selection_draft);
ui.horizontal(|ui| {
if ui.button("确认替换").clicked() {
if let (Some(backend), Some(session_id)) =
(self.backend(), selection.session_id)
{
let text = self.selection_draft.clone();
self.spawn(async move {
backend
.services()
.selection
.confirm(session_id, Some(text))
.await?;
Ok("选区替换已确认".to_string())
});
}
}
if ui.button("取消").clicked() {
if let (Some(backend), Some(session_id)) =
(self.backend(), selection.session_id)
{
self.spawn(async move {
backend
.services()
.selection
.cancel(Some(session_id))
.await?;
Ok("选区替换已取消".to_string())
});
}
}
});
} else if selection.phase == SelectionPhase::Completed
&& selection.revert_outcome.is_none()
{
ui.horizontal(|ui| {
ui.label("最近一次选区替换已完成");
if ui.button("撤销").clicked() {
if let (Some(backend), Some(session_id)) =
(self.backend(), selection.session_id)
{
self.spawn(async move {
backend.services().selection.revert(session_id).await?;
Ok("选区替换已撤销".to_string())
});
}
}
});
}
}
fn models_ui(&mut self, ui: &mut egui::Ui) {
ui.horizontal(|ui| {
ui.heading("本地模型");
if ui.button("刷新").clicked() {
self.models = ModelsState::Loading;
self.load_models();
}
});
let models = match self.models.clone() {
ModelsState::Loading => {
ui.label("正在加载模型目录…");
return;
}
ModelsState::Failed(error) => {
ui.colored_label(egui::Color32::RED, error);
return;
}
ModelsState::Loaded(models) if models.is_empty() => {
ui.label("模型目录未返回任何可用模型");
return;
}
ModelsState::Loaded(models) => models,
};
for model in models {
ui.horizontal(|ui| {
ui.label(format!(
"{} · {} · {}",
model.display_name,
model.family,
if model.installed {
"已安装"
} else {
"未安装"
}
));
if !model.installed && ui.button("下载").clicked() {
if let Some(backend) = self.backend() {
let target = model.target.clone();
self.spawn(async move {
backend
.services()
.local_asr
.start_download(target, None)
.await?;
Ok("模型下载完成".to_string())
});
}
}
if model.installed && ui.button("激活").clicked() {
if let Some(backend) = self.backend() {
let target = model.target.clone();
self.spawn(async move {
let descriptor =
openless_core::provider_rules::provider_descriptor(
openless_core::ProviderKind::Asr,
"local-qwen3-c",
)
.ok_or_else(|| {
openless_core::BackendError::new(
openless_core::BackendErrorCode::Unsupported,
"local Qwen provider is unavailable",
)
})?;
let provider_type = descriptor.provider_type.as_str().to_string();
let existing = backend
.list_channels(openless_core::ChannelKind::Asr)
.await?
.into_iter()
.find(|channel| channel.provider_type == provider_type)
.map(|channel| channel.id);
let provider_id = match existing {
Some(provider_id) => provider_id,
None => {
backend
.create_channel(
openless_core::ChannelKind::Asr,
provider_type,
descriptor.label_key,
)
.await?
}
};
backend
.activate_local_asr(openless_core::LocalAsrActivationRequest {
target,
provider_id,
})
.await?;
Ok("本地模型已激活并预加载".to_string())
});
}
}
if ui.button("取消").clicked() {
if let Some(backend) = self.backend() {
let target = model.target.clone();
self.spawn(async move {
backend.services().local_asr.cancel_download(target).await?;
Ok("模型下载已取消".to_string())
});
}
}
});
}
}
fn provider_management_ui(&mut self, ui: &mut egui::Ui) {
ui.horizontal(|ui| {
ui.strong("凭据渠道");
for (kind, label) in [
(openless_core::ChannelKind::Asr, "ASR"),
(openless_core::ChannelKind::Llm, "LLM"),
] {
if ui
.selectable_label(self.provider_kind == kind, label)
.clicked()
&& self.provider_kind != kind
{
self.provider_kind = kind;
self.providers = ProvidersState::Loading;
self.selected_channel_id = None;
self.pending_channel_delete = None;
self.provider_editor = ProviderEditorState::Idle;
self.provider_models.clear();
self.load_providers(kind);
}
}
if ui.button("刷新渠道").clicked() {
self.providers = ProvidersState::Loading;
self.load_providers(self.provider_kind);
}
});
let panel = match self.providers.clone() {
ProvidersState::Loading => {
ui.label("正在读取 Core 渠道目录…");
return;
}
ProvidersState::Failed(error) => {
ui.colored_label(egui::Color32::RED, error);
return;
}
ProvidersState::Loaded(panel) => panel,
};
ui.group(|ui| {
ui.label("新增渠道");
ui.horizontal(|ui| {
egui::ComboBox::from_id_salt("new-provider-type")
.selected_text(
panel
.descriptors
.iter()
.find(|item| item.provider_type.as_str() == self.new_provider_type)
.map(provider_descriptor_label)
.unwrap_or_else(|| "选择 Provider".to_string()),
)
.show_ui(ui, |ui| {
for descriptor in &panel.descriptors {
ui.selectable_value(
&mut self.new_provider_type,
descriptor.provider_type.as_str().to_string(),
provider_descriptor_label(descriptor),
);
}
});
ui.text_edit_singleline(&mut self.new_channel_name);
if ui
.add_enabled(
!self.new_provider_type.is_empty(),
egui::Button::new("创建"),
)
.clicked()
{
if let (Some(backend), Some(descriptor)) = (
self.backend(),
panel
.descriptors
.iter()
.find(|item| item.provider_type.as_str() == self.new_provider_type),
) {
let kind = panel.kind;
let provider_type = descriptor.provider_type.as_str().to_string();
let name = if self.new_channel_name.trim().is_empty() {
descriptor.label_key.clone()
} else {
self.new_channel_name.trim().to_string()
};
self.new_channel_name.clear();
self.spawn_provider_mutation(async move {
backend.create_channel(kind, provider_type, name).await?;
Ok("渠道已创建".to_string())
});
}
}
});
ui.small("Provider 类型、默认 Endpoint/Model 与鉴权要求均来自 Core descriptor。");
});
if panel.channels.is_empty() {
ui.label("尚无渠道;先从上方 Core Provider 列表创建一个。");
return;
}
for (index, channel) in panel.channels.iter().enumerate() {
let active = channel.id == panel.active_provider;
ui.horizontal(|ui| {
let selected = self.selected_channel_id.as_deref() == Some(channel.id.as_str());
if ui
.selectable_label(
selected,
format!(
"{} · {}{}{}",
channel.name,
channel.provider_type,
if active { " · active" } else { "" },
if channel.enabled { "" } else { " · 已禁用" },
),
)
.clicked()
{
self.selected_channel_id = Some(channel.id.clone());
self.provider_models.clear();
if let Some((channel, descriptor)) =
provider_channel_descriptor(&panel, &channel.id)
{
self.provider_editor = ProviderEditorState::Loading {
kind: panel.kind,
channel_id: channel.id.clone(),
};
self.load_provider_editor(panel.kind, channel, descriptor);
}
}
if !active && channel.enabled && ui.button("设为 active").clicked() {
if let Some(backend) = self.backend() {
let slot = provider_slot(panel.kind);
let channel_id = channel.id.clone();
self.spawn_provider_mutation(async move {
backend.set_active_provider(slot, channel_id).await?;
Ok("active 渠道已更新".to_string())
});
}
}
if ui
.button(if channel.enabled { "禁用" } else { "启用" })
.clicked()
{
if let Some(backend) = self.backend() {
let kind = panel.kind;
let channel_id = channel.id.clone();
let enabled = !channel.enabled;
self.spawn_provider_mutation(async move {
backend
.set_channel_enabled(kind, channel_id, enabled)
.await?;
Ok("渠道启用状态已更新".to_string())
});
}
}
if index > 0 && ui.button("上移").clicked() {
if let Some(backend) = self.backend() {
let kind = panel.kind;
let mut ids = panel
.channels
.iter()
.map(|item| item.id.clone())
.collect::<Vec<_>>();
ids.swap(index, index - 1);
self.spawn_provider_mutation(async move {
backend.reorder_channels(kind, ids).await?;
Ok("渠道顺序已更新".to_string())
});
}
}
if index + 1 < panel.channels.len() && ui.button("下移").clicked() {
if let Some(backend) = self.backend() {
let kind = panel.kind;
let mut ids = panel
.channels
.iter()
.map(|item| item.id.clone())
.collect::<Vec<_>>();
ids.swap(index, index + 1);
self.spawn_provider_mutation(async move {
backend.reorder_channels(kind, ids).await?;
Ok("渠道顺序已更新".to_string())
});
}
}
if self.pending_channel_delete.as_deref() == Some(channel.id.as_str()) {
if ui.button("确认删除").clicked() {
self.pending_channel_delete = None;
if let Some(backend) = self.backend() {
let kind = panel.kind;
let channel_id = channel.id.clone();
self.spawn_provider_mutation(async move {
backend.delete_channel(kind, channel_id).await?;
Ok("渠道已删除".to_string())
});
}
}
if ui.button("取消删除").clicked() {
self.pending_channel_delete = None;
}
} else if ui.button("删除").clicked() {
// Channel deletion may remove the last usable provider
// and its persisted secrets, so require a deliberate
// second click even in this intentionally compact UI.
self.pending_channel_delete = Some(channel.id.clone());
}
});
}
match self.provider_editor.clone() {
ProviderEditorState::Idle => {}
ProviderEditorState::Loading { kind, channel_id } => {
ui.label(format!("正在读取 {:?} 渠道 {channel_id}…", kind));
}
ProviderEditorState::Failed(error) => {
ui.colored_label(egui::Color32::RED, error);
}
ProviderEditorState::Loaded(editor) => {
let mut editor = *editor;
ui.separator();
ui.strong(format!("编辑渠道 {}", editor.channel.id));
let mut provider_type = editor.descriptor.provider_type.as_str().to_string();
egui::ComboBox::from_id_salt("edit-provider-type")
.selected_text(provider_descriptor_label(&editor.descriptor))
.show_ui(ui, |ui| {
for descriptor in &panel.descriptors {
ui.selectable_value(
&mut provider_type,
descriptor.provider_type.as_str().to_string(),
provider_descriptor_label(descriptor),
);
}
});
if provider_type != editor.descriptor.provider_type.as_str() {
if let Some(backend) = self.backend() {
let kind = editor.kind;
let channel_id = editor.channel.id.clone();
self.spawn_provider_mutation(async move {
backend
.set_channel_provider_type(kind, channel_id, provider_type)
.await?;
Ok("Provider 类型已更新".to_string())
});
}
return;
}
ui.label(format!(
"鉴权:{} · 探针:{:?}",
auth_requirement_label(editor.descriptor.auth_requirement),
editor.descriptor.validation_probe
));
ui.horizontal(|ui| {
ui.label("名称");
ui.text_edit_singleline(&mut editor.name);
});
provider_fields_ui(ui, &mut editor);
ui.horizontal(|ui| {
if ui.button("保存字段/Secret").clicked() {
if let Some(backend) = self.backend() {
let saved = editor.clone();
self.spawn_provider_mutation(async move {
save_provider_editor(backend, saved).await?;
Ok("渠道配置已保存".to_string())
});
}
}
if ui.button("清除 Secret").clicked() {
if let Some(backend) = self.backend() {
let cleared = editor.clone();
self.spawn_provider_mutation(async move {
clear_provider_secrets(backend, &cleared).await?;
Ok("渠道 Secret 已清除".to_string())
});
}
}
if ui.button("验证连接").clicked() {
if let Some(backend) = self.backend() {
let kind = editor.kind;
let channel_id = editor.channel.id.clone();
self.spawn_provider_mutation(async move {
validate_provider_channel(backend, kind, channel_id).await
});
}
}
if ui.button("列出模型").clicked() {
self.provider_models.clear();
self.request_provider_models(editor.kind, editor.channel.id.clone());
}
});
if !self.provider_models.is_empty() {
ui.label("模型列表(点击填入):");
for model in self.provider_models.clone() {
if ui.button(&model).clicked() {
editor.model = model;
}
}
}
self.provider_editor = ProviderEditorState::Loaded(Box::new(editor));
}
}
}
fn services_ui(&mut self, ui: &mut egui::Ui) {
ui.heading("AI 服务");
ui.label("选择 ASR 语音识别、LLM 文本处理或 Omni 服务,再编辑并校验渠道。已配置不代表网络请求已通过。");
if let Some(snapshot) = &self.snapshot {
let credentials = &snapshot.credentials;
ui.label(format!(
"ASR:{}({})",
credentials.active_asr_provider,
if credentials.asr_configured {
"已配置"
} else {
"未配置"
}
));
ui.label(format!(
"LLM:{}({})",
credentials.active_llm_provider,
if credentials.llm_configured {
"已配置"
} else {
"未配置"
}
));
}
self.provider_management_ui(ui);
}
fn save_preferences(&mut self) {
let (Some(native), Some(snapshot), Some(preferences)) =
(&self.native, &self.snapshot, &self.preferences)
else {
return;
};
match native
.host()
.save_settings(preferences.clone(), snapshot.preferences_revision)
{
Ok(_) => {
self.status = "设置已保存".to_string();
let config = openless_core::RemoteInputConfig {
enabled: preferences.remote_input_enabled,
port: preferences.remote_input_port,
};
if let Some(backend) = self.backend() {
self.spawn(async move {
backend.services().remote_input.configure(config).await?;
Ok("远程输入状态已更新".to_string())
});
}
}
Err(error) => self.status = error.to_string(),
}
}
fn settings_actions_ui(&mut self, ui: &mut egui::Ui) {
ui.horizontal_wrapped(|ui| {
if ui.button("保存设置").clicked() {
self.save_preferences();
}
if ui.button("放弃修改并重新读取").clicked() {
if let Some(backend) = self.backend() {
self.preferences = Some(backend.get_preferences());
self.snapshot = Some(backend.snapshot());
self.status = "已重新读取设置".to_string();
}
}
});
ui.small(
"环境与设置、手机输入共用设置草稿;保存会一起应用。保存冲突时可重新读取后再修改。",
);
}
fn settings_ui(&mut self, ui: &mut egui::Ui) {
ui.heading("环境与设置");
ui.strong("现有功能设置");
if let Some(preferences) = self.preferences.as_mut() {
ui.checkbox(&mut preferences.streaming_insert, "流式插入");
ui.small("将转写逐步发送到原输入目标,实际结果以听写与历史反馈为准。");
ui.checkbox(&mut preferences.coding_agent_enabled, "启用 Less Computer");
ui.small("使用已有 Agent 配置与 CLI;进程执行仍遵循 Core 的审批规则。");
self.settings_actions_ui(ui);
}
ui.horizontal_wrapped(|ui| {
if ui.button("配置 AI 服务").clicked() {
self.navigation.open(Page::Services);
}
if ui.button("设置手机输入").clicked() {
self.navigation.open(Page::Remote);
}
});
ui.small(
"托盘、自启、自动更新、系统静音与额外全局热键尚未完整接入,此页没有对应开关。",
);
ui.separator();
self.environment_ui(ui);
}
fn remote_ui(&mut self, ui: &mut egui::Ui) {
ui.heading("手机输入");
ui.label(
"先启用并保存,再让手机连接同一局域网,打开本机提供的 HTTPS 地址并输入配对码。",
);
ui.label("首次连接需要确认并信任本服务的证书;服务运行不代表手机已连接。");
if let Some(preferences) = self.preferences.as_mut() {
ui.checkbox(&mut preferences.remote_input_enabled, "启用远程输入");
ui.add(
egui::DragValue::new(&mut preferences.remote_input_port)
.range(1..=u16::MAX)
.prefix("端口 "),
);
self.settings_actions_ui(ui);
}
ui.separator();
if ui.button("刷新连接状态").clicked() {
self.load_remote_status();
}
if let Some(error) = &self.remote_error {
ui.colored_label(
egui::Color32::YELLOW,
format!("暂时无法读取连接状态:{error}"),
);
ui.label("检查桌面密钥环、网络和端口后刷新;旧地址与配对码已隐藏。");
} else if self.remote_access.is_none() {
ui.label("尚未取得手机输入状态。");
}
if let Some((remote, pin)) = &self.remote_access {
ui.label(if remote.running {
"远程输入服务:运行中"
} else if remote.starting {
"远程输入服务:启动中"
} else {
"远程输入服务:已停止"
});
ui.label(format!("当前连接数:{}", remote.connection_count));
if remote.active_session_id.is_some() {
ui.label("手机语音会话进行中,可使用顶部的语音取消。");
}
if remote.urls_stale {
ui.colored_label(
egui::Color32::YELLOW,
"网络地址已过期,请检查网络后刷新状态。",
);
}
if remote.enabled && remote.running && !remote.urls_stale {
// 首次信任前必须核对根证书指纹:网页与描述文件名称不能证明身份。
ui.label("本机根证书 SHA-256");
match remote.ca_fingerprint_sha256.as_ref().filter(|value| {
value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
}) {
Some(fingerprint) => {
let display = fingerprint
.as_bytes()
.chunks(2)
.map(|pair| std::str::from_utf8(pair).unwrap().to_ascii_uppercase())
.collect::<Vec<_>>()
.join(" ");
ui.add(egui::Label::new(egui::RichText::new(&display).monospace()).wrap());
if ui.button("复制完整指纹").clicked() {
ui.ctx().copy_text(display);
}
}
None => ui.label("完整指纹不可用。请勿安装或信任下载的证书。"),
}
ui.label("安装或开启完全信任前,在手机系统的证书详情中核对全部 SHA-256 字符,必须与此处一致。网页、描述文件名称和标识不能证明证书身份。若不一致或无法查看,请停止并移除已下载或安装的描述文件。");
ui.label("描述文件应只包含一张根证书。若有其他证书、VPN 或设备管理配置,请勿安装。首次下载仍可能被局域网攻击者替换;核验后再信任。根证书可签发其他证书,不再使用时请移除。");
ui.monospace(format!("PIN:{pin}"));
for url in &remote.urls {
ui.monospace(url);
}
if remote.urls.is_empty() {
ui.label("服务已启动,但尚未提供可用地址;请检查本机局域网连接。");
}
}
if remote.enabled && ui.button("重置配对码").clicked() {
if let Some(backend) = self.backend() {
self.spawn(async move {
backend
.services()
.remote_input
.regenerate_pairing_pin()
.await?;
Ok("远程输入配对码已重置".to_string())
});
}
}
}
}
fn environment_ui(&mut self, ui: &mut egui::Ui) {
ui.strong("Linux 环境准备");
ui.label(if self.native.is_some() {
"Core 已连接。下面的环境检查不代表录音、落字或服务调用已经实测成功。"
} else {
"Core 未连接。可查看准备步骤;修复启动问题后,请退出并重新启动 OpenLess。"
});
if let Some(environment) = &self.environment {
ui.label(match environment.session {
LinuxDesktopSession::X11 => "桌面会话:检测到 X11 环境",
LinuxDesktopSession::Wayland => "桌面会话:检测到 Wayland 环境",
LinuxDesktopSession::Headless => "桌面会话:未检测到 DISPLAY / WAYLAND_DISPLAY",
});
ui.label(if environment.fcitx5_ready {
"fcitx5:D-Bus 探测有响应,插件加载、快捷键与目标应用落字仍需实际操作确认。"
} else {
"fcitx5:D-Bus 探测未通过,可能是会话总线、服务或插件未就绪。"
});
ui.label(match environment.permissions.microphone {
openless_core::PermissionState::Unsupported => {
"麦克风:当前探测环境不支持;请进入图形桌面会话。"
}
_ => "麦克风:尚未验证录音。请在系统声音设置选择输入设备,再进行一次短听写。",
});
} else {
ui.label("尚未取得桌面环境探测结果。");
}
ui.label(match &self.plugin_check {
Some(Ok(FcitxPluginStatus::Ready)) => {
"本次启动插件检查:找到插件文件;文件存在不代表 fcitx5 已加载它。"
}
Some(Ok(FcitxPluginStatus::Updated)) => {
"本次启动插件检查:插件文件已安装或更新,需要重载配置并重新启动 fcitx5。"
}
Some(Ok(FcitxPluginStatus::Missing)) => {
"本次启动插件检查:未找到插件文件,请重新安装含 OpenLess 插件的软件包。"
}
Some(Err(_)) => "本次启动插件检查:检查失败,请查看下方具体原因。",
None => "本次启动插件检查:未执行。",
});
if let Some(Err(error)) = &self.plugin_check {
ui.colored_label(egui::Color32::YELLOW, error);
}
if ui
.add_enabled(
!self.environment_refreshing,
egui::Button::new(if self.environment_refreshing {
"正在检测…"
} else {
"重新检测会话与 D-Bus"
}),
)
.clicked()
{
self.environment_refreshing = true;
let tx = self.tx.clone();
self.tokio.spawn_blocking(move || {
let environment = LinuxCapabilitySnapshot::detect(false, package_kind());
let _ = tx.send(UiResult::Environment(environment));
});
}
ui.small("重新检测只更新上面的会话与 D-Bus 信息,不安装插件,也不重新连接 Core。本次启动检查结果保留到退出。");
egui::CollapsingHeader::new("准备步骤与官方指南")
.default_open(self.native.is_none())
.show(ui, |ui| {
ui.separator();
ui.strong("1 · 准备输入法与桌面会话");
ui.label("在当前图形桌面安装并启用 fcitx5,再安装含 OpenLess 插件的当前软件包。先在普通编辑器中确认输入法可以输入。");
ui.label("在终端运行以下诊断,查看输入法环境与插件加载信息:");
command_ui(ui, "fcitx5-diagnose");
ui.label("插件安装或更新后可先重载配置;若插件仍未加载,退出并重新登录桌面,再启动 OpenLess:");
command_ui(ui, "fcitx5-remote -r");
ui.horizontal_wrapped(|ui| {
ui.hyperlink_to(
"Fcitx 5 官方设置指南",
"https://fcitx-im.org/wiki/Setup_Fcitx_5",
);
ui.hyperlink_to(
"Wayland 桌面配置差异",
"https://fcitx-im.org/wiki/Using_Fcitx_5_on_Wayland",
);
});
ui.small("Wayland 的输入法配置取决于桌面和应用工具包,请按官方对应章节配置;检测到 Wayland 不代表所有目标应用都支持替换。X11 的 overlay 能力标记也不代表本应用已接入录音浮层。");
ui.separator();
ui.strong("2 · 准备密钥环与识别服务");
ui.label("Secret Service:当前没有独立的服务连接或解锁状态检测;渠道显示“已配置”也不能证明密钥环现在可读写。");
ui.label("打开桌面的密码/密钥环管理器,确认当前登录会话的密钥环已解锁。然后到 AI 服务选择渠道,填写所需凭据、保存并校验;若返回锁定或访问失败,解锁后重试。");
ui.hyperlink_to(
"Secret Service 官方规范",
"https://specifications.freedesktop.org/secret-service/latest/",
);
ui.small("API 密钥输入只用于写入,不回显已有密钥。本地识别可在本地模型页下载并激活 Generic Qwen。");
ui.separator();
ui.strong("3 · 做一次短听写");
ui.label("在系统声音设置确认输入设备有电平。配置识别服务后,在目标编辑器聚焦输入框,用已有听写快捷键录制一句话并结束,检查转写和落字结果。问答、选区润色与 Agent 分别从导航进入。");
ui.small("请分别验证你使用的 X11/Wayland、GTK/Qt/浏览器/终端。托盘、自启和应用内自动更新仍未完整接入。");
});
}
fn start_ui(&mut self, ui: &mut egui::Ui) {
ui.heading("从一次听写开始");
ui.label("先准备 Linux 输入环境,再选择识别服务。切换页面不会停止正在进行的任务。");
if let Some(error) = &self.startup_error {
ui.colored_label(egui::Color32::YELLOW, format!("启动未完成:{error}"));
}
if let Some(snapshot) = &self.snapshot {
ui.label(if snapshot.running {
"Core:运行中"
} else {
"Core:未运行"
});
let credentials = &snapshot.credentials;
match credentials.pipeline_mode {
openless_core::shared_types::PipelineMode::Multimodal => {
ui.label("当前管线:多模态(Omni)");
ui.label(if credentials.omni_configured {
"Omni:已配置。"
} else {
"Omni:尚未配置,请到 AI 服务配置 Omni。"
});
}
openless_core::shared_types::PipelineMode::Traditional => {
ui.label("当前管线:传统(ASR + LLM)");
ui.label(if credentials.asr_configured {
"ASR 语音识别:已配置。"
} else {
"ASR 语音识别:尚未配置,请配置 AI 服务或激活本地模型。"
});
ui.label(if credentials.llm_configured {
"LLM 润色:已配置。"
} else {
"LLM 润色:尚未配置。"
});
}
}
ui.small("已配置不代表校验通过;请到 AI 服务验证连接。");
}
ui.horizontal_wrapped(|ui| {
for (page, label) in [
(Page::Settings, "1. 准备环境"),
(Page::Services, "2. 配置 AI 服务"),
(Page::Models, "使用本地模型"),
(Page::Dictation, "3. 打开听写"),
] {
if ui
.add_enabled(
self.native.is_some() || page == Page::Settings,
egui::Button::new(label),
)
.clicked()
{
self.navigation.open(page);
}
}
});
ui.separator();
if self.native.is_none() {
self.environment_ui(ui);
} else {
ui.strong("继续其他任务");
ui.horizontal_wrapped(|ui| {
for page in [
Page::Qa,
Page::Selection,
Page::Agent,
Page::Remote,
Page::History,
] {
if ui.button(page.label()).clicked() {
self.navigation.open(page);
}
}
});
ui.label("问答支持文字与语音;选区润色保留确认、取消与撤销;Less Computer 的工具执行继续使用原有审批。");
ui.small(
"Linux 当前提供已有 Core / Host 能力的入口,完整原生支持与发布验收仍在继续。",
);
}
}
fn page_activity(&self, page: Page) -> Option<&'static str> {
match page {
Page::Dictation
if self.snapshot.as_ref().is_some_and(|snapshot| {
matches!(
snapshot.dictation.phase,
DictationPhase::Starting
| DictationPhase::Recording
| DictationPhase::Transcribing
| DictationPhase::Polishing
| DictationPhase::Inserting
)
}) =>
{
Some("进行中")
}
Page::Qa if self.qa_visible => Some("会话"),
Page::Selection
if self.selection_preview_visible
&& self.selection.as_ref().is_some_and(|selection| {
selection.phase == SelectionPhase::Preview
}) =>
{
Some("待确认")
}
Page::Agent if self.pending_approval.is_some() => Some("待审批"),
Page::Agent if self.less_computer_running => Some("进行中"),
_ if self.navigation.has_update(page) => Some("有更新"),
_ => None,
}
}
fn navigation_button(&mut self, ui: &mut egui::Ui, page: Page) {
let label = match self.page_activity(page) {
Some(activity) => format!("{} · {activity}", page.label()),
None => page.label().to_string(),
};
if ui
.selectable_label(self.navigation.page == page, label)
.clicked()
{
self.navigation.open(page);
}
}
fn activity_ui(&mut self, ui: &mut egui::Ui) {
ui.horizontal_wrapped(|ui| {
for page in Page::ALL {
if let Some(activity) = self.page_activity(page) {
if ui
.link(format!("{} · {activity} →", page.label()))
.clicked()
{
self.navigation.open(page);
}
}
}
if self.native.is_some() && ui.button("取消当前语音 · Esc").clicked() {
self.cancel_voice();
}
if self.qa_visible && ui.button("取消问答").clicked() {
if let Some(backend) = self.backend() {
let session_id = self
.qa_state
.as_ref()
.and_then(|state| state.session_id.as_deref())
.and_then(|id| uuid::Uuid::parse_str(id).ok())
.map(openless_core::SessionId::from_uuid);
self.spawn(async move {
backend.services().qa.cancel(session_id).await?;
Ok("问答本轮已取消".to_string())
});
}
}
if let Some(session_id) = self
.selection
.as_ref()
.filter(|selection| selection.phase == SelectionPhase::Preview)
.and_then(|selection| selection.session_id)
{
if ui.button("取消选区预览").clicked() {
if let Some(backend) = self.backend() {
self.spawn(async move {
backend
.services()
.selection
.cancel(Some(session_id))
.await?;
Ok("选区替换已取消".to_string())
});
}
}
}
if (self.less_computer_running || self.pending_approval.is_some())
&& ui.button("取消 Agent").clicked()
{
if let Some(backend) = self.backend() {
self.spawn(async move {
backend.cancel_less_computer(None).await?;
Ok("Less Computer 已取消".to_string())
});
}
}
});
}
fn cancel_voice(&self) {
if let Some(backend) = self.backend() {
self.spawn(async move {
backend.cancel_active_voice_session(None).await?;
Ok("语音会话已取消".to_string())
});
}
}
fn history_ui(&mut self, ui: &mut egui::Ui) {
ui.heading("历史");
ui.label("最近 20 条,只读。插入、复制回退与已发送粘贴分别显示实际结果。");
let Some(backend) = self.backend() else {
return;
};
match backend.list_history() {
Ok(history) if history.is_empty() => {
ui.label("暂无历史记录");
}
Ok(history) => {
for item in history.into_iter().rev().take(20) {
let delivery = match item.insert_status {
HistoryInsertStatus::Inserted => "已插入",
HistoryInsertStatus::CopiedFallback => "已复制",
HistoryInsertStatus::PasteSent => "已发送粘贴",
HistoryInsertStatus::Failed => "失败",
HistoryInsertStatus::NotRequested => "未请求插入",
};
ui.label(format!(
"{} · {} · {}",
item.created_at, delivery, item.final_text
));
}
}
Err(error) => {
ui.label(error.to_string());
}
}
}
}
impl eframe::App for OpenLessEguiApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// Poll every frame before routing pages. Hidden pages retain their
// drafts, session ownership, event replay and async completion paths.
self.poll(ctx);
if ctx.input(|input| input.key_pressed(egui::Key::Escape)) {
self.cancel_voice();
}
egui::TopBottomPanel::top("status").show(ctx, |ui| {
ui.horizontal(|ui| {
ui.strong("OpenLess 2.0");
ui.separator();
ui.add(egui::Label::new(&self.status).truncate())
.on_hover_text(&self.status);
});
self.activity_ui(ui);
if self.pending_approval.is_some() {
ui.strong("Less Computer 等待审批");
self.agent_approval_ui(ui);
}
});
if ctx.screen_rect().width() < 760.0 {
egui::TopBottomPanel::top("compact_navigation").show(ctx, |ui| {
ui.horizontal_wrapped(|ui| {
for page in Page::ALL {
self.navigation_button(ui, page);
}
});
});
} else {
egui::SidePanel::left("navigation")
.resizable(false)
.default_width(176.0)
.show(ctx, |ui| {
egui::ScrollArea::vertical()
.id_salt("navigation_scroll")
.show(ui, |ui| {
ui.strong("工作空间");
ui.add_space(8.0);
for page in Page::ALL {
if page == Page::Services {
ui.separator();
ui.strong("准备与管理");
}
self.navigation_button(ui, page);
}
});
});
}
egui::CentralPanel::default().show(ctx, |ui| {
let page = self.navigation.page;
egui::ScrollArea::vertical()
.id_salt(("page", page))
.show(ui, |ui| {
if self.native.is_none() && !matches!(page, Page::Start | Page::Settings) {
ui.heading(page.label());
ui.label("Core 尚未连接,请先完成 Linux 环境准备并重新启动应用。");
if let Some(error) = &self.startup_error {
ui.colored_label(egui::Color32::YELLOW, error);
}
if ui.button("查看环境准备步骤").clicked() {
self.navigation.open(Page::Settings);
}
return;
}
match page {
Page::Start => self.start_ui(ui),
Page::Dictation => self.dictation_ui(ui),
Page::Qa => self.qa_ui(ui),
Page::Selection => self.selection_ui(ui),
Page::Agent => self.less_computer_ui(ui),
Page::Services => self.services_ui(ui),
Page::Models => self.models_ui(ui),
Page::Remote => self.remote_ui(ui),
Page::History => self.history_ui(ui),
Page::Settings => self.settings_ui(ui),
}
});
});
ctx.request_repaint_after(Duration::from_millis(50));
}
}
fn command_ui(ui: &mut egui::Ui, command: &str) {
ui.horizontal_wrapped(|ui| {
ui.monospace(command);
if ui.button("复制命令").clicked() {
ui.ctx().copy_text(command.to_string());
}
});
}
impl Drop for OpenLessEguiApp {
fn drop(&mut self) {
if let Some(native) = self.native.take() {
let _ = self.tokio.block_on(native.shutdown());
}
}
}
fn provider_kind(kind: openless_core::ChannelKind) -> openless_core::ProviderKind {
match kind {
openless_core::ChannelKind::Asr => openless_core::ProviderKind::Asr,
openless_core::ChannelKind::Llm => openless_core::ProviderKind::Llm,
}
}
fn provider_slot(kind: openless_core::ChannelKind) -> openless_core::ProviderSlot {
match kind {
openless_core::ChannelKind::Asr => openless_core::ProviderSlot::Asr,
openless_core::ChannelKind::Llm => openless_core::ProviderSlot::Llm,
}
}
fn provider_namespace(kind: openless_core::ChannelKind) -> openless_core::CredentialNamespace {
match kind {
openless_core::ChannelKind::Asr => openless_core::CredentialNamespace::Asr,
openless_core::ChannelKind::Llm => openless_core::CredentialNamespace::Llm,
}
}
fn endpoint_account(kind: openless_core::ChannelKind) -> &'static str {
match kind {
openless_core::ChannelKind::Asr => openless_core::credentials::ASR_ENDPOINT_ACCOUNT,
openless_core::ChannelKind::Llm => openless_core::credentials::LLM_ENDPOINT_ACCOUNT,
}
}
fn model_account(kind: openless_core::ChannelKind) -> &'static str {
match kind {
openless_core::ChannelKind::Asr => openless_core::credentials::ASR_MODEL_ACCOUNT,
openless_core::ChannelKind::Llm => openless_core::credentials::LLM_MODEL_ACCOUNT,
}
}
fn api_key_account(kind: openless_core::ChannelKind) -> &'static str {
match kind {
openless_core::ChannelKind::Asr => openless_core::credentials::ASR_API_KEY_ACCOUNT,
openless_core::ChannelKind::Llm => openless_core::credentials::LLM_API_KEY_ACCOUNT,
}
}
fn provider_credential_key(
kind: openless_core::ChannelKind,
channel_id: &str,
account: &str,
) -> Result<openless_core::CredentialKey, BackendError> {
openless_core::CredentialKey::new(
provider_namespace(kind),
Some(channel_id.to_string()),
account,
)
}
fn provider_descriptor_label(descriptor: &openless_core::ProviderDescriptor) -> String {
format!(
"{} ({})",
descriptor.label_key,
descriptor.provider_type.as_str()
)
}
fn auth_requirement_label(requirement: openless_core::AuthRequirement) -> &'static str {
match requirement {
openless_core::AuthRequirement::None => "无需 Secret",
openless_core::AuthRequirement::ApiKey => "API Key",
openless_core::AuthRequirement::EndpointModelOptionalApiKey => {
"Endpoint + Model,API Key 可选"
}
openless_core::AuthRequirement::ApiKeyUnlessCustomEndpoint => {
"公共 Endpoint 需要 API Key;自建 Endpoint 可无 Key"
}
openless_core::AuthRequirement::Volcengine => "火山引擎凭据",
openless_core::AuthRequirement::Xfyun => "讯飞 AppID + API Key",
openless_core::AuthRequirement::TencentCloud => "腾讯云 AppID + SecretID + SecretKey",
openless_core::AuthRequirement::OAuth => "OAuth",
}
}
fn provider_channel_descriptor(
panel: &ProviderPanel,
channel_id: &str,
) -> Option<(
openless_core::ChannelSummary,
openless_core::ProviderDescriptor,
)> {
let channel = panel
.channels
.iter()
.find(|channel| channel.id == channel_id)?
.clone();
let descriptor = panel
.descriptors
.iter()
.find(|descriptor| descriptor.provider_type.as_str() == channel.provider_type)
.cloned()
.or_else(|| {
openless_core::provider_rules::provider_descriptor(
provider_kind(panel.kind),
&channel.provider_type,
)
})?;
Some((channel, descriptor))
}
async fn read_provider_value(
backend: &openless_core::OpenLessBackend,
kind: openless_core::ChannelKind,
channel_id: &str,
account: &str,
) -> Result<Option<String>, BackendError> {
backend
.read_credential(provider_credential_key(kind, channel_id, account)?)
.await
.map(|value| value.map(openless_core::SecretValue::into_exposed))
}
async fn load_provider_editor(
backend: Arc<openless_core::OpenLessBackend>,
kind: openless_core::ChannelKind,
channel: openless_core::ChannelSummary,
descriptor: openless_core::ProviderDescriptor,
) -> Result<ProviderEditor, BackendError> {
let endpoint = read_provider_value(&backend, kind, &channel.id, endpoint_account(kind))
.await?
.or_else(|| descriptor.default_endpoint.clone())
.unwrap_or_default();
let model = read_provider_value(&backend, kind, &channel.id, model_account(kind))
.await?
.or_else(|| descriptor.default_model.clone())
.unwrap_or_default();
let (auth_mode, resource_id) =
if descriptor.auth_requirement == openless_core::AuthRequirement::Volcengine {
(
read_provider_value(
&backend,
kind,
&channel.id,
openless_core::credentials::VOLCENGINE_AUTH_MODE_ACCOUNT,
)
.await?
.unwrap_or_else(|| "app_id_token".to_string()),
read_provider_value(
&backend,
kind,
&channel.id,
openless_core::credentials::VOLCENGINE_RESOURCE_ID_ACCOUNT,
)
.await?
.unwrap_or_default(),
)
} else {
(String::new(), String::new())
};
let app_id = if descriptor.auth_requirement == openless_core::AuthRequirement::TencentCloud
{
read_provider_value(
&backend,
kind,
&channel.id,
openless_core::credentials::TENCENT_CLOUD_APP_ID_ACCOUNT,
)
.await?
.unwrap_or_default()
} else {
String::new()
};
Ok(ProviderEditor {
kind,
name: channel.name.clone(),
channel,
descriptor,
endpoint,
model,
auth_mode,
resource_id,
app_id,
primary_secret: String::new(),
secondary_secret: String::new(),
})
}
fn secret_edit(ui: &mut egui::Ui, label: &str, value: &mut String) {
ui.horizontal(|ui| {
ui.label(label);
ui.add(egui::TextEdit::singleline(value).password(true));
});
}
fn provider_fields_ui(ui: &mut egui::Ui, editor: &mut ProviderEditor) {
// This match chooses which input controls to render; it does not decide
// whether credentials are sufficient. ProviderService validates the
// descriptor's AuthRequirement again before any protocol request.
match editor.descriptor.auth_requirement {
openless_core::AuthRequirement::None => {
ui.label("此 Provider 不使用云凭据;模型由本地模型面板管理。");
}
openless_core::AuthRequirement::OAuth => {
ui.label("此 Provider 使用 OAuth;Linux egui 不读取或显示 OAuth token。");
}
openless_core::AuthRequirement::Volcengine => {
egui::ComboBox::from_id_salt("volcengine-auth-mode")
.selected_text(&editor.auth_mode)
.show_ui(ui, |ui| {
ui.selectable_value(
&mut editor.auth_mode,
"app_id_token".to_string(),
"APP ID + Access Token",
);
ui.selectable_value(
&mut editor.auth_mode,
"api_key".to_string(),
"API Key",
);
});
if editor.auth_mode == "api_key" {
secret_edit(ui, "API Key", &mut editor.primary_secret);
} else {
secret_edit(ui, "APP ID", &mut editor.primary_secret);
secret_edit(ui, "Access Token", &mut editor.secondary_secret);
}
ui.horizontal(|ui| {
ui.label("Resource ID");
ui.text_edit_singleline(&mut editor.resource_id);
});
ui.horizontal(|ui| {
ui.label("Model");
ui.text_edit_singleline(&mut editor.model);
});
}
openless_core::AuthRequirement::Xfyun => {
secret_edit(ui, "AppID", &mut editor.primary_secret);
secret_edit(ui, "API Key", &mut editor.secondary_secret);
}
openless_core::AuthRequirement::TencentCloud => {
ui.horizontal(|ui| {
ui.label("腾讯云 AppID");
ui.text_edit_singleline(&mut editor.app_id);
});
secret_edit(ui, "SecretID", &mut editor.primary_secret);
secret_edit(ui, "SecretKey", &mut editor.secondary_secret);
ui.horizontal(|ui| {
ui.label("Model");
ui.text_edit_singleline(&mut editor.model);
});
}
_ => {
secret_edit(ui, "API Key(留空表示不修改)", &mut editor.primary_secret);
ui.horizontal(|ui| {
ui.label("Endpoint");
ui.text_edit_singleline(&mut editor.endpoint);
});
ui.horizontal(|ui| {
ui.label("Model");
ui.text_edit_singleline(&mut editor.model);
});
}
}
}
async fn write_or_remove_provider_value(
backend: &openless_core::OpenLessBackend,
kind: openless_core::ChannelKind,
channel_id: &str,
account: &str,
value: &str,
) -> Result<(), BackendError> {
let key = provider_credential_key(kind, channel_id, account)?;
if value.trim().is_empty() {
backend.remove_credential(key).await?;
} else {
backend
.set_credential(key, openless_core::SecretValue::new(value.trim()))
.await?;
}
Ok(())
}
async fn write_secret_if_entered(
backend: &openless_core::OpenLessBackend,
kind: openless_core::ChannelKind,
channel_id: &str,
account: &str,
value: &str,
) -> Result<(), BackendError> {
let value = value.trim();
if value.is_empty() {
return Ok(());
}
backend
.set_credential(
provider_credential_key(kind, channel_id, account)?,
openless_core::SecretValue::new(value),
)
.await?;
Ok(())
}
async fn save_provider_editor(
backend: Arc<openless_core::OpenLessBackend>,
editor: ProviderEditor,
) -> Result<(), BackendError> {
// Account names are the stable credential wire schema exported by
// Core. Defaults and required/optional semantics stay in the selected
// ProviderDescriptor and ProviderService, never in this Host form.
let channel_id = editor.channel.id.as_str();
backend
.rename_channel(editor.kind, channel_id.to_string(), editor.name)
.await?;
match editor.descriptor.auth_requirement {
openless_core::AuthRequirement::None | openless_core::AuthRequirement::OAuth => {}
openless_core::AuthRequirement::Volcengine => {
write_or_remove_provider_value(
&backend,
editor.kind,
channel_id,
openless_core::credentials::VOLCENGINE_AUTH_MODE_ACCOUNT,
&editor.auth_mode,
)
.await?;
write_or_remove_provider_value(
&backend,
editor.kind,
channel_id,
openless_core::credentials::VOLCENGINE_RESOURCE_ID_ACCOUNT,
&editor.resource_id,
)
.await?;
write_or_remove_provider_value(
&backend,
editor.kind,
channel_id,
model_account(editor.kind),
&editor.model,
)
.await?;
if editor.auth_mode == "api_key" {
write_secret_if_entered(
&backend,
editor.kind,
channel_id,
openless_core::credentials::VOLCENGINE_API_KEY_ACCOUNT,
&editor.primary_secret,
)
.await?;
} else {
write_secret_if_entered(
&backend,
editor.kind,
channel_id,
openless_core::credentials::VOLCENGINE_APP_KEY_ACCOUNT,
&editor.primary_secret,
)
.await?;
write_secret_if_entered(
&backend,
editor.kind,
channel_id,
openless_core::credentials::VOLCENGINE_ACCESS_KEY_ACCOUNT,
&editor.secondary_secret,
)
.await?;
}
}
openless_core::AuthRequirement::Xfyun => {
write_secret_if_entered(
&backend,
editor.kind,
channel_id,
openless_core::credentials::XFYUN_APP_ID_ACCOUNT,
&editor.primary_secret,
)
.await?;
write_secret_if_entered(
&backend,
editor.kind,
channel_id,
openless_core::credentials::XFYUN_API_KEY_ACCOUNT,
&editor.secondary_secret,
)
.await?;
}
openless_core::AuthRequirement::TencentCloud => {
write_or_remove_provider_value(
&backend,
editor.kind,
channel_id,
openless_core::credentials::TENCENT_CLOUD_APP_ID_ACCOUNT,
&editor.app_id,
)
.await?;
write_secret_if_entered(
&backend,
editor.kind,
channel_id,
openless_core::credentials::TENCENT_CLOUD_SECRET_ID_ACCOUNT,
&editor.primary_secret,
)
.await?;
write_secret_if_entered(
&backend,
editor.kind,
channel_id,
openless_core::credentials::TENCENT_CLOUD_SECRET_KEY_ACCOUNT,
&editor.secondary_secret,
)
.await?;
write_or_remove_provider_value(
&backend,
editor.kind,
channel_id,
model_account(editor.kind),
&editor.model,
)
.await?;
}
_ => {
write_or_remove_provider_value(
&backend,
editor.kind,
channel_id,
endpoint_account(editor.kind),
&editor.endpoint,
)
.await?;
write_or_remove_provider_value(
&backend,
editor.kind,
channel_id,
model_account(editor.kind),
&editor.model,
)
.await?;
write_secret_if_entered(
&backend,
editor.kind,
channel_id,
api_key_account(editor.kind),
&editor.primary_secret,
)
.await?;
}
}
Ok(())
}
async fn clear_provider_secrets(
backend: Arc<openless_core::OpenLessBackend>,
editor: &ProviderEditor,
) -> Result<(), BackendError> {
let accounts: &[&str] = match editor.descriptor.auth_requirement {
openless_core::AuthRequirement::None | openless_core::AuthRequirement::OAuth => &[],
openless_core::AuthRequirement::Volcengine => &[
openless_core::credentials::VOLCENGINE_APP_KEY_ACCOUNT,
openless_core::credentials::VOLCENGINE_ACCESS_KEY_ACCOUNT,
openless_core::credentials::VOLCENGINE_API_KEY_ACCOUNT,
],
openless_core::AuthRequirement::Xfyun => &[
openless_core::credentials::XFYUN_APP_ID_ACCOUNT,
openless_core::credentials::XFYUN_API_KEY_ACCOUNT,
],
openless_core::AuthRequirement::TencentCloud => &[
openless_core::credentials::TENCENT_CLOUD_APP_ID_ACCOUNT,
openless_core::credentials::TENCENT_CLOUD_SECRET_ID_ACCOUNT,
openless_core::credentials::TENCENT_CLOUD_SECRET_KEY_ACCOUNT,
],
_ => &[api_key_account(editor.kind)],
};
for account in accounts {
backend
.remove_credential(provider_credential_key(
editor.kind,
&editor.channel.id,
account,
)?)
.await?;
}
Ok(())
}
async fn validate_provider_channel(
backend: Arc<openless_core::OpenLessBackend>,
kind: openless_core::ChannelKind,
channel_id: String,
) -> Result<String, BackendError> {
let started = std::time::Instant::now();
let result = backend
.services()
.provider
.validate(openless_core::ProviderRequest {
thinking_enabled: backend.get_preferences().llm_thinking_enabled,
kind: provider_kind(kind),
channel_id: Some(channel_id.clone()),
})
.await;
let latency_ms = started.elapsed().as_millis().min(u128::from(u32::MAX)) as u32;
match result {
Ok(_) => {
backend
.record_channel_test(kind, channel_id, true, Some(latency_ms), None)
.await?;
Ok(format!("Provider 验证通过({latency_ms} ms)"))
}
Err(error) => {
let _ = backend
.record_channel_test(
kind,
channel_id,
false,
Some(latency_ms),
Some(error.message.clone()),
)
.await;
Err(error)
}
}
}
fn package_kind() -> LinuxPackageKind {
if std::env::var_os("APPDIR").is_some() {
LinuxPackageKind::AppImage
} else if cfg!(debug_assertions) {
LinuxPackageKind::Development
} else {
LinuxPackageKind::SystemPackage
}
}
fn backend_config() -> Result<BackendConfig, String> {
let home = std::env::var_os("HOME").map(std::path::PathBuf::from);
let data_dir = std::env::var_os("XDG_DATA_HOME")
.map(std::path::PathBuf::from)
.or_else(|| home.as_ref().map(|home| home.join(".local/share")))
.ok_or_else(|| "HOME/XDG_DATA_HOME is unavailable".to_string())?
.join("OpenLess");
let cache_dir = std::env::var_os("XDG_CACHE_HOME")
.map(std::path::PathBuf::from)
.or_else(|| home.as_ref().map(|home| home.join(".cache")))
.ok_or_else(|| "HOME/XDG_CACHE_HOME is unavailable".to_string())?
.join("OpenLess");
std::fs::create_dir_all(&data_dir).map_err(|error| error.to_string())?;
std::fs::create_dir_all(&cache_dir).map_err(|error| error.to_string())?;
let kind = package_kind();
let capabilities = LinuxCapabilitySnapshot::detect(false, kind).capabilities;
Ok(BackendConfig {
data_dir,
cache_dir,
home_dir: home,
resource_dir: std::env::current_exe()
.ok()
.and_then(|path| path.parent().map(std::path::Path::to_path_buf)),
platform: capabilities,
locale: std::env::var("LANG").unwrap_or_else(|_| "en-US".to_string()),
})
}
fn ensure_fcitx5_ready(config: &BackendConfig) -> Result<FcitxPluginStatus, String> {
let home = config
.home_dir
.as_deref()
.ok_or_else(|| "HOME is unavailable for the fcitx5 plugin".to_string())?;
let layout = LinuxResourceLayout::detect(None).map_err(|error| error.to_string())?;
let plan =
FcitxPluginInstallPlan::for_layout(&layout, home).map_err(|error| error.to_string())?;
ensure_fcitx5_plugin_installed(&plan).map_err(|error| error.to_string())
}
pub fn run() -> Result<(), String> {
let tokio = Arc::new(tokio::runtime::Runtime::new().map_err(|error| error.to_string())?);
let config = backend_config()?;
let runtime_dir = std::env::var_os("XDG_RUNTIME_DIR")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| config.cache_dir.join("runtime"));
let args = std::env::args().collect::<Vec<_>>();
let broker = match SingleInstanceBroker::acquire_or_forward(
&runtime_dir.join("openless.lock"),
&runtime_dir.join("openless.sock"),
LinuxLaunchIntent::from_args(&args),
)
.map_err(|error| error.to_string())?
{
SingleInstanceRole::Primary(broker) => broker,
SingleInstanceRole::Forwarded => return Ok(()),
};
let plugin_check = ensure_fcitx5_ready(&config);
let environment = LinuxCapabilitySnapshot::detect(false, package_kind());
let native = (|| {
// AppImage may need to materialize its bundled plugin into the
// per-user fcitx5 search path. Do that before opening the DBus
// listener: otherwise the first run can wait forever for signals
// from a plugin fcitx5 has never loaded.
match &plugin_check {
Ok(FcitxPluginStatus::Ready) => {}
Ok(FcitxPluginStatus::Updated) => return Err(
"fcitx5 插件已安装或更新;请重载配置(fcitx5-remote -r),重新启动 fcitx5 或重新登录桌面,再启动 OpenLess".to_string()
),
Ok(FcitxPluginStatus::Missing) => return Err(
"未找到 OpenLess fcitx5 插件;请重新安装当前软件包".to_string()
),
Err(error) => return Err(error.clone()),
}
let hotkeys = Fcitx5HotkeyListener::start().map_err(|error| error.to_string())?;
let backend = {
// Construction captures the existing executor for cpal/native
// callbacks. The GUI thread leaves its context before block_on;
// no extra runtime or per-callback runtime is created.
let _runtime_context = tokio.enter();
LinuxBackendBuilder::from_shared_providers(config)
.map_err(|error| error.to_string())?
.build()
.map_err(|error| error.to_string())?
};
tokio
.block_on(LinuxNativeRuntime::start(
backend,
Some(broker),
Some(hotkeys),
))
.map_err(|error| error.to_string())
})();
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([1040.0, 760.0])
.with_min_inner_size([420.0, 400.0]),
..Default::default()
};
eframe::run_native(
"OpenLess",
options,
Box::new(move |_| {
let mut app = OpenLessEguiApp::new(tokio, native);
app.environment = Some(environment);
app.plugin_check = Some(plugin_check);
Ok(Box::new(app))
}),
)
.map_err(|error| error.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn disconnected_app() -> OpenLessEguiApp {
OpenLessEguiApp::new(
Arc::new(tokio::runtime::Runtime::new().unwrap()),
Err("fixture: plugin unavailable".into()),
)
}
fn rendered_text(mut draw: impl FnMut(&mut egui::Ui)) -> String {
let ctx = egui::Context::default();
let output = ctx.run(
egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(720.0, 1800.0),
)),
..Default::default()
},
|ctx| {
egui::CentralPanel::default().show(ctx, |ui| {
draw(ui);
});
},
);
output
.shapes
.into_iter()
.filter_map(|shape| match shape.shape {
egui::epaint::Shape::Text(text) => Some(text.galley.job.text.clone()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn start_page_pipeline_multimodal_uses_only_omni_configuration() {
use openless_core::shared_types::PipelineMode;
let mut app = disconnected_app();
// A pending settings draft must not override Core's effective mode.
app.preferences = Some(UserPreferences {
multimodal_pipeline_enabled: false,
pipeline_mode: PipelineMode::Traditional,
..Default::default()
});
for omni_configured in [true, false] {
for (asr_configured, llm_configured) in
[(false, false), (true, false), (false, true), (true, true)]
{
app.snapshot = Some(BackendSnapshot {
credentials: openless_core::shared_types::CredentialsStatus {
pipeline_mode: PipelineMode::Multimodal,
omni_configured,
asr_configured,
llm_configured,
..Default::default()
},
..Default::default()
});
let text = rendered_text(|ui| app.start_ui(ui));
let expected = if omni_configured {
"Omni:已配置"
} else {
"Omni:尚未配置"
};
assert!(text.contains(expected), "missing {expected}: {text}");
assert!(!text.contains("语音识别:尚未配置"), "{text}");
assert!(!text.contains("ASR 语音识别:"), "{text}");
assert!(!text.contains("LLM 润色:"), "{text}");
assert!(text.contains("已配置不代表校验通过"), "{text}");
}
}
}
#[test]
fn start_page_pipeline_traditional_reports_asr_and_llm_independently() {
use openless_core::shared_types::PipelineMode;
let mut app = disconnected_app();
// Conversely, a multimodal draft must not hide the effective
// traditional pipeline's missing ASR or LLM configuration.
app.preferences = Some(UserPreferences {
multimodal_pipeline_enabled: true,
pipeline_mode: PipelineMode::Multimodal,
..Default::default()
});
for omni_configured in [true, false] {
for (asr_configured, llm_configured) in
[(false, false), (true, false), (false, true), (true, true)]
{
app.snapshot = Some(BackendSnapshot {
credentials: openless_core::shared_types::CredentialsStatus {
pipeline_mode: PipelineMode::Traditional,
omni_configured,
asr_configured,
llm_configured,
..Default::default()
},
..Default::default()
});
let text = rendered_text(|ui| app.start_ui(ui));
for expected in [
if asr_configured {
"ASR 语音识别:已配置"
} else {
"ASR 语音识别:尚未配置"
},
if llm_configured {
"LLM 润色:已配置"
} else {
"LLM 润色:尚未配置"
},
] {
assert!(text.contains(expected), "missing {expected}: {text}");
}
assert!(!text.contains("Omni:"), "{text}");
assert!(text.contains("已配置不代表校验通过"), "{text}");
}
}
}
#[test]
fn background_approval_survives_navigation_and_stale_terminals() {
let mut app = disconnected_app();
let session = openless_core::SessionId::new();
for (sequence, kind) in [
(
1,
LessComputerEventKind::User {
text: "task".into(),
fresh: true,
},
),
(
2,
LessComputerEventKind::Approval {
token: "approval".into(),
command: "echo test".into(),
reason: "fixture".into(),
},
),
] {
app.apply_event(BackendEvent {
sequence,
session_id: Some(session),
kind: BackendEventKind::LessComputerEvent(openless_core::LessComputerEvent {
seq: None,
kind,
}),
});
}
for page in Page::ALL {
app.navigation.open(page);
assert_eq!(app.page_activity(Page::Agent), Some("待审批"));
let text = rendered_text(|ui| {
app.activity_ui(ui);
app.agent_approval_ui(ui);
});
for control in ["允许", "拒绝", "取消 Agent"] {
assert!(
text.contains(control),
"missing {control} on {page:?}: {text}"
);
}
}
app.apply_event(BackendEvent {
sequence: 3,
session_id: Some(openless_core::SessionId::new()),
kind: BackendEventKind::LessComputerEvent(openless_core::LessComputerEvent {
seq: None,
kind: LessComputerEventKind::Cancelled,
}),
});
assert_eq!(
app.pending_approval,
Some(("approval".into(), "echo test".into()))
);
assert!(app.less_computer_running);
}
#[test]
fn qa_and_selection_events_on_settings_keep_drafts_and_action_notices() {
let mut app = disconnected_app();
app.navigation.open(Page::Settings);
app.qa_input = "unsent question".into();
let qa_session = openless_core::SessionId::new();
let mut thinking = QaStateEvent::simple(QaStateKind::Thinking);
thinking.session_id = Some(qa_session.to_string());
app.apply_event(BackendEvent {
sequence: 1,
session_id: Some(qa_session),
kind: BackendEventKind::QaState(thinking),
});
let selection_session = openless_core::SessionId::new();
app.apply_event(BackendEvent {
sequence: 2,
session_id: Some(selection_session),
kind: BackendEventKind::SelectionStateChanged(SelectionSnapshot {
phase: SelectionPhase::Preview,
session_id: Some(selection_session),
preview_text: Some("editable preview".into()),
..Default::default()
}),
});
assert_eq!(app.navigation.page, Page::Settings);
assert!(app.navigation.has_update(Page::Qa));
assert_eq!(app.page_activity(Page::Selection), Some("待确认"));
app.selection_draft = "user edited preview".into();
app.navigation.open(Page::Qa);
app.navigation.open(Page::Selection);
app.navigation.open(Page::Models);
assert_eq!(app.qa_input, "unsent question");
assert_eq!(app.selection_draft, "user edited preview");
assert_eq!(
app.selection.as_ref().unwrap().session_id,
Some(selection_session)
);
let text = rendered_text(|ui| app.activity_ui(ui));
assert!(text.contains("取消选区预览"), "{text}");
}
#[test]
fn startup_failure_keeps_preparation_steps_without_claiming_connection() {
let mut app = disconnected_app();
app.environment = Some(LinuxCapabilitySnapshot::from_environment(
Some("wayland-0"),
None,
false,
false,
LinuxPackageKind::AppImage,
));
app.plugin_check = Some(Ok(FcitxPluginStatus::Updated));
let text = rendered_text(|ui| app.start_ui(ui));
for expected in [
"Core 未连接",
"Wayland",
"D-Bus 探测未通过",
"尚未验证录音",
"fcitx5-diagnose",
"fcitx5-remote -r",
"Secret Service",
] {
assert!(text.contains(expected), "missing {expected}: {text}");
}
assert!(!text.contains("Core 已连接"));
assert!(!text.contains("Core:运行中"));
}
#[test]
fn stopped_or_stale_remote_status_never_shows_pairing_secrets_or_old_urls() {
let mut app = disconnected_app();
for (running, urls_stale) in [(false, false), (true, true)] {
app.remote_access = Some((
openless_core::RemoteInputStatus {
enabled: true,
running,
starting: false,
port: 8443,
urls: vec!["https://old.example.invalid".into()],
urls_stale,
locale: "en".into(),
connection_count: 0,
active_session_id: None,
},
"fixture-pin".into(),
));
let text = rendered_text(|ui| app.remote_ui(ui));
assert!(!text.contains("fixture-pin"), "{text}");
assert!(!text.contains("https://old.example.invalid"), "{text}");
assert!(text.contains("当前连接数:0"), "{text}");
}
}
#[test]
fn continuation_turn_keeps_receiving_output_and_approval() {
let mut app = OpenLessEguiApp::new(
Arc::new(tokio::runtime::Runtime::new().unwrap()),
Err("fixture".into()),
);
let first = openless_core::SessionId::new();
let second = openless_core::SessionId::new();
for (sequence, session, kind) in [
(
1,
first,
LessComputerEventKind::User {
text: "first".into(),
fresh: true,
},
),
(
2,
first,
LessComputerEventKind::Completed {
text: "first answer".into(),
cost_usd: None,
},
),
(
3,
second,
LessComputerEventKind::User {
text: "follow up".into(),
fresh: false,
},
),
(
4,
second,
LessComputerEventKind::Delta {
text: "second answer".into(),
},
),
(
5,
second,
LessComputerEventKind::Approval {
token: "approval".into(),
command: "echo test".into(),
reason: "test".into(),
},
),
(
6,
first,
LessComputerEventKind::Delta {
text: "stale".into(),
},
),
] {
app.apply_event(BackendEvent {
sequence,
session_id: Some(session),
kind: BackendEventKind::LessComputerEvent(openless_core::LessComputerEvent {
seq: None,
kind,
}),
});
}
assert_eq!(app.less_computer_session, Some(second));
assert!(app.less_computer_output.ends_with("second answer"));
assert_eq!(
app.pending_approval,
Some(("approval".into(), "echo test".into()))
);
}
#[test]
fn qa_deltas_accumulate_without_hiding_conversation_history() {
let mut app = OpenLessEguiApp::new(
Arc::new(tokio::runtime::Runtime::new().unwrap()),
Err("fixture".into()),
);
let session = openless_core::SessionId::new();
let mut thinking = QaStateEvent::simple(QaStateKind::Thinking);
thinking.session_id = Some(session.to_string());
thinking.messages = Some(vec![openless_core::shared_types::QaChatMessage {
role: "user".into(),
content: "question".into(),
selection_text: None,
}]);
app.apply_event(BackendEvent {
sequence: 1,
session_id: Some(session),
kind: BackendEventKind::QaState(thinking),
});
for (sequence, chunk) in [(2, "Hello"), (3, " world")] {
let mut delta = QaStateEvent::simple(QaStateKind::AnswerDelta);
delta.session_id = Some(session.to_string());
delta.chunk = Some(chunk.into());
app.apply_event(BackendEvent {
sequence,
session_id: Some(session),
kind: BackendEventKind::QaState(delta),
});
}
let state = app.qa_state.as_ref().unwrap();
assert_eq!(state.chunk.as_deref(), Some("Hello world"));
assert_eq!(state.messages.as_ref().unwrap()[0].content, "question");
}
}
}
#[cfg(target_os = "linux")]
fn main() {
if let Err(error) = linux_app::run() {
eprintln!("OpenLess Linux UI failed: {error}");
std::process::exit(1);
}
}