From 406ac1df624220a154552548173118769098b14f Mon Sep 17 00:00:00 2001 From: Jonah-Wu23 <3582584159@qq.com> Date: Wed, 28 Jan 2026 23:07:40 +0800 Subject: [PATCH] release: v0.1.1 windows installer --- .gitignore | 5 +- CHANGELOG.md | 11 + backend/app/__init__.py | 27 +++ backend/app/config.py | 40 ++- backend/mirofish_backend.spec | 116 +++++++++ frontend/package-lock.json | 4 - frontend/src/main.js | 8 + installer/ChineseSimplified.isl | 418 ++++++++++++++++++++++++++++++++ installer/MiroFish.ico | Bin 0 -> 685 bytes installer/README.md | 135 +++++++++++ installer/build.ps1 | 230 ++++++++++++++++++ installer/setup.iss | 247 +++++++++++++++++++ launcher.py | 284 ++++++++++++++++++++++ 13 files changed, 1516 insertions(+), 9 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 backend/mirofish_backend.spec create mode 100644 installer/ChineseSimplified.isl create mode 100644 installer/MiroFish.ico create mode 100644 installer/README.md create mode 100644 installer/build.ps1 create mode 100644 installer/setup.iss create mode 100644 launcher.py diff --git a/.gitignore b/.gitignore index 0bb6697e..d7e52634 100644 --- a/.gitignore +++ b/.gitignore @@ -56,4 +56,7 @@ backend/logs/ backend/uploads/ # Docker 数据 -data/ \ No newline at end of file +data/ + +# 安装程序输出目录 +installer/output \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..ad22f193 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +## V0.1.1 +- Packaged the project into a Windows installer (.exe) +- Installer allows entering API keys during setup +- After installation, double-click to open browser UI +- One-click install & run experience on Windows + +## Fork Notice +This project is a fork of MiroFish by 666ghj: +https://github.com/666ghj/MiroFish diff --git a/backend/app/__init__.py b/backend/app/__init__.py index aba624bb..e0e30120 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -3,7 +3,9 @@ MiroFish Backend - Flask应用工厂 """ import os +import time import warnings +import threading # 抑制 multiprocessing resource_tracker 的警告(来自第三方库如 transformers) # 需要在所有其他导入之前设置 @@ -48,6 +50,12 @@ def create_app(config_class=Config): if should_log_startup: logger.info("已注册模拟进程清理函数") + # 心跳状态 + last_heartbeat = {"ts": time.time()} + + def touch_heartbeat(): + last_heartbeat["ts"] = time.time() + # 请求日志中间件 @app.before_request def log_request(): @@ -55,6 +63,8 @@ def create_app(config_class=Config): logger.debug(f"请求: {request.method} {request.path}") if request.content_type and 'json' in request.content_type: logger.debug(f"请求体: {request.get_json(silent=True)}") + if request.path.startswith('/api') or request.path == '/health': + touch_heartbeat() @app.after_request def log_response(response): @@ -72,9 +82,26 @@ def create_app(config_class=Config): @app.route('/health') def health(): return {'status': 'ok', 'service': 'MiroFish Backend'} + + @app.route('/api/heartbeat', methods=['GET', 'POST']) + def heartbeat(): + touch_heartbeat() + return {'status': 'ok'} + + def heartbeat_watch(): + timeout = int(os.environ.get('MIROFISH_HEARTBEAT_TIMEOUT', '45')) + interval = int(os.environ.get('MIROFISH_HEARTBEAT_INTERVAL', '5')) + while True: + time.sleep(interval) + if time.time() - last_heartbeat["ts"] > timeout: + logger.warning("心跳超时,自动关闭后端服务") + os._exit(0) if should_log_startup: logger.info("MiroFish Backend 启动完成") + monitor = threading.Thread(target=heartbeat_watch, daemon=True) + monitor.start() + logger.info("心跳监控已启动") return app diff --git a/backend/app/config.py b/backend/app/config.py index 953dfa50..602327bc 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -4,17 +4,49 @@ """ import os -from dotenv import load_dotenv +from io import StringIO +from dotenv import load_dotenv, dotenv_values # 加载项目根目录的 .env 文件 # 路径: MiroFish/.env (相对于 backend/app/config.py) project_root_env = os.path.join(os.path.dirname(__file__), '../../.env') + +def _decode_env_bytes(data: bytes) -> str: + for encoding in ("utf-8", "utf-8-sig", "gbk", "gb2312"): + try: + return data.decode(encoding) + except UnicodeDecodeError: + continue + return data.decode("utf-8", errors="replace") + + +def _load_env_with_fallback(path: str) -> None: + if not os.path.exists(path): + return + data = _decode_env_bytes(open(path, "rb").read()) + try: + values = dotenv_values(stream=StringIO(data)) + for key, value in values.items(): + if key and value is not None: + os.environ[key] = value + return + except Exception: + pass + for line in data.splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + os.environ[key.strip()] = value.strip() + + if os.path.exists(project_root_env): - load_dotenv(project_root_env, override=True) + _load_env_with_fallback(project_root_env) else: - # 如果根目录没有 .env,尝试加载环境变量(用于生产环境) - load_dotenv(override=True) + default_env = os.path.join(os.getcwd(), '.env') + if os.path.exists(default_env): + _load_env_with_fallback(default_env) class Config: diff --git a/backend/mirofish_backend.spec b/backend/mirofish_backend.spec new file mode 100644 index 00000000..2863f626 --- /dev/null +++ b/backend/mirofish_backend.spec @@ -0,0 +1,116 @@ +# -*- mode: python ; coding: utf-8 -*- +""" +MiroFish Backend PyInstaller 规格文件 +用于将 Flask 后端打包成 Windows 可执行文件 +""" + +import os +import sys +from PyInstaller.utils.hooks import collect_all, collect_data_files, collect_submodules + +# 项目路径 +backend_dir = os.path.abspath(os.path.dirname(SPECPATH)) + +# 收集 app 目录下的所有文件 +app_datas = [] +app_dir = os.path.join(backend_dir, 'app') +for root, dirs, files in os.walk(app_dir): + for file in files: + if not file.endswith('.pyc') and not file.endswith('__pycache__'): + src = os.path.join(root, file) + dst = os.path.relpath(root, backend_dir) + app_datas.append((src, dst)) + +# 隐藏导入列表 +hidden_imports = [ + # Flask 相关 + 'flask', + 'flask_cors', + 'werkzeug', + 'jinja2', + 'markupsafe', + + # OpenAI / LLM + 'openai', + 'httpx', + 'httpcore', + + # Zep Cloud + 'zep_cloud', + + # Pydantic + 'pydantic', + 'pydantic.deprecated.decorator', + + # 数据处理 + 'charset_normalizer', + 'chardet', + 'fitz', # PyMuPDF + + # dotenv + 'dotenv', + 'python_dotenv', + + # 其他依赖 + 'logging.handlers', + 'email.mime.text', + 'email.mime.multipart', +] + +# 收集大型包的所有模块 +datas = app_datas +binaries = [] + +# 分析配置 +a = Analysis( + [os.path.join(backend_dir, 'run.py')], + pathex=[backend_dir], + binaries=binaries, + datas=datas, + hiddenimports=hidden_imports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[ + # 排除不需要的大型包以减小体积 + 'matplotlib', + 'scipy', + 'numpy.distutils', + 'PIL', + 'tkinter', + 'test', + 'unittest', + ], + noarchive=False, + optimize=0, +) + +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='mirofish_backend', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=True, # 保留控制台以便调试 + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) + +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='mirofish_backend', +) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8c4fa710..fee02cad 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1331,7 +1331,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -1809,7 +1808,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1943,7 +1941,6 @@ "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -2018,7 +2015,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.25.tgz", "integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.25", "@vue/compiler-sfc": "3.5.25", diff --git a/frontend/src/main.js b/frontend/src/main.js index c8e37b03..e85c28fb 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -7,3 +7,11 @@ const app = createApp(App) app.use(router) app.mount('#app') + +const apiBase = (import.meta.env.VITE_API_BASE_URL || 'http://127.0.0.1:5001').replace(/\/$/, '') +const heartbeatUrl = `${apiBase}/api/heartbeat` +const heartbeat = () => { + fetch(heartbeatUrl, { method: 'POST', keepalive: true }).catch(() => {}) +} +heartbeat() +setInterval(heartbeat, 10000) diff --git a/installer/ChineseSimplified.isl b/installer/ChineseSimplified.isl new file mode 100644 index 00000000..d6a11c44 --- /dev/null +++ b/installer/ChineseSimplified.isl @@ -0,0 +1,418 @@ +; *** Inno Setup version 6.5.0+ Chinese Simplified messages *** +; +; To download user-contributed translations of this file, go to: +; https://jrsoftware.org/files/istrans/ +; +; Note: When translating this text, do not add periods (.) to the end of +; messages that didn't have them already, because on those messages Inno +; Setup adds the periods automatically (appending a period would result in +; two periods being displayed). +; +; Maintained by Zhenghan Yang +; Email: 847320916@QQ.com +; Translation based on network resource +; The latest Translation is on https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation +; + +[LangOptions] +; The following three entries are very important. Be sure to read and +; understand the '[LangOptions] section' topic in the help file. +LanguageName=简体中文 +; If Language Name display incorrect, uncomment next line +; LanguageName=<7B80><4F53><4E2D><6587> +; About LanguageID, to reference link: +; https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-lcid/a9eac961-e77d-41a6-90a5-ce1a8b0cdb9c +LanguageID=$0804 +; About CodePage, to reference link: +; https://docs.microsoft.com/en-us/windows/win32/intl/code-page-identifiers +LanguageCodePage=936 +; If the language you are translating to requires special font faces or +; sizes, uncomment any of the following entries and change them accordingly. +;DialogFontName= +;DialogFontSize=9 +;DialogFontBaseScaleWidth=7 +;DialogFontBaseScaleHeight=15 +;WelcomeFontName=Segoe UI +;WelcomeFontSize=14 + +[Messages] + +; *** 应用程序标题 +SetupAppTitle=安装 +SetupWindowTitle=安装 - %1 +UninstallAppTitle=卸载 +UninstallAppFullTitle=%1 卸载 + +; *** Misc. common +InformationTitle=信息 +ConfirmTitle=确认 +ErrorTitle=错误 + +; *** SetupLdr messages +SetupLdrStartupMessage=现在将安装 %1。您想要继续吗? +LdrCannotCreateTemp=无法创建临时文件。安装程序已中止 +LdrCannotExecTemp=无法执行临时目录中的文件。安装程序已中止 +HelpTextNote= + +; *** 启动错误消息 +LastErrorMessage=%1。%n%n错误 %2: %3 +SetupFileMissing=安装目录中缺少文件 %1。请修正这个问题或者获取程序的新副本。 +SetupFileCorrupt=安装文件已损坏。请获取程序的新副本。 +SetupFileCorruptOrWrongVer=安装文件已损坏,或是与这个安装程序的版本不兼容。请修正这个问题或获取新的程序副本。 +InvalidParameter=无效的命令行参数:%n%n%1 +SetupAlreadyRunning=安装程序正在运行。 +WindowsVersionNotSupported=此程序不支持当前计算机运行的 Windows 版本。 +WindowsServicePackRequired=此程序需要 %1 服务包 %2 或更高版本。 +NotOnThisPlatform=此程序不能在 %1 上运行。 +OnlyOnThisPlatform=此程序只能在 %1 上运行。 +OnlyOnTheseArchitectures=此程序只能安装到为下列处理器架构设计的 Windows 版本中:%n%n%1 +WinVersionTooLowError=此程序需要 %1 版本 %2 或更高。 +WinVersionTooHighError=此程序不能安装于 %1 版本 %2 或更高。 +AdminPrivilegesRequired=在安装此程序时您必须以管理员身份登录。 +PowerUserPrivilegesRequired=在安装此程序时您必须以管理员身份或有权限的用户组身份登录。 +SetupAppRunningError=安装程序发现 %1 当前正在运行。%n%n请先关闭正在运行的程序,然后点击“确定”继续,或点击“取消”退出。 +UninstallAppRunningError=卸载程序发现 %1 当前正在运行。%n%n请先关闭正在运行的程序,然后点击“确定”继续,或点击“取消”退出。 + +; *** 启动问题 +PrivilegesRequiredOverrideTitle=选择安装程序模式 +PrivilegesRequiredOverrideInstruction=选择安装模式 +PrivilegesRequiredOverrideText1=%1 可以为所有用户安装(需要管理员权限),或仅为您安装。 +PrivilegesRequiredOverrideText2=%1 可以仅为您安装,或为所有用户安装(需要管理员权限)。 +PrivilegesRequiredOverrideAllUsers=为所有用户安装(&A) +PrivilegesRequiredOverrideAllUsersRecommended=为所有用户安装(&A) (建议选项) +PrivilegesRequiredOverrideCurrentUser=仅为我安装(&M) +PrivilegesRequiredOverrideCurrentUserRecommended=仅为我安装(&M) (建议选项) + +; *** 其他错误 +ErrorCreatingDir=安装程序无法创建目录“%1” +ErrorTooManyFilesInDir=无法在目录“%1”中创建文件,因为里面包含太多文件 + +; *** 安装程序公共消息 +ExitSetupTitle=退出安装程序 +ExitSetupMessage=安装程序尚未完成。如果现在退出,将不会安装该程序。%n%n您之后可以再次运行安装程序完成安装。%n%n现在退出安装程序吗? +AboutSetupMenuItem=关于安装程序(&A)... +AboutSetupTitle=关于安装程序 +AboutSetupMessage=%1 版本 %2%n%3%n%n%1 主页:%n%4 +AboutSetupNote= +TranslatorNote=简体中文翻译由Kira(847320916@qq.com)维护。项目地址:https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation + +; *** 按钮 +ButtonBack=< 上一步(&B) +ButtonNext=下一步(&N) > +ButtonInstall=安装(&I) +ButtonOK=确定 +ButtonCancel=取消 +ButtonYes=是(&Y) +ButtonYesToAll=全是(&A) +ButtonNo=否(&N) +ButtonNoToAll=全否(&O) +ButtonFinish=完成(&F) +ButtonBrowse=浏览(&B)... +ButtonWizardBrowse=浏览(&R)... +ButtonNewFolder=新建文件夹(&M) + +; *** “选择语言”对话框消息 +SelectLanguageTitle=选择安装语言 +SelectLanguageLabel=选择安装时使用的语言。 + +; *** 公共向导文字 +ClickNext=点击“下一步”继续,或点击“取消”退出安装程序。 +BeveledLabel= +BrowseDialogTitle=浏览文件夹 +BrowseDialogLabel=在下面的列表中选择一个文件夹,然后点击“确定”。 +NewFolderName=新建文件夹 + +; *** “欢迎”向导页 +WelcomeLabel1=欢迎使用 [name] 安装向导 +WelcomeLabel2=现在将安装 [name/ver] 到您的电脑中。%n%n建议您在继续安装前关闭所有其他应用程序。 + +; *** “密码”向导页 +WizardPassword=密码 +PasswordLabel1=这个安装程序有密码保护。 +PasswordLabel3=请输入密码,然后点击“下一步”继续。密码区分大小写。 +PasswordEditLabel=密码(&P): +IncorrectPassword=您输入的密码不正确,请重新输入。 + +; *** “许可协议”向导页 +WizardLicense=许可协议 +LicenseLabel=请在继续安装前阅读以下重要信息。 +LicenseLabel3=请仔细阅读下列许可协议。在继续安装前您必须同意这些协议条款。 +LicenseAccepted=我同意此协议(&A) +LicenseNotAccepted=我不同意此协议(&D) + +; *** “信息”向导页 +WizardInfoBefore=信息 +InfoBeforeLabel=请在继续安装前阅读以下重要信息。 +InfoBeforeClickLabel=准备好继续安装后,点击“下一步”。 +WizardInfoAfter=信息 +InfoAfterLabel=请在继续安装前阅读以下重要信息。 +InfoAfterClickLabel=准备好继续安装后,点击“下一步”。 + +; *** “用户信息”向导页 +WizardUserInfo=用户信息 +UserInfoDesc=请输入您的信息。 +UserInfoName=用户名(&U): +UserInfoOrg=组织(&O): +UserInfoSerial=序列号(&S): +UserInfoNameRequired=您必须输入用户名。 + +; *** “选择目标目录”向导页 +WizardSelectDir=选择目标位置 +SelectDirDesc=您想将 [name] 安装在哪里? +SelectDirLabel3=安装程序将安装 [name] 到下面的文件夹中。 +SelectDirBrowseLabel=点击“下一步”继续。如果您想选择其他文件夹,点击“浏览”。 +DiskSpaceGBLabel=至少需要有 [gb] GB 的可用磁盘空间。 +DiskSpaceMBLabel=至少需要有 [mb] MB 的可用磁盘空间。 +CannotInstallToNetworkDrive=安装程序无法安装到一个网络驱动器。 +CannotInstallToUNCPath=安装程序无法安装到一个 UNC 路径。 +InvalidPath=您必须输入一个带驱动器卷标的完整路径,例如:%n%nC:\APP%n%n或UNC路径:%n%n\\server\share +InvalidDrive=您选定的驱动器或 UNC 共享不存在或不能访问。请选择其他位置。 +DiskSpaceWarningTitle=磁盘空间不足 +DiskSpaceWarning=安装程序至少需要 %1 KB 的可用空间才能安装,但选定驱动器只有 %2 KB 的可用空间。%n%n您一定要继续吗? +DirNameTooLong=文件夹名称或路径太长。 +InvalidDirName=文件夹名称无效。 +BadDirName32=文件夹名称不能包含下列任何字符:%n%n%1 +DirExistsTitle=文件夹已存在 +DirExists=文件夹:%n%n%1%n%n已经存在。您一定要安装到这个文件夹中吗? +DirDoesntExistTitle=文件夹不存在 +DirDoesntExist=文件夹:%n%n%1%n%n不存在。您想要创建此文件夹吗? + +; *** “选择组件”向导页 +WizardSelectComponents=选择组件 +SelectComponentsDesc=您想安装哪些程序组件? +SelectComponentsLabel2=选中您想安装的组件;取消您不想安装的组件。然后点击“下一步”继续。 +FullInstallation=完全安装 +; if possible don't translate 'Compact' as 'Minimal' (I mean 'Minimal' in your language) +CompactInstallation=简洁安装 +CustomInstallation=自定义安装 +NoUninstallWarningTitle=组件已存在 +NoUninstallWarning=安装程序检测到下列组件已安装在您的电脑中:%n%n%1%n%n取消选中这些组件不会卸载它们。%n%n确定要继续吗? +ComponentSize1=%1 KB +ComponentSize2=%1 MB +ComponentsDiskSpaceGBLabel=当前选择的组件需要至少 [gb] GB 的磁盘空间。 +ComponentsDiskSpaceMBLabel=当前选择的组件需要至少 [mb] MB 的磁盘空间。 + +; *** “选择附加任务”向导页 +WizardSelectTasks=选择附加任务 +SelectTasksDesc=您想要安装程序执行哪些附加任务? +SelectTasksLabel2=选择您想要安装程序在安装 [name] 时执行的附加任务,然后点击“下一步”。 + +; *** “选择开始菜单文件夹”向导页 +WizardSelectProgramGroup=选择开始菜单文件夹 +SelectStartMenuFolderDesc=安装程序应该在哪里放置程序的快捷方式? +SelectStartMenuFolderLabel3=安装程序将在下列“开始”菜单文件夹中创建程序的快捷方式。 +SelectStartMenuFolderBrowseLabel=点击“下一步”继续。如果您想选择其他文件夹,点击“浏览”。 +MustEnterGroupName=您必须输入一个文件夹名。 +GroupNameTooLong=文件夹名或路径太长。 +InvalidGroupName=无效的文件夹名字。 +BadGroupName=文件夹名不能包含下列任何字符:%n%n%1 +NoProgramGroupCheck2=不创建开始菜单文件夹(&D) + +; *** “准备安装”向导页 +WizardReady=准备安装 +ReadyLabel1=安装程序准备就绪,现在可以开始安装 [name] 到您的电脑。 +ReadyLabel2a=点击“安装”继续此安装程序。如果您想重新考虑或修改任何设置,点击“上一步”。 +ReadyLabel2b=点击“安装”继续此安装程序。 +ReadyMemoUserInfo=用户信息: +ReadyMemoDir=目标位置: +ReadyMemoType=安装类型: +ReadyMemoComponents=已选择组件: +ReadyMemoGroup=开始菜单文件夹: +ReadyMemoTasks=附加任务: + +; *** TExtractionWizardPage 向导页面与 ExtractArchive +ExtractingLabel=正在解压文件... +ButtonStopExtraction=停止解压(&S) +StopExtraction=您确定要停止解压吗? +ErrorExtractionAborted=解压已中止 +ErrorExtractionFailed=解压失败:%1 + +; *** 压缩文件解压失败详情 +ArchiveIncorrectPassword=压缩文件密码不正确 +ArchiveIsCorrupted=压缩文件已损坏 +ArchiveUnsupportedFormat=不支持的压缩文件格式 + +; *** TDownloadWizardPage 向导页面和 DownloadTemporaryFile +DownloadingLabel2=正在下载文件... +ButtonStopDownload=停止下载(&S) +StopDownload=您确定要停止下载吗? +ErrorDownloadAborted=下载已中止 +ErrorDownloadFailed=下载失败:%1 %2 +ErrorDownloadSizeFailed=获取下载大小失败:%1 %2 +ErrorProgress=无效的进度:%1 / %2 +ErrorFileSize=文件大小错误:预期 %1,实际 %2 + +; *** “正在准备安装”向导页 +WizardPreparing=正在准备安装 +PreparingDesc=安装程序正在准备安装 [name] 到您的电脑。 +PreviousInstallNotCompleted=先前的程序安装或卸载未完成,您需要重启您的电脑以完成。%n%n在重启电脑后,再次运行安装程序以完成 [name] 的安装。 +CannotContinue=安装程序不能继续。请点击“取消”退出。 +ApplicationsFound=以下应用程序正在使用将由安装程序更新的文件。建议您允许安装程序自动关闭这些应用程序。 +ApplicationsFound2=以下应用程序正在使用将由安装程序更新的文件。建议您允许安装程序自动关闭这些应用程序。安装完成后,安装程序将尝试重新启动这些应用程序。 +CloseApplications=自动关闭应用程序(&A) +DontCloseApplications=不要关闭应用程序(&D) +ErrorCloseApplications=安装程序无法自动关闭所有应用程序。建议您在继续之前,关闭所有在使用需要由安装程序更新的文件的应用程序。 +PrepareToInstallNeedsRestart=安装程序必须重启您的计算机。计算机重启后,请再次运行安装程序以完成 [name] 的安装。%n%n是否立即重新启动? + +; *** “正在安装”向导页 +WizardInstalling=正在安装 +InstallingLabel=安装程序正在安装 [name] 到您的电脑,请稍候。 + +; *** “安装完成”向导页 +FinishedHeadingLabel=[name] 安装完成 +FinishedLabelNoIcons=安装程序已在您的电脑中安装了 [name]。 +FinishedLabel=安装程序已在您的电脑中安装了 [name]。您可以通过已安装的快捷方式运行此应用程序。 +ClickFinish=点击“完成”退出安装程序。 +FinishedRestartLabel=为完成 [name] 的安装,安装程序必须重新启动您的电脑。要立即重启吗? +FinishedRestartMessage=为完成 [name] 的安装,安装程序必须重新启动您的电脑。%n%n要立即重启吗? +ShowReadmeCheck=是,我想查阅自述文件 +YesRadio=是,立即重启电脑(&Y) +NoRadio=否,稍后重启电脑(&N) +; used for example as 'Run MyProg.exe' +RunEntryExec=运行 %1 +; used for example as 'View Readme.txt' +RunEntryShellExec=查阅 %1 + +; *** “安装程序需要下一张磁盘”提示 +ChangeDiskTitle=安装程序需要下一张磁盘 +SelectDiskLabel2=请插入磁盘 %1 并点击“确定”。%n%n如果这个磁盘中的文件可以在下列文件夹之外的文件夹中找到,请输入正确的路径或点击“浏览”。 +PathLabel=路径(&P): +FileNotInDir2=“%2”中找不到文件“%1”。请插入正确的磁盘或选择其他文件夹。 +SelectDirectoryLabel=请指定下一张磁盘的位置。 + +; *** 安装阶段消息 +SetupAborted=安装程序未完成安装。%n%n请修正这个问题并重新运行安装程序。 +AbortRetryIgnoreSelectAction=选择操作 +AbortRetryIgnoreRetry=重试(&T) +AbortRetryIgnoreIgnore=忽略错误并继续(&I) +AbortRetryIgnoreCancel=关闭安装程序 +RetryCancelSelectAction=选择操作 +RetryCancelRetry=重试(&T) +RetryCancelCancel=取消(&C) + +; *** 安装状态消息 +StatusClosingApplications=正在关闭应用程序... +StatusCreateDirs=正在创建目录... +StatusExtractFiles=正在提取文件... +StatusDownloadFiles=正在下载文件... +StatusCreateIcons=正在创建快捷方式... +StatusCreateIniEntries=正在创建 INI 条目... +StatusCreateRegistryEntries=正在创建注册表条目... +StatusRegisterFiles=正在注册文件... +StatusSavingUninstall=正在保存卸载信息... +StatusRunProgram=正在完成安装... +StatusRestartingApplications=正在重启应用程序... +StatusRollback=正在撤销更改... + +; *** 其他错误 +ErrorInternal2=内部错误:%1 +ErrorFunctionFailedNoCode=%1 失败 +ErrorFunctionFailed=%1 失败;错误代码 %2 +ErrorFunctionFailedWithMessage=%1 失败;错误代码 %2.%n%3 +ErrorExecutingProgram=无法执行文件:%n%1 + +; *** 注册表错误 +ErrorRegOpenKey=打开注册表项时出错:%n%1\%2 +ErrorRegCreateKey=创建注册表项时出错:%n%1\%2 +ErrorRegWriteKey=写入注册表项时出错:%n%1\%2 + +; *** INI 错误 +ErrorIniEntry=在文件“%1”中创建 INI 条目时出错。 + +; *** 文件复制错误 +FileAbortRetryIgnoreSkipNotRecommended=跳过此文件(&S) (不推荐) +FileAbortRetryIgnoreIgnoreNotRecommended=忽略错误并继续(&I) (不推荐) +SourceIsCorrupted=源文件已损坏 +SourceDoesntExist=源文件“%1”不存在 +SourceVerificationFailed=源文件验证失败: %1 +VerificationSignatureDoesntExist=签名文件“%1”不存在 +VerificationSignatureInvalid=签名文件“%1”无效 +VerificationKeyNotFound=签名文件“%1”使用了未知密钥 +VerificationFileNameIncorrect=文件名不正确 +VerificationFileTagIncorrect=文件标签不正确 +VerificationFileSizeIncorrect=文件大小不正确 +VerificationFileHashIncorrect=文件哈希值不正确 +ExistingFileReadOnly2=无法替换现有文件,它是只读的。 +ExistingFileReadOnlyRetry=移除只读属性并重试(&R) +ExistingFileReadOnlyKeepExisting=保留现有文件(&K) +ErrorReadingExistingDest=尝试读取现有文件时出错: +FileExistsSelectAction=选择操作 +FileExists2=文件已经存在。 +FileExistsOverwriteExisting=覆盖已存在的文件(&O) +FileExistsKeepExisting=保留现有的文件(&K) +FileExistsOverwriteOrKeepAll=为所有冲突文件执行此操作(&D) +ExistingFileNewerSelectAction=选择操作 +ExistingFileNewer2=现有的文件比安装程序将要安装的文件还要新。 +ExistingFileNewerOverwriteExisting=覆盖已存在的文件(&O) +ExistingFileNewerKeepExisting=保留现有的文件(&K) (推荐) +ExistingFileNewerOverwriteOrKeepAll=为所有冲突文件执行此操作(&D) +ErrorChangingAttr=尝试更改下列现有文件的属性时出错: +ErrorCreatingTemp=尝试在目标目录创建文件时出错: +ErrorReadingSource=尝试读取下列源文件时出错: +ErrorCopying=尝试复制下列文件时出错: +ErrorDownloading=下载文件时出错: +ErrorExtracting=解压压缩文件时出错: +ErrorReplacingExistingFile=尝试替换现有文件时出错: +ErrorRestartReplace=重启并替换失败: +ErrorRenamingTemp=尝试重命名下列目标目录中的一个文件时出错: +ErrorRegisterServer=无法注册 DLL/OCX:%1 +ErrorRegSvr32Failed=RegSvr32 失败;退出代码 %1 +ErrorRegisterTypeLib=无法注册类库:%1 + +; *** 卸载显示名字标记 +; used for example as 'My Program (32-bit)' +UninstallDisplayNameMark=%1 (%2) +; used for example as 'My Program (32-bit, All users)' +UninstallDisplayNameMarks=%1 (%2, %3) +UninstallDisplayNameMark32Bit=32 位 +UninstallDisplayNameMark64Bit=64 位 +UninstallDisplayNameMarkAllUsers=所有用户 +UninstallDisplayNameMarkCurrentUser=当前用户 + +; *** 安装后错误 +ErrorOpeningReadme=尝试打开自述文件时出错。 +ErrorRestartingComputer=安装程序无法重启电脑,请手动重启。 + +; *** 卸载消息 +UninstallNotFound=文件“%1”不存在。无法卸载。 +UninstallOpenError=文件“%1”不能被打开。无法卸载。 +UninstallUnsupportedVer=此版本的卸载程序无法识别卸载日志文件“%1”的格式。无法卸载 +UninstallUnknownEntry=卸载日志中遇到一个未知条目 (%1) +ConfirmUninstall=您确认要完全移除 %1 及其所有组件吗? +UninstallOnlyOnWin64=仅允许在 64 位 Windows 中卸载此程序。 +OnlyAdminCanUninstall=仅使用管理员权限的用户能完成此卸载。 +UninstallStatusLabel=正在从您的电脑中移除 %1,请稍候。 +UninstalledAll=已顺利从您的电脑中移除 %1。 +UninstalledMost=%1 卸载完成。%n%n有部分内容未能被删除,但您可以手动删除它们。 +UninstalledAndNeedsRestart=为完成 %1 的卸载,需要重启您的电脑。%n%n立即重启电脑吗? +UninstallDataCorrupted=文件“%1”已损坏。无法卸载 + +; *** 卸载状态消息 +ConfirmDeleteSharedFileTitle=删除共享的文件吗? +ConfirmDeleteSharedFile2=系统表示下列共享的文件已不有其他程序使用。您希望卸载程序删除这些共享的文件吗?%n%n如果删除这些文件,但仍有程序在使用这些文件,则这些程序可能出现异常。如果您不能确定,请选择“否”,在系统中保留这些文件以免引发问题。 +SharedFileNameLabel=文件名: +SharedFileLocationLabel=位置: +WizardUninstalling=卸载状态 +StatusUninstalling=正在卸载 %1... + +; *** Shutdown block reasons +ShutdownBlockReasonInstallingApp=正在安装 %1。 +ShutdownBlockReasonUninstallingApp=正在卸载 %1。 + +; The custom messages below aren't used by Setup itself, but if you make +; use of them in your scripts, you'll want to translate them. + +[CustomMessages] + +NameAndVersion=%1 版本 %2 +AdditionalIcons=附加快捷方式: +CreateDesktopIcon=创建桌面快捷方式(&D) +CreateQuickLaunchIcon=创建快速启动栏快捷方式(&Q) +ProgramOnTheWeb=%1 网站 +UninstallProgram=卸载 %1 +LaunchProgram=运行 %1 +AssocFileExtension=将 %2 文件扩展名与 %1 建立关联(&A) +AssocingFileExtension=正在将 %2 文件扩展名与 %1 建立关联... +AutoStartProgramGroupDescription=启动: +AutoStartProgram=自动启动 %1 +AddonHostProgramNotFound=您选择的文件夹中无法找到 %1。%n%n您要继续吗? diff --git a/installer/MiroFish.ico b/installer/MiroFish.ico new file mode 100644 index 0000000000000000000000000000000000000000..05553b2c0e650840637a2b809ddbc2464365efb9 GIT binary patch literal 685 zcmV;e0#f||0096201yxW0000W0G9#)02TlM0EtjeM-2)Z3IG5A4M|8uQUCw|5C8xG z5C{eU001BJ|6u?C0$xc(K~#90jgw1H6Hyd^zkBbropxqwsgx42SQ3m#RUmPp8+XJN zTYrLo%YUE?O>`j(5{-!oO2jA{Um#*bTRJU$&f{J;fMC>cR_B#3Imvf~*4l6oLI^s8 z5Z6v%%u)NlApwjr2qCEM?X&#q6V+xHAq1yk$iu`K!%n@;{PYC7&CUVzlmICu{b7KT zibBpM)Q0{rL>v%~pY!KwQULuhrZ;zxTxKL~#>NqGc2%nxhp;o2roKGe7~ zTVQf5$G3W$K^PN8nmE=NV`#P7I8K^WDn+ZkkCKvPGJ#SGX@ZcAuT|FD0o@>Cd)FtW zU>Fw)z1_&f~8v(B9$bLVrqT}#vp|xoApR06Xf$bI-M?a za}_Y~ ⚠️ 注意:PyInstaller 模式打包时间长,输出文件可能超过 1GB + +### 方式三:分步执行 + +如果只需要部分步骤,可以使用参数跳过: + +```powershell +# 跳过前端构建(如果前端没有修改) +.\installer\build.ps1 -SkipFrontend + +# 跳过后端处理(如果后端没有修改) +.\installer\build.ps1 -SkipBackend + +# 跳过安装程序创建(只生成可执行文件) +.\installer\build.ps1 -SkipInstaller + +# 清理旧构建后重新开始 +.\installer\build.ps1 -Clean +``` + +## 输出文件 + +打包完成后,会生成以下文件(嵌入式 Python 模式): + +``` +MiroFish_exe/ +├── dist/ +│ └── MiroFish/ # 可直接运行的目录 +│ ├── MiroFish.exe # 主启动器(双击运行) +│ ├── python/ # 嵌入式 Python 运行时 +│ │ ├── python.exe +│ │ ├── Lib/ +│ │ └── ... +│ ├── backend/ # 后端源代码 +│ │ ├── run.py +│ │ ├── app/ +│ │ └── .env # 安装时生成的配置 +│ └── frontend/ +│ └── dist/ # 静态文件 +│ +└── installer/ + └── output/ + └── MiroFish_Setup_0.1.0.exe # 安装程序 +``` + +## 安装程序功能 + +生成的安装程序 `MiroFish_Setup_0.1.0.exe` 包含: + +1. **欢迎页面**:显示应用信息 +2. **许可协议**:AGPL-3.0 许可证 +3. **安装目录选择**:用户可自定义安装位置 +4. **API 配置页面**: + - LLM API Key(必填) + - LLM Base URL(默认:阿里百炼) + - LLM Model Name(默认:qwen-plus) + - ZEP API Key(必填) +5. **安装进度**:显示文件复制进度 +6. **完成页面**:可选立即启动 + +## 常见问题 + +### Inno Setup 未找到 + +如果看到警告"未找到 Inno Setup",请: +1. 下载安装 [Inno Setup 6](https://jrsoftware.org/isinfo.php) +2. 确保安装到默认位置或将路径添加到环境变量 +3. 重新运行打包脚本 + +### 后端打包失败 + +检查以下几点: +1. 确保 Python 3.11+ 已安装 +2. 确保 uv 包管理器已安装:`pip install uv` +3. 确保后端依赖已安装:`cd backend && uv sync` + +### 前端构建失败 + +检查以下几点: +1. 确保 Node.js 18+ 已安装 +2. 确保前端依赖已安装:`cd frontend && npm install` + +### 运行时缺少 DLL + +PyInstaller 已自动包含大部分依赖,如果仍有问题: +1. 在干净的 Windows 环境测试 +2. 安装 [Visual C++ Redistributable](https://aka.ms/vs/17/release/vc_redist.x64.exe) + +## 修改配置页面 + +如需修改安装程序的配置页面,编辑 `installer/setup.iss` 文件: + +- 添加/删除配置项:修改 `InitializeWizard` 过程 +- 修改默认值:修改对应的 `Edit.Text` 属性 +- 修改验证逻辑:修改 `NextButtonClick` 函数 +- 修改 .env 生成:修改 `CurStepChanged` 过程 + +## 版本更新 + +更新版本号时,修改以下位置: + +1. `installer/setup.iss` 第 7 行:`#define MyAppVersion "x.x.x"` +2. `package.json` 中的 `version` 字段 +3. `backend/pyproject.toml` 中的 `version` 字段 diff --git a/installer/build.ps1 b/installer/build.ps1 new file mode 100644 index 00000000..be5b2ad9 --- /dev/null +++ b/installer/build.ps1 @@ -0,0 +1,230 @@ +# MiroFish Windows build script +# Requirements: Python 3.11+, Node.js 18+, Inno Setup 6 +# +# Usage: +# .\build.ps1 +# .\build.ps1 -PyInstaller +# .\build.ps1 -SkipFrontend +# .\build.ps1 -SkipBackend +# .\build.ps1 -SkipInstaller +# .\build.ps1 -Clean + +param( + [switch]$SkipFrontend, + [switch]$SkipBackend, + [switch]$SkipInstaller, + [switch]$Clean, + [switch]$PyInstaller +) + +$ErrorActionPreference = "Stop" +$ProjectRoot = Split-Path -Parent $PSScriptRoot +$DistDir = Join-Path $ProjectRoot "dist" +$InstallerDir = Join-Path $ProjectRoot "installer" +$PythonVersion = "3.11.9" +$PythonEmbedUrl = "https://www.python.org/ftp/python/$PythonVersion/python-$PythonVersion-embed-amd64.zip" + +Write-Host "============================================" -ForegroundColor Cyan +Write-Host " MiroFish Windows Builder" -ForegroundColor Cyan +Write-Host "============================================" -ForegroundColor Cyan +Write-Host "" + +if ($PyInstaller) { + Write-Host "Mode: PyInstaller (large, standalone)" -ForegroundColor Magenta +} else { + Write-Host "Mode: Embedded Python (recommended)" -ForegroundColor Magenta +} +Write-Host "" + +if ($Clean -or -not (Test-Path $DistDir)) { + Write-Host "[1/5] Cleaning output..." -ForegroundColor Yellow + if (Test-Path $DistDir) { + Remove-Item -Recurse -Force $DistDir + } + New-Item -ItemType Directory -Force -Path $DistDir | Out-Null +} + +$MiroFishDist = Join-Path $DistDir "MiroFish" +New-Item -ItemType Directory -Force -Path $MiroFishDist | Out-Null +New-Item -ItemType Directory -Force -Path (Join-Path $MiroFishDist "backend") | Out-Null +New-Item -ItemType Directory -Force -Path (Join-Path $MiroFishDist "frontend") | Out-Null + +if (-not $SkipFrontend) { + Write-Host "[2/5] Building frontend..." -ForegroundColor Yellow + Push-Location (Join-Path $ProjectRoot "frontend") + try { + npm install + npm run build + $FrontendDist = Join-Path -Path $ProjectRoot -ChildPath "frontend\\dist" + $TargetFrontendDist = Join-Path -Path $MiroFishDist -ChildPath "frontend\\dist" + if (Test-Path $FrontendDist) { + if (Test-Path $TargetFrontendDist) { + Remove-Item -Recurse -Force $TargetFrontendDist + } + Copy-Item -Recurse -Force $FrontendDist $TargetFrontendDist + Write-Host " Frontend build complete" -ForegroundColor Green + } else { + throw "Frontend build failed: dist not found" + } + } finally { + Pop-Location + } +} else { + Write-Host "[2/5] Skipped frontend build" -ForegroundColor Gray +} + +if (-not $SkipBackend) { + Write-Host "[3/5] Preparing backend..." -ForegroundColor Yellow + if ($PyInstaller) { + Write-Host " Using PyInstaller..." -ForegroundColor Gray + Push-Location (Join-Path $ProjectRoot "backend") + try { + uv sync + uv pip install pyinstaller + uv run pyinstaller ` + --name "mirofish_backend" ` + --distpath "$DistDir\backend_temp" ` + --workpath "$DistDir\build" ` + --specpath "$DistDir" ` + --add-data "app;app" ` + --hidden-import "flask" ` + --hidden-import "flask_cors" ` + --hidden-import "openai" ` + --hidden-import "zep_cloud" ` + --hidden-import "pydantic" ` + --hidden-import "dotenv" ` + --hidden-import "charset_normalizer" ` + --hidden-import "chardet" ` + --hidden-import "fitz" ` + --noconfirm ` + --console ` + run.py + $BackendBuild = Join-Path -Path $DistDir -ChildPath "backend_temp\\mirofish_backend" + if (Test-Path $BackendBuild) { + Copy-Item -Recurse -Force "$BackendBuild\*" (Join-Path $MiroFishDist "backend") + Write-Host " Backend build complete" -ForegroundColor Green + } else { + throw "Backend build failed" + } + } finally { + Pop-Location + } + } else { + Write-Host " Downloading embedded Python $PythonVersion..." -ForegroundColor Gray + $PythonZip = Join-Path $DistDir "python-embed.zip" + $PythonDir = Join-Path $MiroFishDist "python" + if (-not (Test-Path $PythonZip)) { + Invoke-WebRequest -Uri $PythonEmbedUrl -OutFile $PythonZip + } + Write-Host " Extracting embedded Python..." -ForegroundColor Gray + Expand-Archive -Path $PythonZip -DestinationPath $PythonDir -Force + + $PthFile = Get-ChildItem -Path $PythonDir -Filter "python*._pth" | Select-Object -First 1 + if ($PthFile) { + $PthContent = Get-Content $PthFile.FullName + $PthContent = $PthContent -replace "^#import site", "import site" + if ($PthContent -notcontains "Lib\site-packages") { + $PthContent += "Lib\site-packages" + } + Set-Content -Path $PthFile.FullName -Value $PthContent + } + + Write-Host " Installing pip..." -ForegroundColor Gray + $GetPipUrl = "https://bootstrap.pypa.io/get-pip.py" + $GetPipPath = Join-Path $DistDir "get-pip.py" + if (-not (Test-Path $GetPipPath)) { + Invoke-WebRequest -Uri $GetPipUrl -OutFile $GetPipPath + } + $PythonExe = Join-Path $PythonDir "python.exe" + & $PythonExe $GetPipPath --no-warn-script-location + + Write-Host " Installing backend deps..." -ForegroundColor Gray + $RequirementsFile = Join-Path -Path $ProjectRoot -ChildPath "backend\\requirements.txt" + & $PythonExe -m pip install -r $RequirementsFile --no-warn-script-location -q + + Write-Host " Copying backend sources..." -ForegroundColor Gray + $BackendSrc = Join-Path -Path $ProjectRoot -ChildPath "backend" + $BackendDst = Join-Path -Path $MiroFishDist -ChildPath "backend" + Copy-Item -Force (Join-Path -Path $BackendSrc -ChildPath "run.py") $BackendDst + Copy-Item -Recurse -Force (Join-Path -Path $BackendSrc -ChildPath "app") (Join-Path -Path $BackendDst -ChildPath "app") + Write-Host " Backend prepared" -ForegroundColor Green + } +} else { + Write-Host "[3/5] Skipped backend" -ForegroundColor Gray +} + +Write-Host "[4/5] Building launcher..." -ForegroundColor Yellow +Push-Location $ProjectRoot +try { + $pip = pip --version 2>$null + if (-not $?) { + Write-Host " Installing PyInstaller..." -ForegroundColor Gray + } + pip install pyinstaller -q + pyinstaller ` + --name "MiroFish" ` + --distpath "$DistDir\launcher_temp" ` + --workpath "$DistDir\build" ` + --specpath "$DistDir" ` + --icon "$InstallerDir\MiroFish.ico" ` + --onefile ` + --windowed ` + --noconfirm ` + launcher.py + $LauncherExe = Join-Path -Path $DistDir -ChildPath "launcher_temp\\MiroFish.exe" + Copy-Item -Force $LauncherExe $MiroFishDist + Write-Host " Launcher built" -ForegroundColor Green +} finally { + Pop-Location +} + +if (-not $SkipInstaller) { + Write-Host "[5/5] Building installer..." -ForegroundColor Yellow + $ISCCPaths = @( + "C:\Program Files (x86)\Inno Setup 6\ISCC.exe", + "C:\Program Files\Inno Setup 6\ISCC.exe", + "$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe" + ) + $ISCC = $null + foreach ($path in $ISCCPaths) { + if (Test-Path $path) { + $ISCC = $path + break + } + } + if ($ISCC) { + Push-Location $InstallerDir + try { + & $ISCC "setup.iss" + Write-Host " Installer built" -ForegroundColor Green + Write-Host (" Output: {0}" -f (Join-Path $InstallerDir "output")) -ForegroundColor Cyan + } finally { + Pop-Location + } + } else { + Write-Host " Inno Setup not found; skipping installer" -ForegroundColor Yellow + Write-Host " Install Inno Setup 6: https://jrsoftware.org/isinfo.php" -ForegroundColor Yellow + } +} else { + Write-Host "[5/5] Skipped installer" -ForegroundColor Gray +} + +Write-Host "" +Write-Host "Cleaning temp files..." -ForegroundColor Gray +Remove-Item -Recurse -Force (Join-Path $DistDir "backend_temp") -ErrorAction SilentlyContinue +Remove-Item -Recurse -Force (Join-Path $DistDir "launcher_temp") -ErrorAction SilentlyContinue +Remove-Item -Recurse -Force (Join-Path $DistDir "build") -ErrorAction SilentlyContinue +Remove-Item -Force (Join-Path $DistDir "*.spec") -ErrorAction SilentlyContinue +Remove-Item -Force (Join-Path $DistDir "python-embed.zip") -ErrorAction SilentlyContinue +Remove-Item -Force (Join-Path $DistDir "get-pip.py") -ErrorAction SilentlyContinue + +Write-Host "" +Write-Host "============================================" -ForegroundColor Green +Write-Host " Done" -ForegroundColor Green +Write-Host "============================================" -ForegroundColor Green +Write-Host "" +Write-Host "Output: $MiroFishDist" -ForegroundColor Cyan +if (Test-Path (Join-Path $InstallerDir "output")) { + Write-Host ("Installer: {0}" -f (Join-Path $InstallerDir "output")) -ForegroundColor Cyan +} +Write-Host "" diff --git a/installer/setup.iss b/installer/setup.iss new file mode 100644 index 00000000..af31b225 --- /dev/null +++ b/installer/setup.iss @@ -0,0 +1,247 @@ +; MiroFish 安装程序脚本 +; 使用 Inno Setup 6.x 编译 +; 中文界面,包含 API 配置页面 + +#define MyAppName "MiroFish" +#define MyAppVersion "0.1.0" +#define MyAppPublisher "MiroFish Team" +#define MyAppURL "https://github.com/666ghj/MiroFish" +#define MyAppExeName "MiroFish.exe" + +[Setup] +; 基本信息 +AppId={{A1B2C3D4-E5F6-7890-ABCD-EF1234567890} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +AppVerName={#MyAppName} {#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL={#MyAppURL} +AppSupportURL={#MyAppURL} +AppUpdatesURL={#MyAppURL} + +; 安装目录 +DefaultDirName={autopf}\{#MyAppName} +DefaultGroupName={#MyAppName} +DisableProgramGroupPage=yes + +; 输出设置 +OutputDir=output +OutputBaseFilename=MiroFish_Setup_{#MyAppVersion} +SetupIconFile=MiroFish.ico +UninstallDisplayIcon={app}\MiroFish.ico + +; 压缩设置 +Compression=lzma2/ultra64 +SolidCompression=yes +LZMAUseSeparateProcess=yes + +; 权限设置 +PrivilegesRequired=admin +PrivilegesRequiredOverridesAllowed=dialog + +; 向导设置 +WizardStyle=modern +WizardSizePercent=100 + +; 语言设置 - 使用中文 +[Languages] +Name: "chinesesimplified"; MessagesFile: "ChineseSimplified.isl" + +[CustomMessages] +chinesesimplified.ConfigTitle=API 配置 +chinesesimplified.ConfigSubtitle=请填写必要的 API 密钥 +chinesesimplified.ConfigInfo=提示: LLM 推荐使用阿里百炼平台 qwen-plus 模型,ZEP 每月免费额度即可支撑简单使用。 +chinesesimplified.LLMApiKeyLabel=LLM API Key (必填): +chinesesimplified.LLMBaseURLLabel=LLM Base URL (可保持默认): +chinesesimplified.LLMModelNameLabel=LLM 模型名称 (可保持默认): +chinesesimplified.ZEPApiKeyLabel=ZEP API Key (必填): +chinesesimplified.LLMApiKeyRequired=请输入 LLM API Key! +chinesesimplified.ZEPApiKeyRequired=请输入 ZEP API Key! + +[Tasks] +Name: "desktopicon"; Description: "创建桌面快捷方式"; GroupDescription: "附加选项:" +Name: "quicklaunchicon"; Description: "创建快速启动栏快捷方式"; GroupDescription: "附加选项:"; Flags: unchecked + +[Files] +; 主程序文件(包含 MiroFish.exe、backend、frontend、python 目录) +Source: "..\dist\MiroFish\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs +; 图标文件 +Source: "MiroFish.ico"; DestDir: "{app}"; Flags: ignoreversion + +[Icons] +Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\MiroFish.ico" +Name: "{group}\卸载 {#MyAppName}"; Filename: "{uninstallexe}" +Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\MiroFish.ico"; Tasks: desktopicon +Name: "{userappdata}\Microsoft\Internet Explorer\Quick Launch\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: quicklaunchicon + +[Run] +Filename: "{app}\{#MyAppExeName}"; Description: "立即启动 {#MyAppName}"; Flags: nowait postinstall skipifsilent + +[Code] +var + ConfigPage: TInputQueryWizardPage; + LLMApiKeyEdit: TNewEdit; + LLMBaseURLEdit: TNewEdit; + LLMModelNameEdit: TNewEdit; + ZEPApiKeyEdit: TNewEdit; + +procedure InitializeWizard; +var + LabelHeight: Integer; + EditHeight: Integer; + VerticalSpacing: Integer; + TopPos: Integer; + LabelLLMApiKey, LabelLLMBaseURL, LabelLLMModelName, LabelZEPApiKey: TLabel; + InfoLabel: TNewStaticText; +begin + LabelHeight := 16; + EditHeight := 23; + VerticalSpacing := 8; + // 增加初始 TopPos,避免与页面顶部说明重叠 + TopPos := 10; + + // 创建自定义配置页面 + ConfigPage := CreateInputQueryPage(wpSelectDir, + CustomMessage('ConfigTitle'), + CustomMessage('ConfigSubtitle'), + ''); // 移除这一行的长文本,改用 InfoLabel 显示,以便更好地控制布局 + + // 添加说明信息 (灰色斜体) + InfoLabel := TNewStaticText.Create(ConfigPage); + InfoLabel.Parent := ConfigPage.Surface; + InfoLabel.Caption := CustomMessage('ConfigInfo'); + InfoLabel.Left := 0; + InfoLabel.Top := TopPos; + InfoLabel.AutoSize := False; + InfoLabel.Width := ConfigPage.SurfaceWidth; + InfoLabel.WordWrap := True; // 允许自动换行 + InfoLabel.Font.Style := [fsItalic]; + InfoLabel.Font.Color := clGray; + WizardForm.AdjustLabelHeight(InfoLabel); + + // 重新计算 TopPos,根据文本实际高度 + 额外间距 + TopPos := TopPos + InfoLabel.Height + 20; + + // 1. LLM API Key + LabelLLMApiKey := TLabel.Create(ConfigPage); + LabelLLMApiKey.Parent := ConfigPage.Surface; + LabelLLMApiKey.Caption := CustomMessage('LLMApiKeyLabel'); + LabelLLMApiKey.Left := 0; + LabelLLMApiKey.Top := TopPos; + TopPos := TopPos + LabelHeight + VerticalSpacing; + + LLMApiKeyEdit := TNewEdit.Create(ConfigPage); + LLMApiKeyEdit.Parent := ConfigPage.Surface; + LLMApiKeyEdit.Left := 0; + LLMApiKeyEdit.Top := TopPos; + LLMApiKeyEdit.Width := ConfigPage.SurfaceWidth; + LLMApiKeyEdit.Height := EditHeight; + LLMApiKeyEdit.Text := ''; + + TopPos := TopPos + EditHeight + 12; // 组间距加大 + + // 2. LLM Base URL + LabelLLMBaseURL := TLabel.Create(ConfigPage); + LabelLLMBaseURL.Parent := ConfigPage.Surface; + LabelLLMBaseURL.Caption := CustomMessage('LLMBaseURLLabel'); + LabelLLMBaseURL.Left := 0; + LabelLLMBaseURL.Top := TopPos; + TopPos := TopPos + LabelHeight + VerticalSpacing; + + LLMBaseURLEdit := TNewEdit.Create(ConfigPage); + LLMBaseURLEdit.Parent := ConfigPage.Surface; + LLMBaseURLEdit.Left := 0; + LLMBaseURLEdit.Top := TopPos; + LLMBaseURLEdit.Width := ConfigPage.SurfaceWidth; + LLMBaseURLEdit.Height := EditHeight; + LLMBaseURLEdit.Text := 'https://dashscope.aliyuncs.com/compatible-mode/v1'; + + TopPos := TopPos + EditHeight + 12; + + // 3. LLM Model Name + LabelLLMModelName := TLabel.Create(ConfigPage); + LabelLLMModelName.Parent := ConfigPage.Surface; + LabelLLMModelName.Caption := CustomMessage('LLMModelNameLabel'); + LabelLLMModelName.Left := 0; + LabelLLMModelName.Top := TopPos; + TopPos := TopPos + LabelHeight + VerticalSpacing; + + LLMModelNameEdit := TNewEdit.Create(ConfigPage); + LLMModelNameEdit.Parent := ConfigPage.Surface; + LLMModelNameEdit.Left := 0; + LLMModelNameEdit.Top := TopPos; + LLMModelNameEdit.Width := ConfigPage.SurfaceWidth; + LLMModelNameEdit.Height := EditHeight; + LLMModelNameEdit.Text := 'qwen-plus'; + + TopPos := TopPos + EditHeight + 12; + + // 4. ZEP API Key + LabelZEPApiKey := TLabel.Create(ConfigPage); + LabelZEPApiKey.Parent := ConfigPage.Surface; + LabelZEPApiKey.Caption := CustomMessage('ZEPApiKeyLabel'); + LabelZEPApiKey.Left := 0; + LabelZEPApiKey.Top := TopPos; + TopPos := TopPos + LabelHeight + VerticalSpacing; + + ZEPApiKeyEdit := TNewEdit.Create(ConfigPage); + ZEPApiKeyEdit.Parent := ConfigPage.Surface; + ZEPApiKeyEdit.Left := 0; + ZEPApiKeyEdit.Top := TopPos; + ZEPApiKeyEdit.Width := ConfigPage.SurfaceWidth; + ZEPApiKeyEdit.Height := EditHeight; + ZEPApiKeyEdit.Text := ''; +end; + +function NextButtonClick(CurPageID: Integer): Boolean; +begin + Result := True; + + // 在配置页面验证必填项 + if CurPageID = ConfigPage.ID then + begin + if Trim(LLMApiKeyEdit.Text) = '' then + begin + MsgBox('请输入 LLM API Key!', mbError, MB_OK); + Result := False; + Exit; + end; + + if Trim(ZEPApiKeyEdit.Text) = '' then + begin + MsgBox('请输入 ZEP API Key!', mbError, MB_OK); + Result := False; + Exit; + end; + end; +end; + +procedure CurStepChanged(CurStep: TSetupStep); +var + EnvContent: String; + EnvFilePath: String; +begin + if CurStep = ssPostInstall then + begin + // 创建 .env 文件 + EnvFilePath := ExpandConstant('{app}\backend\.env'); + + EnvContent := '# MiroFish configuration file' + #13#10; + EnvContent := EnvContent + '# Generated by installer' + #13#10; + EnvContent := EnvContent + #13#10; + EnvContent := EnvContent + '# LLM API configuration (OpenAI-compatible)' + #13#10; + EnvContent := EnvContent + 'LLM_API_KEY=' + LLMApiKeyEdit.Text + #13#10; + EnvContent := EnvContent + 'LLM_BASE_URL=' + LLMBaseURLEdit.Text + #13#10; + EnvContent := EnvContent + 'LLM_MODEL_NAME=' + LLMModelNameEdit.Text + #13#10; + EnvContent := EnvContent + #13#10; + EnvContent := EnvContent + '# ZEP configuration' + #13#10; + EnvContent := EnvContent + 'ZEP_API_KEY=' + ZEPApiKeyEdit.Text + #13#10; + + EnvContent := #239#187#191 + EnvContent; + if not SaveStringToFile(EnvFilePath, EnvContent, False) then + begin + MsgBox('无法写入配置文件:' + #13#10 + EnvFilePath + #13#10 + #13#10 + + '请检查安装目录权限,或以管理员身份重新安装。', mbError, MB_OK); + end; + end; +end; diff --git a/launcher.py b/launcher.py new file mode 100644 index 00000000..307bc305 --- /dev/null +++ b/launcher.py @@ -0,0 +1,284 @@ +""" +MiroFish 启动器 +双击运行后自动启动后端服务和前端服务,并打开浏览器 +""" + +import os +import sys +import time +import subprocess +import webbrowser +import signal +import threading +from pathlib import Path +from typing import Optional + + +def get_base_path(): + """获取程序基础路径""" + if getattr(sys, 'frozen', False): + # PyInstaller 打包后的路径 + return Path(sys.executable).parent + else: + # 开发环境路径 + return Path(__file__).parent + + +def has_usable_stdin() -> bool: + try: + return sys.stdin is not None and sys.stdin.isatty() + except Exception: + return False + + +def show_error(title: str, message: str) -> None: + if sys.platform == "win32": + try: + import ctypes + + ctypes.windll.user32.MessageBoxW(None, message, title, 0x10) + return + except Exception: + pass + print(f"{title}\n{message}", file=sys.stderr) + + +LOG_FILE: Optional[Path] = None + + +def log_line(message: str) -> None: + if not LOG_FILE: + return + try: + with open(LOG_FILE, "a", encoding="utf-8", errors="replace") as f: + f.write(message + "\n") + except Exception: + pass + + +def wait_for_server(url: str, timeout: int = 30) -> bool: + """等待服务器启动""" + import urllib.request + import urllib.error + import socket + + start_time = time.time() + while time.time() - start_time < timeout: + try: + urllib.request.urlopen(url, timeout=1) + return True + except (urllib.error.URLError, ConnectionRefusedError, socket.timeout, TimeoutError): + time.sleep(0.5) + return False + + +def read_env_lines(env_file: Path) -> list[str]: + data = env_file.read_bytes() + for encoding in ("utf-8", "utf-8-sig", "gbk", "gb2312"): + try: + return data.decode(encoding).splitlines() + except UnicodeDecodeError: + continue + return data.decode("utf-8", errors="replace").splitlines() + + +def main(): + """主函数""" + base_path = get_base_path() + + # 配置路径 + backend_path = base_path / "backend" + frontend_path = base_path / "frontend" / "dist" + env_file = backend_path / ".env" + + # 检查 .env 文件是否存在 + if not env_file.exists(): + show_error( + "MiroFish 启动失败", + "未找到配置文件 .env。\n\n" + "请先运行安装程序并在“API 配置”页面填写密钥,或手动创建:\n" + f"{env_file}", + ) + log_line(f".env missing: {env_file}") + if has_usable_stdin(): + input("按回车键退出...") + sys.exit(1) + + # 设置环境变量 + os.environ['FLASK_HOST'] = '127.0.0.1' + os.environ['FLASK_PORT'] = '5001' + if getattr(sys, 'frozen', False): + os.environ['FLASK_DEBUG'] = 'False' + + # 日志目录 + logs_path = base_path / "logs" + try: + logs_path.mkdir(parents=True, exist_ok=True) + except Exception: + temp_dir = Path(os.environ.get("TEMP", str(base_path))) + logs_path = temp_dir / "MiroFishLogs" + logs_path.mkdir(parents=True, exist_ok=True) + backend_log = logs_path / "backend.log" + global LOG_FILE + LOG_FILE = logs_path / "launcher.log" + log_line("=== launcher start ===") + log_line(f"base_path={base_path}") + log_line(f"logs_path={logs_path}") + + # 存储子进程 + processes = [] + + def terminate_processes(): + """清理子进程""" + if has_usable_stdin(): + print("\n正在关闭服务...") + for proc in processes: + try: + proc.terminate() + proc.wait(timeout=5) + except: + proc.kill() + + def handle_signal(signum, frame): + terminate_processes() + raise SystemExit(0) + + # 注册信号处理 + signal.signal(signal.SIGINT, handle_signal) + signal.signal(signal.SIGTERM, handle_signal) + + try: + # 启动后端服务 + print("正在启动后端服务...") + + # 加载 .env 文件到环境变量 + for line in read_env_lines(env_file): + line = line.strip() + if line and not line.startswith('#') and '=' in line: + key, value = line.split('=', 1) + os.environ[key.strip()] = value.strip() + + if getattr(sys, 'frozen', False): + # 打包环境 + # 优先检查嵌入式 Python + python_exe = base_path / "python" / "python.exe" + backend_exe = backend_path / "mirofish_backend.exe" + run_py = backend_path / "run.py" + + if python_exe.exists() and run_py.exists(): + # 嵌入式 Python 模式 + print(" 使用嵌入式 Python 启动后端...") + with open(backend_log, "a", encoding="utf-8", errors="replace") as log_file: + log_file.write("\n=== backend start (embedded python) ===\n") + backend_proc = subprocess.Popen( + [str(python_exe), str(run_py)], + cwd=str(backend_path), + env=os.environ.copy(), + stdout=log_file, + stderr=log_file, + creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0 + ) + elif backend_exe.exists(): + # PyInstaller 打包模式 + print(" 使用打包的后端程序...") + with open(backend_log, "a", encoding="utf-8", errors="replace") as log_file: + log_file.write("\n=== backend start (pyinstaller) ===\n") + backend_proc = subprocess.Popen( + [str(backend_exe)], + cwd=str(backend_path), + env=os.environ.copy(), + stdout=log_file, + stderr=log_file, + creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0 + ) + else: + show_error( + "MiroFish 启动失败", + "未找到后端启动程序。\n\n" + f"请检查以下路径是否存在:\n{python_exe}\n{backend_exe}", + ) + if has_usable_stdin(): + input("按回车键退出...") + sys.exit(1) + else: + # 开发环境:使用 uv + backend_proc = subprocess.Popen( + ["uv", "run", "python", "run.py"], + cwd=str(backend_path) + ) + + processes.append(backend_proc) + + # 启动前端静态文件服务 + print("正在启动前端服务...") + + # 使用内置线程作为简单 HTTP 服务器 + import http.server + import socketserver + + class QuietHandler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=str(frontend_path), **kwargs) + + def log_message(self, format, *args): + pass # 禁用日志输出 + + def run_frontend_server(): + with socketserver.TCPServer(("127.0.0.1", 3000), QuietHandler) as httpd: + httpd.serve_forever() + + frontend_thread = threading.Thread(target=run_frontend_server, daemon=True) + frontend_thread.start() + + # 等待后端服务启动 + print("正在等待服务启动...") + time.sleep(0.5) + if backend_proc.poll() is not None: + show_error( + "MiroFish 启动失败", + "后端服务启动后立刻退出。\n\n" + f"请查看日志:\n{backend_log}" + ) + log_line("backend exited immediately; see backend.log") + raise SystemExit(1) + if wait_for_server("http://127.0.0.1:5001/health", timeout=30): + print("后端服务已启动") + else: + print("警告:后端服务启动超时") + + # 等待前端服务启动 + if wait_for_server("http://127.0.0.1:3000/", timeout=10): + print("前端服务已启动") + else: + print("警告:前端服务启动超时") + + # 打开浏览器 + print("正在打开浏览器...") + webbrowser.open("http://127.0.0.1:3000") + + print("\n" + "=" * 50) + print("MiroFish 已启动!") + print("前端地址: http://127.0.0.1:3000") + print("后端地址: http://127.0.0.1:5001") + print("=" * 50) + print("\n按 Ctrl+C 关闭服务...") + + # 等待进程 + while True: + # 检查后端进程是否仍在运行 + if backend_proc.poll() is not None: + print("后端服务已停止") + break + time.sleep(1) + + except Exception as e: + show_error("MiroFish 启动失败", f"{e}") + log_line(f"launcher exception: {e}") + terminate_processes() + raise SystemExit(1) + finally: + terminate_processes() + + +if __name__ == "__main__": + main()