feat(gui): add DeepSeek GUI with layout fixes and CI packaging#3349
feat(gui): add DeepSeek GUI with layout fixes and CI packaging#3349victorhuang868 wants to merge 1 commit into
Conversation
Introduce the Tauri Deepseek-GUI app with Composer clickability and three-column layout fixes, plus GitHub Actions to build Windows NSIS and macOS DMG installers with the deepseek-tui sidecar. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Too many files changed for review. ( |
|
Thanks @victorhuang868 for taking the time to contribute. This repository is observing a maintainer-managed PR intake gate in dry-run mode, so this pull request is staying open. This note helps maintainers prepare the allowlist before any enforcement is considered. Please read |
There was a problem hiding this comment.
Code Review
This pull request introduces DeepSeek GUI, a desktop graphical interface for the DeepSeek CLI/TUI built on Tauri, React, and TypeScript. It features an IDE-like three-column layout, multi-session chat, a CodeMirror-based editor, xterm.js terminal integration, and various configuration panels. The review feedback highlights several critical issues that need to be addressed: a race condition in the Rust LSP bridge that can spawn duplicate server processes, compatibility issues in the SSE event parser when handling CRLF line endings, and a missing workspace directory validation when restarting the backend. Additionally, there is a widespread error-handling issue across multiple UI components where Tauri command rejections (returned as strings) are treated as standard Error objects, which would display as 'undefined' in the UI; using the formatInvokeError helper is recommended to resolve this.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| buffer += decoder.decode(value, { stream: true }); | ||
|
|
||
| // 按 SSE 规范以空行分隔事件块 | ||
| let idx: number; | ||
| while ((idx = buffer.indexOf("\n\n")) !== -1) { | ||
| const rawBlock = buffer.slice(0, idx); | ||
| buffer = buffer.slice(idx + 2); | ||
| const evt = parseSseBlock(rawBlock); | ||
| if (evt) { | ||
| lastSeq = Math.max(lastSeq, evt.seq); |
There was a problem hiding this comment.
The SSE protocol allows both \n\n and \r\n\r\n as event separators. If the backend server uses standard CRLF (\r\n) line endings, buffer.indexOf("\n\n") will return -1 because of the \r character between the two \ns, preventing any events from being parsed. Searching for both separators ensures compatibility across different platforms and server configurations.
let idx: number;
while (true) {
let sep = "\n\n";
idx = buffer.indexOf("\n\n");
if (idx === -1) {
idx = buffer.indexOf("\r\n\r\n");
sep = "\r\n\r\n";
}
if (idx === -1) break;
const rawBlock = buffer.slice(0, idx);
buffer = buffer.slice(idx + sep.length);
const evt = parseSseBlock(rawBlock);
if (evt) {
lastSeq = Math.max(lastSeq, evt.seq);
onEvent(evt);
}
}| { | ||
| let guard = self.sessions.lock().map_err(|e| e.to_string())?; | ||
| if guard.contains_key(&session_id) { | ||
| return Ok(LspSessionInfo { | ||
| session_id: session_id.clone(), | ||
| language_id: lang.language_id().to_string(), | ||
| root_uri: uri_from_path(&workspace_path), | ||
| server_command: command.to_string(), | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| let mut child = spawn_lsp_process(command, args, &workspace_path)?; | ||
| let stdin = child | ||
| .stdin | ||
| .take() | ||
| .ok_or_else(|| format!("LSP `{command}` 无 stdin"))?; | ||
| let stdout = child | ||
| .stdout | ||
| .take() | ||
| .ok_or_else(|| format!("LSP `{command}` 无 stdout"))?; | ||
|
|
||
| let (tx_outbound, mut rx_outbound) = mpsc::unbounded_channel::<String>(); | ||
| let app_reader = app.clone(); | ||
| let session_id_reader = session_id.clone(); | ||
|
|
||
| // 写 stdin:前端 JSON → Content-Length 帧 | ||
| tauri::async_runtime::spawn(async move { | ||
| let mut stdin = stdin; | ||
| while let Some(json) = rx_outbound.recv().await { | ||
| if write_framed(&mut stdin, json.as_bytes()).await.is_err() { | ||
| break; | ||
| } | ||
| } | ||
| }); | ||
|
|
||
| // 读 stdout:Content-Length 帧 → 前端 JSON 事件 | ||
| tauri::async_runtime::spawn(async move { | ||
| read_stdout_loop(stdout, app_reader, session_id_reader).await; | ||
| }); | ||
|
|
||
| let session = LspSession { | ||
| tx_outbound, | ||
| _child: child, | ||
| }; | ||
| self.sessions | ||
| .lock() | ||
| .map_err(|e| e.to_string())? | ||
| .insert(session_id.clone(), session); |
There was a problem hiding this comment.
There is a race condition here. Since ensure_session is an async fn but contains no .await points between checking the sessions map and inserting the newly spawned session, multiple concurrent requests for the same session_id can check the map simultaneously, find it empty, and spawn duplicate LSP server processes. Keeping the lock held for the entire duration of the session setup avoids this race condition.
let mut guard = self.sessions.lock().map_err(|e| e.to_string())?;
if guard.contains_key(&session_id) {
return Ok(LspSessionInfo {
session_id: session_id.clone(),
language_id: lang.language_id().to_string(),
root_uri: uri_from_path(&workspace_path),
server_command: command.to_string(),
});
}
let mut child = spawn_lsp_process(command, args, &workspace_path)?;
let stdin = child
.stdin
.take()
.ok_or_else(|| format!("LSP `{command}` 无 stdin"))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| format!("LSP `{command}` 无 stdout"))?;
let (tx_outbound, mut rx_outbound) = mpsc::unbounded_channel::<String>();
let app_reader = app.clone();
let session_id_reader = session_id.clone();
// 写 stdin:前端 JSON → Content-Length 帧
tauri::async_runtime::spawn(async move {
let mut stdin = stdin;
while let Some(json) = rx_outbound.recv().await {
if write_framed(&mut stdin, json.as_bytes()).await.is_err() {
break;
}
}
});
// 读 stdout:Content-Length 帧 → 前端 JSON 事件
tauri::async_runtime::spawn(async move {
read_stdout_loop(stdout, app_reader, session_id_reader).await;
});
let session = LspSession {
tx_outbound,
_child: child,
};
guard.insert(session_id.clone(), session);| setMsg(null); | ||
| } catch (e) { | ||
| setMsg((e as Error).message); | ||
| } finally { |
There was a problem hiding this comment.
| } catch (e) { | ||
| setMsg((e as Error).message); | ||
| } finally { | ||
| setLoading(false); |
There was a problem hiding this comment.
| import CodeMirror from "@uiw/react-codemirror"; | ||
| import { vscodeDark } from "@uiw/codemirror-theme-vscode"; | ||
| import { EditorView } from "@codemirror/view"; | ||
| import { readFile, writeFile } from "../api/tauri"; |
There was a problem hiding this comment.
| await restartBackend(); | ||
| setHooks(nextHooks); | ||
| setJsonText(JSON.stringify(nextHooks, null, 2)); | ||
| setMsg(zh ? "已保存" : "Saved"); |
There was a problem hiding this comment.
Tauri command rejections are typically returned as strings rather than standard Error objects. Using (e as Error).message will result in undefined being displayed in the UI. Use formatInvokeError to safely extract the error message.
| setMsg(zh ? "已保存" : "Saved"); | |
| setMsg(formatInvokeError(e)); |
| await addTrust(workspace, p); | ||
| await refresh(); | ||
| setNewPath(""); | ||
| setMsg(zh ? "已新增信任路径" : "Trusted path added"); |
There was a problem hiding this comment.
Tauri command rejections are typically returned as strings rather than standard Error objects. Using (e as Error).message will result in undefined being displayed in the UI. Use formatInvokeError to safely extract the error message.
| setMsg(zh ? "已新增信任路径" : "Trusted path added"); | |
| setMsg(formatInvokeError(e)); |
| await refresh(); | ||
| setMsg(zh ? "已移除" : "Removed"); | ||
| } catch (e) { | ||
| setMsg((e as Error).message); |
There was a problem hiding this comment.
Tauri command rejections are typically returned as strings rather than standard Error objects. Using (e as Error).message will result in undefined being displayed in the UI. Use formatInvokeError to safely extract the error message.
| setMsg((e as Error).message); | |
| setMsg(formatInvokeError(e)); |
| import type { Locale } from "../i18n"; | ||
| import { | ||
| isTauri, | ||
| ptyClose, | ||
| ptyResize, | ||
| ptySpawn, | ||
| ptyWrite, |
There was a problem hiding this comment.
Import formatInvokeError to safely handle Tauri command rejections, which are typically returned as strings rather than standard Error objects.
| import type { Locale } from "../i18n"; | |
| import { | |
| isTauri, | |
| ptyClose, | |
| ptyResize, | |
| ptySpawn, | |
| ptyWrite, | |
| import { | |
| isTauri, | |
| ptyClose, | |
| ptyResize, | |
| ptySpawn, | |
| ptyWrite, | |
| formatInvokeError, | |
| } from "../api/tauri"; |
| }); | ||
|
|
||
| term.onData((data) => { | ||
| const pid = ptyIdRef.current; |
There was a problem hiding this comment.
Tauri command rejections are typically returned as strings rather than standard Error objects. Using (e as Error).message will result in undefined being displayed in the UI. Use formatInvokeError to safely extract the error message.
| const pid = ptyIdRef.current; | |
| setErr(formatInvokeError(e)); |
|
Closing: GUI moved to standalone repo https://github.com/victorhuang868/Deepseek-GUI (separate from TUI CLI). |
Summary
Test plan
Made with Cursor