feat(desktop): 工业化升级 B —— 真桌面集成(Wails Go 桥 + 原生能力)
让它真正像桌面 App 而非套壳网页:启用闲置的 Wails Go 桥,接原生文件框/系统打开/ 通知/无边框标题栏,且全部对浏览器预览(make web)优雅降级。 - app.go:SaveReportAs(原生"另存为"框 + 下载落盘)、OpenReport(下到临时目录 + 系统默认应用打开 docx)、Notify(macOS osascript 通知)、download/openInSystem 跨平台 - main.go:macOS TitleBarHiddenInset —— 隐藏标题栏、内容铺满到顶、保留红绿灯交通灯 - lib/desktop.ts:window.go.main.App 运行时桥 + isDesktop/isMacDesktop 探测; saveReportAs/openReport/notify 在无 Wails(浏览器)时分别降级为 <a download>/新标签/Toast - ReportView:桌面端「另存为 Word」(原生框) +「用系统打开」+ 完成弹系统通知; 浏览器端保持「下载 Word」 - KbView:拖拽文件入库(HTML5 dataTransfer,两种模式通用)+ 拖拽高亮 +「选择文件」按钮 - TopBar:顶栏设为 Wails 可拖拽区(--wails-draggable),控件标 no-drag; macOS 桌面端左留白让位交通灯 验证:GOWORK=off wails build 打出 .app(绑定生成 + mac 标题栏);启动真实原生窗口 截图确认无边框标题栏 + 交通灯内嵌 + 顶栏可拖拽(见会话截图);浏览器(Preview)确认 window.go 不存在时降级正确(下载链路 + 拖拽占位)。tsc + vite build 通过。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+81
-3
@@ -2,10 +2,19 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
goruntime "runtime"
|
||||||
|
|
||||||
|
wr "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
// App 经 Wails 的 TS/Go 强绑定暴露给前端,承载本地文件 I/O 等只有桌面端能做的能力。
|
// App 经 Wails 的 TS/Go 强绑定暴露给前端,承载只有桌面端能做的原生能力:
|
||||||
|
// 文件读写、系统"另存为"框、用系统默认应用打开、原生通知。
|
||||||
type App struct {
|
type App struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
}
|
}
|
||||||
@@ -15,6 +24,9 @@ func NewApp() *App { return &App{} }
|
|||||||
// startup 在 Wails 启动时注入运行时 ctx。
|
// startup 在 Wails 启动时注入运行时 ctx。
|
||||||
func (a *App) startup(ctx context.Context) { a.ctx = ctx }
|
func (a *App) startup(ctx context.Context) { a.ctx = ctx }
|
||||||
|
|
||||||
|
// Ping 供前端探活 Go 桥是否就绪。
|
||||||
|
func (a *App) Ping() string { return "sundynix-desktop ok" }
|
||||||
|
|
||||||
// ReadLocalFile 读取本地文件内容(本地文件系统 I/O)。
|
// ReadLocalFile 读取本地文件内容(本地文件系统 I/O)。
|
||||||
func (a *App) ReadLocalFile(path string) (string, error) {
|
func (a *App) ReadLocalFile(path string) (string, error) {
|
||||||
b, err := os.ReadFile(path)
|
b, err := os.ReadFile(path)
|
||||||
@@ -24,5 +36,71 @@ func (a *App) ReadLocalFile(path string) (string, error) {
|
|||||||
return string(b), nil
|
return string(b), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ping 供前端探活 Go 桥是否就绪。
|
// SaveReportAs 弹原生"另存为"对话框,把 url 指向的报告(.docx)下载到用户选定路径。
|
||||||
func (a *App) Ping() string { return "sundynix-desktop ok" }
|
// 返回保存路径;用户取消则返回空串。
|
||||||
|
func (a *App) SaveReportAs(url, filename string) (string, error) {
|
||||||
|
if filename == "" {
|
||||||
|
filename = "report.docx"
|
||||||
|
}
|
||||||
|
path, err := wr.SaveFileDialog(a.ctx, wr.SaveDialogOptions{
|
||||||
|
Title: "保存报告",
|
||||||
|
DefaultFilename: filename,
|
||||||
|
Filters: []wr.FileFilter{{DisplayName: "Word 文档 (*.docx)", Pattern: "*.docx"}},
|
||||||
|
})
|
||||||
|
if err != nil || path == "" {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := download(url, path); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenReport 把报告下载到临时目录,并用系统默认应用(Word/Pages/WPS)打开。
|
||||||
|
func (a *App) OpenReport(url, filename string) error {
|
||||||
|
if filename == "" {
|
||||||
|
filename = "report.docx"
|
||||||
|
}
|
||||||
|
dst := filepath.Join(os.TempDir(), "sundynix-open-"+filename)
|
||||||
|
if err := download(url, dst); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return openInSystem(dst)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify 弹一条系统通知(best-effort:macOS 用 osascript,其它平台暂静默)。
|
||||||
|
func (a *App) Notify(title, body string) {
|
||||||
|
if goruntime.GOOS == "darwin" {
|
||||||
|
script := fmt.Sprintf("display notification %q with title %q", body, title)
|
||||||
|
_ = exec.Command("osascript", "-e", script).Start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func download(url, dst string) error {
|
||||||
|
resp, err := http.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return fmt.Errorf("下载失败 HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
f, err := os.Create(dst)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
_, err = io.Copy(f, resp.Body)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func openInSystem(path string) error {
|
||||||
|
switch goruntime.GOOS {
|
||||||
|
case "darwin":
|
||||||
|
return exec.Command("open", path).Start()
|
||||||
|
case "windows":
|
||||||
|
return exec.Command("rundll32", "url.dll,FileProtocolHandler", path).Start()
|
||||||
|
default:
|
||||||
|
return exec.Command("xdg-open", path).Start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// 桌面端原生能力桥 —— 经 Wails 注入的 window.go.main.App 调 Go 方法。
|
||||||
|
// 关键:在浏览器预览(make web,无 Wails 运行时)下 window.go 不存在,
|
||||||
|
// 所有方法自动降级为纯 Web 行为,保证两种模式都能跑。
|
||||||
|
|
||||||
|
interface WailsApp {
|
||||||
|
SaveReportAs(url: string, filename: string): Promise<string>;
|
||||||
|
OpenReport(url: string, filename: string): Promise<void>;
|
||||||
|
Notify(title: string, body: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function app(): WailsApp | null {
|
||||||
|
return ((window as unknown as { go?: { main?: { App?: WailsApp } } }).go?.main?.App as WailsApp) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// isDesktop 是否运行在真实 Wails 桌面窗口(而非浏览器预览)。
|
||||||
|
export function isDesktop(): boolean {
|
||||||
|
return !!app();
|
||||||
|
}
|
||||||
|
|
||||||
|
// isMacDesktop 用于交通灯让位等 macOS 专属适配。
|
||||||
|
export function isMacDesktop(): boolean {
|
||||||
|
return isDesktop() && /Mac/i.test(navigator.userAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveReportAs:桌面端弹原生"另存为"框落盘;浏览器降级为触发下载。返回保存路径(浏览器/取消为空)。
|
||||||
|
export async function saveReportAs(url: string, filename: string): Promise<string> {
|
||||||
|
const a = app();
|
||||||
|
if (!a) {
|
||||||
|
triggerDownload(url, filename);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return a.SaveReportAs(url, filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
// openReport:桌面端下载到临时目录并用系统默认应用打开;浏览器降级为新标签打开。
|
||||||
|
export async function openReport(url: string, filename: string): Promise<void> {
|
||||||
|
const a = app();
|
||||||
|
if (!a) {
|
||||||
|
window.open(url, "_blank");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return a.OpenReport(url, filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
// notify:桌面端弹系统通知;浏览器为空操作(由应用内 Toast 兜底)。
|
||||||
|
export function notify(title: string, body: string): void {
|
||||||
|
app()?.Notify(title, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerDownload(url: string, filename: string) {
|
||||||
|
const el = document.createElement("a");
|
||||||
|
el.href = url;
|
||||||
|
el.download = filename;
|
||||||
|
document.body.appendChild(el);
|
||||||
|
el.click();
|
||||||
|
el.remove();
|
||||||
|
}
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
|
import type { CSSProperties } from "react";
|
||||||
import { User, ChevronDown } from "lucide-react";
|
import { User, ChevronDown } from "lucide-react";
|
||||||
import type { Identity } from "../lib/api";
|
import type { Identity } from "../lib/api";
|
||||||
import { useHealth } from "../lib/health";
|
import { useHealth } from "../lib/health";
|
||||||
|
import { isMacDesktop } from "../lib/desktop";
|
||||||
import { cn } from "../ui";
|
import { cn } from "../ui";
|
||||||
|
|
||||||
|
// Wails 拖拽区/控件 CSS 变量(浏览器忽略,桌面端生效)。
|
||||||
|
const DRAG = { "--wails-draggable": "drag" } as CSSProperties;
|
||||||
|
const NODRAG = { "--wails-draggable": "no-drag" } as CSSProperties;
|
||||||
|
|
||||||
function Light({ on, label, unknown }: { on?: boolean; label: string; unknown?: boolean }) {
|
function Light({ on, label, unknown }: { on?: boolean; label: string; unknown?: boolean }) {
|
||||||
const dot = unknown ? "bg-slate-600" : on ? "bg-success shadow-[0_0_8px_rgba(52,211,153,0.7)]" : "bg-danger";
|
const dot = unknown ? "bg-slate-600" : on ? "bg-success shadow-[0_0_8px_rgba(52,211,153,0.7)]" : "bg-danger";
|
||||||
return (
|
return (
|
||||||
@@ -17,14 +23,17 @@ function Light({ on, label, unknown }: { on?: boolean; label: string; unknown?:
|
|||||||
export function TopBar({ identity, setIdentity }: { identity: Identity; setIdentity: (id: Identity) => void }) {
|
export function TopBar({ identity, setIdentity }: { identity: Identity; setIdentity: (id: Identity) => void }) {
|
||||||
const h = useHealth();
|
const h = useHealth();
|
||||||
return (
|
return (
|
||||||
<header className="flex h-12 shrink-0 items-center gap-3 border-b border-line bg-ink-900/80 px-3 backdrop-blur">
|
<header
|
||||||
|
style={DRAG}
|
||||||
|
className={cn("flex h-12 shrink-0 items-center gap-3 border-b border-line bg-ink-900/80 pr-3 backdrop-blur", isMacDesktop() ? "pl-20" : "pl-3")}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-gradient-to-br from-brand to-accent text-xs font-bold text-white">
|
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-gradient-to-br from-brand to-accent text-xs font-bold text-white">
|
||||||
S
|
S
|
||||||
</div>
|
</div>
|
||||||
<span className="brand-gradient text-sm font-semibold">sundynix-agentix</span>
|
<span className="brand-gradient text-sm font-semibold">sundynix-agentix</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative">
|
<div className="relative" style={NODRAG}>
|
||||||
<select
|
<select
|
||||||
className="appearance-none rounded-md border border-line bg-ink-800 py-1 pl-2.5 pr-7 text-xs text-slate-300 focus:border-brand focus:outline-none"
|
className="appearance-none rounded-md border border-line bg-ink-800 py-1 pl-2.5 pr-7 text-xs text-slate-300 focus:border-brand focus:outline-none"
|
||||||
defaultValue="通用版"
|
defaultValue="通用版"
|
||||||
@@ -42,7 +51,7 @@ export function TopBar({ identity, setIdentity }: { identity: Identity; setIdent
|
|||||||
<Light unknown label="Milvus" />
|
<Light unknown label="Milvus" />
|
||||||
<Light unknown label="Neo4j" />
|
<Light unknown label="Neo4j" />
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2" style={NODRAG}>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<User className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-500" />
|
<User className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-500" />
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export function KbView() {
|
|||||||
const [hits, setHits] = useState<KbHit[] | null>(null);
|
const [hits, setHits] = useState<KbHit[] | null>(null);
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const [graph, setGraph] = useState<Triple[] | null>(null);
|
const [graph, setGraph] = useState<Triple[] | null>(null);
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
|
||||||
const onGraph = async () => {
|
const onGraph = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -118,7 +119,27 @@ export function KbView() {
|
|||||||
{/* 左:入库 + 实时监控 */}
|
{/* 左:入库 + 实时监控 */}
|
||||||
<section className="flex w-1/2 flex-col border-r border-line p-4">
|
<section className="flex w-1/2 flex-col border-r border-line p-4">
|
||||||
<h3 className="mb-2 text-xs font-semibold text-slate-400">入库</h3>
|
<h3 className="mb-2 text-xs font-semibold text-slate-400">入库</h3>
|
||||||
<Textarea className="h-24 resize-none" value={text} onChange={(e) => setText(e.target.value)} placeholder="每行一条知识,或上传文件" />
|
<div
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
const f = e.dataTransfer.files?.[0];
|
||||||
|
if (f) onFile(f);
|
||||||
|
}}
|
||||||
|
className={cn("relative rounded-md", dragOver && "ring-2 ring-brand")}
|
||||||
|
>
|
||||||
|
<Textarea className="h-24 w-full resize-none" value={text} onChange={(e) => setText(e.target.value)} placeholder="每行一条知识,或把文件拖到这里 / 点选择文件" />
|
||||||
|
{dragOver && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-md bg-ink-950/85 text-xs font-medium text-brand-400">
|
||||||
|
松手以入库该文件
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="mt-2 flex items-center gap-2">
|
<div className="mt-2 flex items-center gap-2">
|
||||||
<Button variant="primary" size="sm" icon={Upload} onClick={onIngest} disabled={ingesting || !text.trim()}>
|
<Button variant="primary" size="sm" icon={Upload} onClick={onIngest} disabled={ingesting || !text.trim()}>
|
||||||
{ingesting ? "入库中…" : "入库文本"}
|
{ingesting ? "入库中…" : "入库文本"}
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { Play, Download, FileText } from "lucide-react";
|
import { Play, Download, FileText, ExternalLink } from "lucide-react";
|
||||||
import { generateReport, streamTokens, streamExec, reportDownloadUrl, type Identity, type ExecEvent } from "../lib/api";
|
import { generateReport, streamTokens, streamExec, reportDownloadUrl, type Identity, type ExecEvent } from "../lib/api";
|
||||||
|
import { isDesktop, saveReportAs, openReport, notify } from "../lib/desktop";
|
||||||
import { ExecTrace } from "../components/ExecTrace";
|
import { ExecTrace } from "../components/ExecTrace";
|
||||||
import { Button, Input, Field, Panel, Dot, EmptyState, useToast } from "../ui";
|
import { Button, Input, Field, Panel, Dot, EmptyState, useToast } from "../ui";
|
||||||
|
|
||||||
|
// 安全文件名:去掉路径不安全字符,限长。
|
||||||
|
function reportFilename(topic: string, id: string): string {
|
||||||
|
const base = topic.replace(/[\\/:*?"<>|]/g, "").trim().slice(0, 40) || id;
|
||||||
|
return `${base}.docx`;
|
||||||
|
}
|
||||||
|
|
||||||
type Phase = "idle" | "running" | "done" | "error";
|
type Phase = "idle" | "running" | "done" | "error";
|
||||||
|
|
||||||
// 报告生成:输入主题(+可选知识库) → 触发后端专用编排
|
// 报告生成:输入主题(+可选知识库) → 触发后端专用编排
|
||||||
@@ -43,7 +50,8 @@ export function ReportView({ identity }: { identity: Identity }) {
|
|||||||
(tok) => setOut((o) => o + tok),
|
(tok) => setOut((o) => o + tok),
|
||||||
() => {
|
() => {
|
||||||
setPhase("done");
|
setPhase("done");
|
||||||
toast.push("success", "报告已生成,可下载 Word");
|
toast.push("success", "报告已生成,可保存 Word");
|
||||||
|
notify("报告已生成", topic.trim());
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
setPhase("error");
|
setPhase("error");
|
||||||
@@ -83,13 +91,26 @@ export function ReportView({ identity }: { identity: Identity }) {
|
|||||||
{running ? "生成中…" : "生成报告"}
|
{running ? "生成中…" : "生成报告"}
|
||||||
</Button>
|
</Button>
|
||||||
{phase === "done" && taskId ? (
|
{phase === "done" && taskId ? (
|
||||||
<a
|
<div className="flex items-center gap-2">
|
||||||
href={reportDownloadUrl(taskId)}
|
<Button
|
||||||
className="inline-flex h-9 items-center gap-1.5 rounded-md border border-brand/60 px-4 text-sm font-medium text-brand-400 transition hover:bg-brand/10"
|
icon={Download}
|
||||||
>
|
onClick={async () => {
|
||||||
<Download className="h-4 w-4" />
|
const p = await saveReportAs(reportDownloadUrl(taskId), reportFilename(topic, taskId));
|
||||||
下载 Word
|
if (p) toast.push("success", "已保存到 " + p);
|
||||||
</a>
|
}}
|
||||||
|
>
|
||||||
|
{isDesktop() ? "另存为 Word" : "下载 Word"}
|
||||||
|
</Button>
|
||||||
|
{isDesktop() && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
icon={ExternalLink}
|
||||||
|
onClick={() => openReport(reportDownloadUrl(taskId), reportFilename(topic, taskId)).catch((e) => toast.push("error", String(e)))}
|
||||||
|
>
|
||||||
|
用系统打开
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="h-9" />
|
<span className="h-9" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"github.com/wailsapp/wails/v2"
|
"github.com/wailsapp/wails/v2"
|
||||||
"github.com/wailsapp/wails/v2/pkg/options"
|
"github.com/wailsapp/wails/v2/pkg/options"
|
||||||
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/options/mac"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed all:frontend/dist
|
//go:embed all:frontend/dist
|
||||||
@@ -22,7 +23,11 @@ func main() {
|
|||||||
MinHeight: 700,
|
MinHeight: 700,
|
||||||
BackgroundColour: &options.RGBA{R: 11, G: 13, B: 18, A: 1}, // 与深色主题一致 #0b0d12
|
BackgroundColour: &options.RGBA{R: 11, G: 13, B: 18, A: 1}, // 与深色主题一致 #0b0d12
|
||||||
AssetServer: &assetserver.Options{Assets: assets},
|
AssetServer: &assetserver.Options{Assets: assets},
|
||||||
OnStartup: app.startup,
|
// macOS:隐藏标题栏、内容铺满到顶(保留红绿灯交通灯),顶栏自定义为可拖拽区。
|
||||||
|
Mac: &mac.Options{
|
||||||
|
TitleBar: mac.TitleBarHiddenInset(),
|
||||||
|
},
|
||||||
|
OnStartup: app.startup,
|
||||||
// Bind: TS/Go 强绑定 —— 把 App 的方法暴露给前端
|
// Bind: TS/Go 强绑定 —— 把 App 的方法暴露给前端
|
||||||
Bind: []any{app},
|
Bind: []any{app},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user