File size: 10,031 Bytes
d0bf6e2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
00a5163
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d0bf6e2
00a5163
d0bf6e2
00a5163
 
 
 
 
 
 
 
 
d0bf6e2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
00a5163
d0bf6e2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
"""onw AMD tray: the resident icon (Windows task tray / Ubuntu top-bar indicator).

  onw tray      icon + menu: open the onw window (models / server / settings), chat UI, check page, copy the
                OpenAI API URL, start / stop the server. With no model downloaded yet it opens the model manager.

The server itself runs as a separate process managed by onw.app (so the window and the tray share it). The tray
also hosts the manager (onw.manager: downloads, model list, settings) that the window talks to, so downloads keep
going while the window is closed.
"""
import json, os, subprocess, sys, threading, time, webbrowser

from . import app, __version__


def open_window(tab="server", updated=None):
    exe = sys.executable
    if os.name == "nt" and exe.lower().endswith("python.exe"):
        exe = exe[:-10] + "pythonw.exe"
    subprocess.Popen([exe, "-m", "onw", "window", "--tab", tab] + (["--updated", updated] if updated else []))


def icon_image(state):
    from . import logo
    return logo.draw(64, simple=True, state=state)          # the small-size logo + a status dot (grey when stopped)


def copy_text(text):
    """Copy to the clipboard: clip (Windows), wl-copy (Wayland) or xclip / xsel (X11). False when none is there."""
    if os.name == "nt":
        cmds = [(["clip"], text.encode("utf-16-le"))]
    else:
        cmds = [(["wl-copy"], text.encode()), (["xclip", "-selection", "clipboard"], text.encode()),
                (["xsel", "--clipboard", "--input"], text.encode())]
    for cmd, data in cmds:
        try:
            if subprocess.run(cmd, input=data, check=False, timeout=5).returncode == 0:
                return True
        except (OSError, subprocess.SubprocessError):
            continue
    return False


def replace_running():
    """The installer ran again while a tray is running (an update by the one-line installer): ask that tray to restart
    into the new files, as the in-app update does (the model that was running is loaded again). False when no
    tray answered."""
    import urllib.request
    from . import manager as mgr
    d = mgr.find_manager()
    if not d:
        return False
    try:
        req = urllib.request.Request(f"http://127.0.0.1:{d['port']}/api/restart_tray", b"{}",
                                     {"X-Onw-Token": d["token"], "Content-Type": "application/json"})
        return "error" not in json.loads(urllib.request.urlopen(req, timeout=10).read())
    except Exception:
        return False


def take_over():
    """A running tray that cannot restart itself (older than 0.1.3): stop it and its server, remember the model for the
    new tray to load again, and wait for its single-instance lock. True once the lock is ours."""
    from . import manager as mgr
    info = app.server_info()
    with open(os.path.join(app.config_dir(), "resume.json"), "w") as f:
        json.dump({"model": info["model"] if info else None}, f)
    app.stop_server()
    d = mgr.find_manager()
    if d:
        try:
            import psutil
            p = psutil.Process(d["pid"])
            procs = p.children(recursive=True) + [p]
            for q in procs:
                q.terminate()
            psutil.wait_procs(procs, timeout=10)
        except Exception:
            pass
    for _ in range(40):
        if app.single_instance("tray"):
            return True
        time.sleep(0.5)
    return False


