feat(desktop): settings features, bridge panel fixes, align startup with test
Add settings "Features" section with mykey import and service manager entry. Split spawnBridge (panel) from test-style startBridge boot; fix bridge-exit UI race; restore takeover_stale_bridge on launch; allow Tauri IPC for start_bridge from remote bridge UI.
This commit is contained in:
1 parent
333476c4e5
commit
d4560ea7bf
5 files changed
+155
-15
No files matched your search
@@ -13,6 +13,9 @@
|
||||
"core:window:allow-set-focus",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-get-all-windows",
|
||||
"core:webview:default"
|
||||
"core:webview:default",
|
||||
"allow-start-bridge",
|
||||
"allow-start-bridge-with-config",
|
||||
"allow-get-config"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[[permission]]
|
||||
identifier = "allow-start-bridge"
|
||||
description = "Spawn bridge from the services panel when bridge was stopped."
|
||||
commands.allow = ["start_bridge"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-start-bridge-with-config"
|
||||
description = "Spawn bridge from setup with explicit python/project paths."
|
||||
commands.allow = ["start_bridge_with_config"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-get-config"
|
||||
description = "Read saved bridge spawn configuration."
|
||||
commands.allow = ["get_config"]
|
||||
@@ -348,6 +348,71 @@ fn run_offline_prepare(project_dir: &str, report: &dyn Fn(i32, &str)) -> Result<
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// GET /services/identity from a running bridge; returns the ga_root it serves (or None
|
||||
/// when the endpoint is absent — i.e. an older/foreign bridge).
|
||||
fn bridge_reported_ga_root() -> Option<String> {
|
||||
use std::io::{Read, Write};
|
||||
let mut stream = TcpStream::connect_timeout(
|
||||
&"127.0.0.1:14168".parse().unwrap(),
|
||||
Duration::from_millis(800),
|
||||
).ok()?;
|
||||
let _ = stream.set_read_timeout(Some(Duration::from_millis(800)));
|
||||
let _ = stream.set_write_timeout(Some(Duration::from_millis(600)));
|
||||
let req = b"GET /services/identity HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n";
|
||||
stream.write_all(req).ok()?;
|
||||
let mut buf = Vec::new();
|
||||
let _ = stream.read_to_end(&mut buf);
|
||||
let text = String::from_utf8_lossy(&buf);
|
||||
let body = text.split("\r\n\r\n").nth(1)?;
|
||||
let v: serde_json::Value = serde_json::from_str(body.trim()).ok()?;
|
||||
v.get("ga_root")?.as_str().map(|s| s.to_string())
|
||||
}
|
||||
|
||||
fn norm_path(p: &str) -> String {
|
||||
std::fs::canonicalize(p)
|
||||
.map(|c| c.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|_| p.to_string())
|
||||
}
|
||||
|
||||
fn bridge_identity_matches(project_dir: &str) -> bool {
|
||||
let Some(reported) = bridge_reported_ga_root() else { return false; };
|
||||
let (a, b) = (norm_path(&reported), norm_path(project_dir));
|
||||
#[cfg(windows)]
|
||||
{ a.eq_ignore_ascii_case(&b) }
|
||||
#[cfg(not(windows))]
|
||||
{ a == b }
|
||||
}
|
||||
|
||||
fn request_bridge_shutdown() {
|
||||
use std::io::{Read, Write};
|
||||
let Ok(mut stream) = TcpStream::connect_timeout(
|
||||
&"127.0.0.1:14168".parse().unwrap(),
|
||||
Duration::from_millis(800),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let _ = stream.set_read_timeout(Some(Duration::from_millis(600)));
|
||||
let _ = stream.set_write_timeout(Some(Duration::from_millis(600)));
|
||||
let req = b"POST /services/bridge/exit HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
|
||||
let _ = stream.write_all(req);
|
||||
let _ = stream.read(&mut [0u8; 512]);
|
||||
}
|
||||
|
||||
fn takeover_stale_bridge(project_dir: &str) {
|
||||
if project_dir.is_empty() || !is_bridge_running() {
|
||||
return;
|
||||
}
|
||||
if bridge_identity_matches(project_dir) {
|
||||
return;
|
||||
}
|
||||
eprintln!("[tauri] a different/stale bridge holds 127.0.0.1:14168; taking over");
|
||||
request_bridge_shutdown();
|
||||
let start = Instant::now();
|
||||
while is_bridge_running() && start.elapsed() < Duration::from_secs(10) {
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
}
|
||||
|
||||
fn is_bridge_running() -> bool {
|
||||
TcpStream::connect(("127.0.0.1", 14168)).is_ok()
|
||||
}
|
||||
@@ -438,10 +503,11 @@ pub fn run() {
|
||||
let dev_mode = args.iter().any(|a| a == "--dev");
|
||||
|
||||
let project_dir = find_project_dir().unwrap_or_default();
|
||||
let needs_prepare = needs_first_run_prepare(&project_dir);
|
||||
|
||||
takeover_stale_bridge(&project_dir);
|
||||
|
||||
let bridge_ok = is_bridge_running();
|
||||
// If any bridge is already present, attach to it immediately. Prepare is only needed before
|
||||
// this app starts a bridge from its own bundled runtime.
|
||||
let needs_prepare = !bridge_ok && needs_first_run_prepare(&project_dir);
|
||||
let mut spawned_bridge = false;
|
||||
// Skip the early spawn when a first-run prepare is required (no venv yet);
|
||||
// the setup thread prepares the env first and then starts the bridge.
|
||||
|
||||
@@ -50,6 +50,8 @@ const gaServiceStore = {
|
||||
get: (id) => _serviceById[id],
|
||||
};
|
||||
|
||||
let bridgeUiOffline = false;
|
||||
|
||||
/* ═══════════════ Bridge 适配(HTTP 命令 + WS 状态) ═══════════════ */
|
||||
(function initGaBridge() {
|
||||
const listeners = new Map();
|
||||
@@ -238,13 +240,17 @@ const gaServiceStore = {
|
||||
return res;
|
||||
}
|
||||
|
||||
async function startBridge() {
|
||||
async function spawnBridge() {
|
||||
connectWs();
|
||||
try {
|
||||
return await http('/status');
|
||||
const status = await http('/status');
|
||||
bridgeUiOffline = false;
|
||||
return status;
|
||||
} catch (_) {
|
||||
await tauriInvoke('start_bridge');
|
||||
return waitBridgeStatus();
|
||||
const status = await waitBridgeStatus();
|
||||
bridgeUiOffline = false;
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,7 +265,8 @@ const gaServiceStore = {
|
||||
|
||||
window.ga = {
|
||||
platform: navigator.platform.toLowerCase().includes('mac') ? 'darwin' : 'win32',
|
||||
startBridge,
|
||||
startBridge: async () => { connectWs(); return http('/status'); },
|
||||
spawnBridge,
|
||||
stopBridge: async () => ({ ok: true }),
|
||||
exitBridge,
|
||||
checkStatus: () => rpc('app/status', {}),
|
||||
@@ -275,6 +282,7 @@ const gaServiceStore = {
|
||||
getServicePanel: () => rpc('services/panel', {}),
|
||||
getMykeyContent: () => rpc('services/mykey/get', {}),
|
||||
saveMykeyContent: (content) => rpc('services/mykey/save', { content }),
|
||||
setBridgeUiOffline: (offline) => { bridgeUiOffline = !!offline; },
|
||||
pollSession: (sessionId, afterId = 0) => rpc('session/poll', { sessionId, afterId }),
|
||||
rpc,
|
||||
onBridgeMessage: (cb) => on('bridge-message', cb),
|
||||
@@ -323,7 +331,7 @@ const I18N = {
|
||||
'customPreset.removeTitle': '删除',
|
||||
'customPreset.editTitle': '编辑',
|
||||
'builtinPreset.restoreBtn': '恢复默认预设',
|
||||
'set.appearance': '外观', 'set.plainUi': '素色', 'set.fontSize': '聊天字号', 'set.lang': '语言', 'set.model': '模型', 'set.addModel': '添加模型', 'set.serviceManager': '后台服务管理',
|
||||
'set.appearance': '外观', 'set.plainUi': '素色', 'set.fontSize': '聊天字号', 'set.lang': '语言', 'set.model': '模型', 'set.addModel': '添加模型', 'set.features': '功能', 'set.importMykey': '导入已有 mykey.py', 'set.serviceManager': '后台服务管理',
|
||||
'appearance.light': '浅色', 'appearance.dark': '深色',
|
||||
'set.noModels': '暂无模型,点击下方添加',
|
||||
'lang.zh': '简体中文', 'lang.en': 'English',
|
||||
@@ -429,11 +437,13 @@ const I18N = {
|
||||
'ch.loading': '加载中…', 'ch.empty': '未发现 IM 进程脚本',
|
||||
'ch.logEmpty': '暂无日志',
|
||||
'err.channelLoad': '加载失败', 'err.channelStart': '启动失败', 'err.channelStop': '停止失败',
|
||||
'err.mykeyImport': '导入 mykey.py 失败',
|
||||
'err.channelNotConfigured': '请先在 mykey.py 中配置该平台',
|
||||
'sys.channelStarted': '已启动', 'sys.channelStopped': '已停止',
|
||||
'modal.channelLogs': '进程日志',
|
||||
'modal.mykeyConfig': 'mykey.py 配置',
|
||||
'sys.configSaved': '配置已保存',
|
||||
'sys.mykeyImported': 'mykey.py 已导入',
|
||||
'st.starting': '启动中…', 'st.stopping': '停止中…', 'st.online': '在线', 'st.offline': '离线', 'st.error': '错误', 'st.running': '运行', 'st.abnormal': '异常',
|
||||
'act.configure': '配置', 'act.logs': '日志', 'act.restart': '重启', 'act.stop': '停止', 'act.start': '启动', 'act.exit': '退出',
|
||||
'act.copy': '复制', 'act.copied': '已复制', 'act.copyTex': 'TeX', 'act.send': '发送',
|
||||
@@ -486,7 +496,7 @@ const I18N = {
|
||||
'customPreset.removeTitle': 'Delete',
|
||||
'customPreset.editTitle': 'Edit',
|
||||
'builtinPreset.restoreBtn': 'Restore defaults',
|
||||
'set.appearance': 'Appearance', 'set.plainUi': 'Plain', 'set.fontSize': 'Chat font size', 'set.lang': 'Language', 'set.model': 'Model', 'set.addModel': 'Add model', 'set.serviceManager': 'Service manager',
|
||||
'set.appearance': 'Appearance', 'set.plainUi': 'Plain', 'set.fontSize': 'Chat font size', 'set.lang': 'Language', 'set.model': 'Model', 'set.addModel': 'Add model', 'set.features': 'Features', 'set.importMykey': 'Import mykey.py', 'set.serviceManager': 'Service manager',
|
||||
'appearance.light': 'Light', 'appearance.dark': 'Dark',
|
||||
'set.noModels': 'No models yet — add one below',
|
||||
'lang.zh': '简体中文', 'lang.en': 'English',
|
||||
@@ -592,11 +602,13 @@ const I18N = {
|
||||
'ch.loading': 'Loading…', 'ch.empty': 'No IM process scripts found',
|
||||
'ch.logEmpty': 'No log output yet',
|
||||
'err.channelLoad': 'Failed to load', 'err.channelStart': 'Start failed', 'err.channelStop': 'Stop failed',
|
||||
'err.mykeyImport': 'Failed to import mykey.py',
|
||||
'err.channelNotConfigured': 'Configure this platform in mykey.py first',
|
||||
'sys.channelStarted': 'Started', 'sys.channelStopped': 'Stopped',
|
||||
'modal.channelLogs': 'Process logs',
|
||||
'modal.mykeyConfig': 'mykey.py',
|
||||
'sys.configSaved': 'Configuration saved',
|
||||
'sys.mykeyImported': 'mykey.py imported',
|
||||
'st.starting': 'Starting…', 'st.stopping': 'Stopping…', 'st.online': 'Online', 'st.offline': 'Offline', 'st.error': 'Error', 'st.running': 'Running', 'st.abnormal': 'Error',
|
||||
'act.configure': 'Configure', 'act.logs': 'Logs', 'act.restart': 'Restart', 'act.stop': 'Stop', 'act.start': 'Start', 'act.exit': 'Exit',
|
||||
'act.copy': 'Copy', 'act.copied': 'Copied', 'act.copyTex': 'TeX', 'act.send': 'Send',
|
||||
@@ -902,6 +914,32 @@ bindClick('add-model-btn', (e) => {
|
||||
});
|
||||
bindClick('settings-btn', (e) => { e.stopPropagation(); openSettings(); });
|
||||
bindClick('settings-services-btn', (e) => { e.stopPropagation(); openServiceManagerFromSettings(); });
|
||||
|
||||
const importMykeyInput = document.getElementById('import-mykey-input');
|
||||
async function importMykeyFromFile(file) {
|
||||
if (!file) return;
|
||||
const text = await file.text();
|
||||
if (!text.trim()) throw new Error(t('err.mykeyImport'));
|
||||
await window.ga.saveMykeyContent(text);
|
||||
await loadModelProfiles();
|
||||
}
|
||||
bindClick('import-mykey-btn', (e) => {
|
||||
e.stopPropagation();
|
||||
if (importMykeyInput) importMykeyInput.click();
|
||||
});
|
||||
if (importMykeyInput) {
|
||||
importMykeyInput.addEventListener('change', async () => {
|
||||
const file = importMykeyInput.files && importMykeyInput.files[0];
|
||||
importMykeyInput.value = '';
|
||||
if (!file) return;
|
||||
try {
|
||||
await importMykeyFromFile(file);
|
||||
showChanToast(t('sys.mykeyImported'), '', 'ok');
|
||||
} catch (err) {
|
||||
showChanToast(t('err.mykeyImport'), err.message || String(err), 'err');
|
||||
}
|
||||
});
|
||||
}
|
||||
// 侧边栏「快速接入」:点击官方模型按钮 → 打开预填好的添加模型表单
|
||||
const pqEl = document.getElementById('provider-quickstart');
|
||||
if (pqEl) pqEl.addEventListener('click', (e) => {
|
||||
@@ -5143,10 +5181,16 @@ function renderStatusPanel(services) {
|
||||
|
||||
async function loadStatusPanel() {
|
||||
if (!statusListEl) return;
|
||||
if (bridgeUiOffline) {
|
||||
renderStatusPanel(bridgeOfflinePanelServices());
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await window.ga.getServicePanel();
|
||||
renderStatusPanel(res.services || []);
|
||||
} catch (_) {
|
||||
window.ga.setBridgeUiOffline(true);
|
||||
gaServiceStore.applySnapshot(bridgeOfflinePanelServices());
|
||||
renderStatusPanel(bridgeOfflinePanelServices());
|
||||
}
|
||||
}
|
||||
@@ -5178,7 +5222,7 @@ if (statusListEl) {
|
||||
_chanBusy = true;
|
||||
actEl.disabled = true;
|
||||
try {
|
||||
await window.ga.startBridge();
|
||||
await window.ga.spawnBridge();
|
||||
await loadStatusPanel();
|
||||
showChanToast(t('sys.channelStarted') + ' · bridge', '', 'ok');
|
||||
} catch (err) {
|
||||
@@ -5194,8 +5238,10 @@ if (statusListEl) {
|
||||
_chanBusy = true;
|
||||
actEl.disabled = true;
|
||||
try {
|
||||
await window.ga.exitBridge();
|
||||
window.ga.setBridgeUiOffline(true);
|
||||
gaServiceStore.applySnapshot(bridgeOfflinePanelServices());
|
||||
renderStatusPanel(bridgeOfflinePanelServices());
|
||||
await window.ga.exitBridge();
|
||||
showChanToast(t('sys.channelStopped') + ' · bridge', '', 'ok');
|
||||
} catch (err) {
|
||||
showChanToast(t('err.channelStop') + ' · bridge', err.message || String(err), 'err');
|
||||
@@ -5229,7 +5275,10 @@ if (statusListEl) {
|
||||
|
||||
gaServiceStore.onServices((list) => {
|
||||
if (isSvcTab('channels')) renderChannelList(list);
|
||||
if (isSvcTab('status')) loadStatusPanel();
|
||||
if (isSvcTab('status')) {
|
||||
if (bridgeUiOffline) renderStatusPanel(bridgeOfflinePanelServices());
|
||||
else loadStatusPanel();
|
||||
}
|
||||
});
|
||||
if (chanListEl) {
|
||||
chanListEl.addEventListener('click', async (e) => {
|
||||
|
||||
@@ -605,13 +605,21 @@
|
||||
<div class="set-sec-t" data-i18n="set.lang"></div>
|
||||
<div class="model-list lang-list" id="lang-list"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="set-block">
|
||||
<div class="set-sec-t" data-i18n="set.model"></div>
|
||||
<div class="model-list" id="model-list"></div>
|
||||
<button class="set-btn" id="add-model-btn" type="button">
|
||||
<span data-ga-icon="plus"></span>
|
||||
<span data-i18n="set.addModel"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="set-block">
|
||||
<div class="set-sec-t" data-i18n="set.features"></div>
|
||||
<button class="set-btn" id="import-mykey-btn" type="button">
|
||||
<span data-ga-icon="fileText"></span>
|
||||
<span data-i18n="set.importMykey"></span>
|
||||
</button>
|
||||
<input type="file" id="import-mykey-input" accept=".py,text/plain" hidden>
|
||||
<button class="set-btn set-btn-follow" id="settings-services-btn" type="button">
|
||||
<span data-ga-icon="broadcast"></span>
|
||||
<span data-i18n="set.serviceManager"></span>
|
||||
@@ -637,6 +645,6 @@
|
||||
<!-- highlight.js -->
|
||||
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js" crossorigin="anonymous"></script>
|
||||
<script src="phosphor-icons.js?v=2"></script>
|
||||
<script src="app.js?v=191"></script>
|
||||
<script src="app.js?v=194"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user