def main(argv=None):
    argv = argv or []
    if not app.single_instance("tray"):                       # already in the tray (e.g. started again from the menu)
        if "--replace" in argv:                                # the installer ran again: the files are new
            if replace_running():                              # the old tray restarts itself on the new code
                return
            if not take_over():                                # an older tray: stop it and start here instead
                open_window()
                return
        else:
            open_window()
            return
    import pystray
    from . import manager as mgr
    app.clear_stale_server()                                   # a pid file from before a crash / reboot
    manager = mgr.Manager()
    mgr.serve(manager)                                         # the window's backend lives here
    from .i18n import T
    labels = {"stopped": "停止中", "loading": "ロード中…", "running": "動作中", "failed": "ロードに失敗"}
    state = {"s": app.server_status()}

    def cfg():
        return app.load_config()

    def status_text(_=None):
        info = app.server_info()
        name = (info or {}).get("model") or cfg().get("model") or T("モデル未選択")
        return f"onw AMD: {T(labels[state['s']])} — {name.split('/')[-1]}"

    def start(_=None, __=None):
        c = cfg()
        if not app.installed(c):
            open_window("models")
            return
        if c["model"] not in app.installed(c):
            c = app.update_config(model=next(iter(app.installed(c))))
        try:
            app.start_server(c)
        except Exception:                                      # e.g. the port in use: the window says why
            open_window("server")

    def quit_(icon, __=None):
        app.stop_server()
        try:                                                   # manager.json points at this process: a later
            os.remove(mgr.INFO())                              # `onw window` then starts its own manager
        except OSError:
            pass
        icon.stop()

    def restart_after_update(icon_=None):
        """Stop the server, start a fresh tray on the new code, reload the model that was running."""
        info = app.server_info()
        with open(os.path.join(app.config_dir(), "resume.json"), "w") as f:
            json.dump({"model": info["model"] if info else None}, f)
        app.stop_server()
        subprocess.Popen([sys.executable, "-m", "onw.relaunch", str(os.getpid())],
                         cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
                         **({"creationflags": subprocess.CREATE_NO_WINDOW | subprocess.DETACHED_PROCESS} if os.name == "nt"
                            else {"start_new_session": True}))
        quit_(icon)

    def update_found(v):
        try:
            icon.notify(T("「更新」を押すと自動で入れ替えて再起動します。"), T("onw v{0} が使えます", v))
        except Exception:                                      # not every indicator backend can notify
            pass
        try:
            icon.update_menu()
        except Exception:
            pass

    def do_update(_=None, __=None):
        r = manager.run_update()
        if r.get("error"):
            open_window("server")

    manager.on_updated = lambda: restart_after_update()
    manager.on_restart = lambda: restart_after_update()        # the installer ran again (onw tray --replace)
    manager.on_update_found = update_found
    running = lambda _: state["s"] == "running"
    menu = pystray.Menu(
        pystray.MenuItem(status_text, None, enabled=False),
        pystray.MenuItem(lambda _: T("onw を開く(モデル・サーバー・設定)"), lambda *_: open_window(), default=True),
        pystray.MenuItem(lambda _: T("チャットを開く"), lambda *_: webbrowser.open(f"http://localhost:{cfg()['port']}/chat"), enabled=running),
        pystray.MenuItem(lambda _: T("検証ページを開く"), lambda *_: webbrowser.open(f"http://localhost:{cfg()['port']}/check"), enabled=running),
        pystray.MenuItem(lambda _: T("OpenAI API のURLをコピー"),
                         lambda *_: copy_text(app.api_url(cfg())) or open_window("server")),   # no clipboard tool: shown there
        pystray.MenuItem(lambda _: T("onw を更新(v{0})", manager.upd["latest"]), do_update,
                         visible=lambda _: bool(manager.upd["latest"]) and manager.upd["state"] != "updating"),
        pystray.MenuItem(lambda _: T("モデルをロード"), start, visible=lambda _: state["s"] in ("stopped", "failed")),
        pystray.MenuItem(lambda _: T("アンロード(サーバー停止)"), lambda *_: app.stop_server(), visible=lambda _: state["s"] in ("loading", "running")),
        pystray.MenuItem(lambda _: T("終了(サーバーも停止)"), quit_))
    icon = pystray.Icon("onw-amd", icon_image(state["s"]), app.title(), menu)

    def refresh():
        last = None
        while True:
            state["s"] = app.server_status()
            if state["s"] != last:
                last = state["s"]
                icon.icon = icon_image(last)
                icon.title = status_text()
                try:
                    icon.update_menu()
                except Exception:
                    pass
            time.sleep(2)

    c = cfg()
    resume = os.path.join(app.config_dir(), "resume.json")
    resumed = None
    after_update = os.path.exists(resume)
    if after_update:                                 # just restarted by an update: reload that model
        try:
            resumed = json.load(open(resume)).get("model")
        except (OSError, ValueError):
            pass
        os.remove(resume)
    if not app.installed(c):
        open_window("models")                                  # first run: pick a model to download
    elif resumed and state["s"] == "stopped" and resumed in app.installed(c):
        try:
            app.start_server(app.update_config(model=resumed))
        except Exception:
            open_window("server")
    elif c["autostart_server"] and state["s"] == "stopped":
        start()
    if after_update and app.installed(c):                     # show the new version (an open window reopens itself
        open_window("server", updated=__version__)             # too; the single-instance lock keeps just one)
    threading.Thread(target=refresh, daemon=True).start()
    threading.Thread(target=app.refresh_shortcut, daemon=True).start()
    icon.run()