From 61b07d676091d7810351e9db09251af023aa7498 Mon Sep 17 00:00:00 2001 From: XYAIStudio Date: Mon, 28 Sep 2026 15:56:51 +0800 Subject: [PATCH 1/8] docs: plan local model benchmarking and routing --- ...local-model-auto-benchmark-routing-plan.md | 397 ++++++++++++++++++ 1 file changed, 397 insertions(+) create mode 100644 docs/local-model-auto-benchmark-routing-plan.md diff --git a/docs/local-model-auto-benchmark-routing-plan.md b/docs/local-model-auto-benchmark-routing-plan.md new file mode 100644 index 00000000..706dacef --- /dev/null +++ b/docs/local-model-auto-benchmark-routing-plan.md @@ -0,0 +1,397 @@ +# FreeOS 本地模型自动评测与场景路由规划 + +状态:0.0.7 核心功能候选 + +范围:本地 Ollama 模型;为其他 OpenAI 兼容本地运行时预留扩展点 + +原则:本地优先、可解释、用户可覆盖、低扰动、不上传提示或结果 + +## 1. 要解决的问题 + +普通用户通常只知道已经下载了哪些模型,不知道模型在自己的 CPU、GPU、内存和运行参数下是否真正可用。模型参数量、厂商宣传和社区榜单不能代替本机实测。FreeOS 需要把“选择模型”从一次人工猜测改成可重复、可解释的本机评测。 + +目标不是选出一个全局最快模型,而是为不同场景提供合适候选: + +1. **实时聊天**:首字快、连续输出稳定,适合长时间陪伴和高频问答。 +2. **长时任务**:长上下文稳定,失败率低,持续生成速度不会明显衰减。 +3. **工具智能体**:能正确调用工具并产出合法参数。 +4. **视觉理解**:能接收图像并完成基础识别任务。 +5. **低资源模式**:在内存或显存紧张时仍可稳定工作。 + +明确不做: + +- 不根据模型名称或参数量直接宣称“最好”。 +- 不自动下载大型模型,也不删除用户模型。 +- 不在每次启动时跑完整基准。 +- 不在一轮对话中频繁切换模型,破坏上下文一致性。 +- 不把本机硬件、测试提示、模型输出或评分上传到 FreeOS 服务。 + +## 2. 现有基础与改造边界 + +现有能力可以直接复用: + +- `infra/agents/providers/local_speed.py` 已能测量总延迟、首字时间和近似 tokens/s。 +- `/api/local-models/probe` 已能发现硬件、Ollama 和已安装模型。 +- `/api/local-models/speed-test` 已提供单模型手动测速。 +- `local_default.py`、用户偏好和 provider store 已负责默认模型解析。 +- gateway processor 已支持线程模型覆盖、智能体默认模型和多模态升级。 +- 0.0.6 的 Ollama tool fallback 已能识别“不支持 tools”,但降级结果目前不是持久化能力画像。 + +本功能新增一个领域服务,并保持 API 路由为薄适配层: + +```text +dashboard/模型页 + │ + ▼ +api/routers/local_model_benchmarks.py + │ + ▼ +infra/agents/model_selection/ + benchmark_service.py + capability_probe.py + scoring.py + router.py + fingerprints.py + │ + ├── provider store / Ollama HTTP + └── db repos / settings +``` + +模型选择和评分属于 `infra/` 领域逻辑;HTTP 状态码、请求校验和 SSE 进度属于 `api/`;结果展示和人工覆盖属于 `dashboard/`。 + +## 3. 用户流程 + +### 3.1 首次发现 + +1. FreeOS 扫描已安装并已注册的本地模型。 +2. 如果存在两个或更多未评测模型,显示“为本机推荐模型”卡片。 +3. 用户点击开始后先展示预计耗时、资源占用和将要测试的模型。 +4. 默认执行 2–5 分钟轻量评测;用户可以取消。 +5. 完成后分别给出“实时聊天”“长时任务”“工具智能体”“视觉理解”推荐。 +6. 用户可以接受某一推荐、维持当前模型或锁定指定模型。 + +首次版本不在无明确提示时自动触发高负载测试。后续可增加“设备空闲时自动复测”,默认关闭。 + +### 3.2 日常使用 + +- 对话创建时根据任务要求选择模型,并在该线程内保持稳定。 +- 若用户手动选定模型或智能体设置了 `default_model`,始终优先使用用户设置。 +- 当请求含图片、工具或长上下文时,只在满足能力硬约束的候选中评分。 +- 当前模型连续失败时可以切换至同场景备选,但必须在界面显示切换原因。 +- 已开始生成有效内容后不自动更换模型;失败重试发生在新一次模型调用上。 + +### 3.3 复测条件 + +以下指纹变化时将结果标记为“需要复测”,而不是立即启动测试: + +- 模型 digest、量化版本或模型参数变化。 +- CPU、GPU、显存、内存或 Ollama 版本变化。 +- FreeOS 基准套件版本变化。 +- 用户改变上下文长度、GPU 层数或运行参数。 +- 结果超过 30 天,或近期真实调用稳定性明显恶化。 + +## 4. 基准套件 + +### 4.1 两级评测 + +**快速评测**用于首次推荐:每个模型约 20–45 秒。 + +- 冷启动一次:记录模型加载时间。 +- 热启动两次:记录首字时间和 tokens/s,取中位数。 +- 256–512 token 上下文测试一次。 +- 能力声明与最小能力探测。 +- 进程峰值内存、显存信息在运行时可获得时记录;不可获得时明确标为未知。 + +**完整评测**由用户主动选择:每个模型约 2–5 分钟。 + +- 冷、热启动分开统计。 +- 2K、8K 上下文档位;更大档位仅在模型声明和设备资源允许时执行。 +- 固定长度持续生成,观察 tokens/s 衰减和超时。 +- 工具调用、结构化输出和视觉探测。 +- 重复三次,保存中位数、P90 和成功率。 + +### 4.2 指标定义 + +| 指标 | 定义 | 用途 | +|---|---|---| +| `load_ms` | 冷启动到请求可处理的时间 | 首次体验、切换成本 | +| `ttft_ms` | 请求发送到第一个非空输出片段 | 实时聊天核心指标 | +| `decode_tokens_per_sec` | 模型报告的 eval_count / eval_duration,缺失时才使用近似值 | 持续生成速度 | +| `total_latency_ms` | 完整请求总耗时 | 综合延迟 | +| `success_rate` | 有效完成次数 / 总次数 | 稳定性硬指标 | +| `timeout_rate` | 超时次数 / 总次数 | 排除不可用候选 | +| `long_context_pass` | 指定上下文档位是否成功并回答校验点 | 长时任务约束 | +| `tool_call_pass` | 是否返回合法工具名及符合 schema 的参数 | 工具智能体约束 | +| `vision_pass` | 是否接受图片并返回可验证内容 | 视觉任务约束 | +| `json_schema_pass` | 是否能稳定生成符合 schema 的输出 | 工作流约束 | +| `peak_ram_mb` / `peak_vram_mb` | 测试期间可观测峰值 | 资源安全与低资源推荐 | +| `sustained_speed_ratio` | 长生成后半段速度 / 前半段速度 | 热降频和长时稳定性 | + +tokens/s 必须优先使用运行时给出的真实 token 计数。只有片段数量时,字段应标为 `estimated_tokens_per_sec`,不能与真实 tokens/s 混用。 + +### 4.3 能力探测 + +- **工具调用**:提供一个无副作用的本地 `echo` schema,要求返回固定参数;禁止执行文件、网络或系统工具。 +- **视觉**:使用随安装包提供的小型测试图,不读取用户照片。 +- **长上下文**:在合成文本中放置随机校验码,要求模型准确找回;不测试主观回答质量。 +- **结构化输出**:校验 JSON schema,不用模糊文本匹配代替。 +- **中文基础能力**:只作为最低可用门槛,首版不把小型题库分数包装成通用智力评分。 + +## 5. 评分与推荐算法 + +### 5.1 先约束,再评分 + +路由先应用硬约束: + +```text +视觉请求 → vision_pass = true +工具请求 → tool_call_pass = true +目标上下文 N → verified_context_tokens >= N +内存安全 → 未触发 OOM,且资源余量高于安全阈值 +用户锁定 → 直接使用锁定模型;能力不满足时明确提示 +``` + +不满足硬约束的模型不会因为速度快而进入候选。 + +### 5.2 归一化 + +评分只在本机、同一基准版本和同一场景的候选模型之间归一化。延迟类指标使用对数缩放,避免极端慢模型把其他模型挤在很小区间;缺失指标不按零分处理,而是降低置信度。 + +```text +higher_is_better(x) = percentile_rank(log1p(x)) +lower_is_better(x) = 1 - percentile_rank(log1p(x)) +confidence = 已完成必测项权重 / 场景必测项总权重 +``` + +### 5.3 首版权重 + +| 场景 | TTFT | tokens/s | 稳定性 | 长上下文 | 资源余量 | 能力校验 | +|---|---:|---:|---:|---:|---:|---:| +| 实时聊天 | 35% | 25% | 25% | 5% | 10% | 硬约束 | +| 长时任务 | 10% | 20% | 30% | 25% | 15% | 硬约束 | +| 工具智能体 | 15% | 15% | 30% | 15% | 10% | 15% + 硬约束 | +| 视觉理解 | 15% | 15% | 25% | 10% | 10% | 25% + 硬约束 | +| 低资源 | 20% | 15% | 25% | 5% | 35% | 硬约束 | + +总分用于排序,推荐理由必须引用原始事实,例如:“热启动首字中位数 620ms;3/3 成功;工具探测通过;峰值显存 4.1GB”。不展示无法从测试推导的笼统结论。 + +### 5.4 抖动控制 + +- 新模型需比当前模型高至少 8 分,或当前模型不满足硬约束,才建议切换。 +- 同一线程不因微小分数变化切换。 +- 自动故障转移后设置冷却时间,避免两个模型来回切换。 +- 连续 3 次同类运行时故障才降低健康评分;用户取消、网络断开和应用退出不计入模型失败。 + +## 6. 数据模型 + +新增迁移需要同时提供 SQLite 和 PostgreSQL 版本,并遵循当前未发布 schema 的折叠规则。 + +### `local_model_profiles` + +每个模型指纹一行,保存最近聚合结果: + +- `model_profile_id`:公开 ULID。 +- `provider_name`、`model_id`、`model_digest`。 +- `runtime_kind`、`runtime_version`。 +- `hardware_fingerprint`:仅保存本机不可逆摘要。 +- `benchmark_suite_version`、`status`、`started_at`、`completed_at`。 +- `metrics_json`、`capabilities_json`、`scenario_scores_json`。 +- `confidence`、`stale_reason`、`last_error_code`。 + +唯一键:`provider_name + model_id + model_digest + hardware_fingerprint + benchmark_suite_version`。 + +### `local_model_benchmark_runs` + +保存有限期运行记录,用于审计和重新聚合: + +- `benchmark_run_id`、`model_profile_id`、`mode`、`status`。 +- 每个测试项的耗时、指标、错误分类和开始结束时间。 +- 不保存自由文本模型输出,只保存校验是否通过和必要摘要。 +- 默认保留最近 10 次或 30 天,由用户清除。 + +### `local_model_routing_prefs` + +建议优先放入现有用户偏好 JSON,避免过早增加表: + +```json +{ + "mode": "recommend", + "locked_model": null, + "scenario_overrides": { + "realtime_chat": null, + "long_task": null, + "tool_agent": null, + "vision": null + }, + "allow_background_benchmark": false, + "allow_runtime_failover": true +} +``` + +`mode` 首版提供 `manual` 和 `recommend`。等推荐逻辑经过真实用户验证后,再增加默认自动路由模式。 + +## 7. API 设计 + +建议新增: + +| 方法 | 路径 | 作用 | +|---|---|---| +| `GET` | `/api/local-models/benchmarks` | 列出模型画像、评分、陈旧原因和当前任务 | +| `POST` | `/api/local-models/benchmarks` | 创建批量评测任务 | +| `GET` | `/api/local-models/benchmarks/{job_id}` | 查询任务与逐模型进度 | +| `DELETE` | `/api/local-models/benchmarks/{job_id}` | 协作取消,保留已完成结果 | +| `POST` | `/api/local-models/benchmarks/{profile_id}/retry` | 重测单一模型 | +| `GET` | `/api/local-models/recommendations` | 返回各场景推荐、备选、理由与置信度 | +| `PUT` | `/api/local-models/routing-preferences` | 保存人工锁定和自动策略 | + +批量任务使用轮询或 SSE 返回阶段:`queued → loading → warmup → latency → sustained → capability → scoring → completed`。任务必须限制为单机一次只运行一个重型模型,避免多个模型同时占满显存。 + +请求示例: + +```json +{ + "model_refs": ["Ollama (Local)/qwen2.5:7b", "Ollama (Local)/llama3.2:3b"], + "mode": "quick", + "scenarios": ["realtime_chat", "long_task", "tool_agent"] +} +``` + +推荐响应必须同时返回 `eligible`、`disqualifiers`、`score`、`confidence` 和原始关键指标,供 UI 解释。 + +## 8. 运行时路由 + +在 gateway processor 现有 `_resolve_harness_model()` 前增加一个领域选择步骤,保持优先级明确: + +```text +线程手动覆盖 + > 智能体显式 default_model + > 用户场景锁定 + > 满足硬约束且置信度足够的本机推荐 + > 现有全局 active model / fallback +``` + +场景推断首版只使用确定性信号: + +- 附件含图片 → `vision`。 +- 当前智能体拥有工具且本轮允许工具 → `tool_agent`。 +- 估算上下文超过已验证阈值的 60% → `long_task`。 +- 其他 → `realtime_chat`。 + +不使用另一个 LLM 来判断路由,以免增加延迟、成本和循环依赖。 + +### 故障转移 + +允许故障转移的错误:模型明确不支持所需能力、OOM、服务端 5xx、模型进程退出、在限定时间内无首字。认证失败、用户取消、输入不合法和工具自身错误不触发换模型。 + +故障转移顺序:同场景第二名 → 当前全局默认 → 返回明确错误。每次切换写入结构化审计事件,并在聊天界面显示“已从 A 切换到 B:A 工具调用不受支持”。 + +## 9. 前端设计 + +模型设置页新增“本机模型推荐”区域: + +- 顶部显示硬件摘要、上次评测时间、基准版本和“开始快速评测”。 +- 模型表显示状态、TTFT、tokens/s、稳定性、上下文、视觉、工具、资源占用。 +- 推荐卡按场景排列,展示首选、备选、分数、置信度和三条主要理由。 +- 支持“设为此场景默认”“锁定当前模型”“取消评测”“完整评测”。 +- 结果陈旧时显示具体原因,不能继续以绿色“推荐”展示。 + +聊天消息的模型徽标应保留实际执行模型;发生自动切换时记录两个模型和原因,避免用户误以为一直由原模型回答。 + +## 10. 调度、资源与安全 + +- 评测默认串行;GPU/统一内存低于安全余量时暂停并提示。 +- 笔记本使用电池、系统高负载、正在语音通话或有活跃生成时不启动后台评测。 +- 每项测试设独立超时,总任务有预算;取消必须能终止后续请求并释放模型。 +- 所有提示均来自版本化内置基准,不读取用户聊天、知识库或组织数据。 +- 日志不得记录模型完整输出、API Key、用户目录或硬件序列号。 +- 远程 URL 即使配置为“本地 provider”,也不能被当成本机基准;首版仅允许 loopback Ollama。 +- 基准接口沿用 `ollama_models` 权限;普通用户只能查看和修改自己的推荐偏好。 + +## 11. 可观测性 + +新增结构化指标: + +- `local_model_benchmark_started/completed/cancelled/failed` +- `local_model_benchmark_duration_seconds` +- `local_model_route_selected{scenario,reason}` +- `local_model_failover{error_class}` +- `local_model_recommendation_accepted/rejected` + +指标只记录类别和计数,不包含模型输出和硬件原始标识。诊断页提供本机可导出的 JSON 报告,默认脱敏。 + +## 12. 分阶段交付 + +### M1:可信批量评测 + +- 后端 profile/run 存储、模型指纹、任务取消与串行执行。 +- 快速评测:冷/热 TTFT、真实 tokens/s、成功率、资源可用性。 +- 模型页批量进度和结果表。 +- 不改变聊天实际选模。 + +验收:两种以上 Ollama 模型可在 Windows/macOS/Linux 完成、取消并复用结果;重启后结果仍在;模型 digest 变化后结果变陈旧。 + +### M2:能力探测与场景推荐 + +- 工具、视觉、长上下文、JSON schema 探测。 +- 场景硬约束、评分、置信度和解释。 +- 用户接受推荐、场景锁定和手动覆盖。 + +验收:不支持 tools 的 qwen2.5vl:3b 不进入工具智能体候选,但可进入通过验证的视觉候选;缺失数据不会被伪装成零分或已通过。 + +### M3:受控运行时路由 + +- 接入 `_resolve_harness_model()` 优先级链。 +- 线程粘性、明确切换提示、同场景故障转移和冷却。 +- 实际调用健康反馈只影响稳定性,不覆盖离线基准原始数据。 + +验收:手动锁定始终优先;视觉/工具/长上下文硬约束生效;失败不会产生模型切换循环;历史消息显示实际执行模型。 + +### M4:空闲复测与长期优化 + +- 可选的空闲时复测、速度衰减检测和推荐变化提示。 +- 根据匿名关闭的本机接受/拒绝行为调整用户自己的权重,不进行云端训练。 +- 扩展 LM Studio、vLLM 等 loopback OpenAI 兼容运行时。 + +## 13. 测试与发布门禁 + +单元测试: + +- 指纹稳定性、指标聚合、缺失值、硬约束、归一化和抖动控制。 +- 工具/视觉/上下文探测的成功、能力不支持、超时、OOM 和取消。 +- 路由优先级、线程粘性、故障分类与冷却。 +- SQLite/PostgreSQL repo 和迁移等价性。 + +集成测试: + +- 伪 Ollama SSE 流覆盖冷/热指标及错误分类。 +- API 权限、任务并发限制、取消和重启恢复。 +- 用户接受推荐后默认模型更新;手动锁定不被覆盖。 + +真实验收矩阵: + +- Windows NVIDIA、Windows 纯 CPU、Apple Silicon、Linux NVIDIA。 +- 至少一个文本模型、一个视觉但不支持 tools 的模型、一个工具模型。 +- 快速评测、完整评测、应用重启、模型更新、低内存、Ollama 中途退出。 +- 连续长对话至少 60 分钟,确认无频繁切换、无显存持续增长、失败可恢复。 + +发布门禁仍为 `make all`,并增加 dashboard 类型检查、真实 Ollama 可选测试和桌面安装包隔离验收。模拟测试通过不等于真实模型和安装包验收。 + +## 14. 成功指标 + +- 首次有两个以上本地模型的用户,80% 能在 5 分钟内得到至少一个高置信度推荐。 +- 接受推荐后的实时聊天 TTFT 中位数相对原默认模型改善至少 20%,或稳定性显著提高。 +- 因模型能力不匹配导致的 `stream_error` 降低 80%。 +- 自动故障转移循环为零;错误切换率低于 1%。 +- 用户手动锁定被覆盖的事件为零。 +- 基准期间用户数据外发事件为零。 + +## 15. 开发顺序与估算 + +建议四个迭代完成: + +1. **1 周**:数据结构、指纹、批量任务、现有测速重构和 API。 +2. **1 周**:能力探测、评分解释、模型页 UI。 +3. **1 周**:受控路由、线程粘性、故障转移和审计事件。 +4. **1 周**:四平台真实模型验收、性能调优、文档与灰度开关。 + +M1 和 M2 可以在 0.0.7 默认提供;M3 建议先以“推荐模式”灰度,收集本机验收证据后再把自动路由设为可选。M4 不应阻塞 0.0.7。 From eb07d5a0beee28dd5fe781797e65d264d2d2cbe8 Mon Sep 17 00:00:00 2001 From: XYAIStudio Date: Mon, 28 Sep 2026 16:11:25 +0800 Subject: [PATCH 2/8] docs: add local runtime setup flow --- ...local-model-auto-benchmark-routing-plan.md | 123 ++++++++++++++++-- 1 file changed, 111 insertions(+), 12 deletions(-) diff --git a/docs/local-model-auto-benchmark-routing-plan.md b/docs/local-model-auto-benchmark-routing-plan.md index 706dacef..f981b95a 100644 --- a/docs/local-model-auto-benchmark-routing-plan.md +++ b/docs/local-model-auto-benchmark-routing-plan.md @@ -2,7 +2,7 @@ 状态:0.0.7 核心功能候选 -范围:本地 Ollama 模型;为其他 OpenAI 兼容本地运行时预留扩展点 +范围:本地模型运行环境安装、模型下载、Ollama 模型评测与路由;兼容用户已有 Ollama 和其他 OpenAI 兼容本地运行时 原则:本地优先、可解释、用户可覆盖、低扰动、不上传提示或结果 @@ -21,7 +21,7 @@ 明确不做: - 不根据模型名称或参数量直接宣称“最好”。 -- 不自动下载大型模型,也不删除用户模型。 +- 不在用户未确认模型大小、磁盘位置和预计下载量时下载模型,也不删除用户模型。 - 不在每次启动时跑完整基准。 - 不在一轮对话中频繁切换模型,破坏上下文一致性。 - 不把本机硬件、测试提示、模型输出或评分上传到 FreeOS 服务。 @@ -43,6 +43,17 @@ dashboard/模型页 │ ▼ +api/routers/local_runtime.py + │ + ▼ +infra/agents/local_runtime/ + detector.py + installer.py + service.py + model_catalog.py + downloads.py + │ + ▼ api/routers/local_model_benchmarks.py │ ▼ @@ -57,11 +68,55 @@ infra/agents/model_selection/ └── db repos / settings ``` -模型选择和评分属于 `infra/` 领域逻辑;HTTP 状态码、请求校验和 SSE 进度属于 `api/`;结果展示和人工覆盖属于 `dashboard/`。 +运行环境检测、安装状态机、下载任务、模型选择和评分属于 `infra/` 领域逻辑;HTTP 状态码、请求校验和 SSE 进度属于 `api/`;结果展示和人工覆盖属于 `dashboard/`。桌面壳只负责调用经过校验的系统安装能力和启动本机服务,不在 dashboard 中直接执行命令。 ## 3. 用户流程 -### 3.1 首次发现 +### 3.1 本地运行环境引导 + +FreeOS 首次进入模型设置时先检测本地模型运行环境,并区分以下状态: + +| 状态 | 判断依据 | 用户操作 | +|---|---|---| +| `not_installed` | 找不到 Ollama 可执行文件,loopback API 也不可达 | 显示“一键安装本地模型环境” | +| `installed_stopped` | 找到可信可执行文件,但 `/api/tags` 不可达 | 显示“启动服务”并给出诊断 | +| `running_empty` | `/api/tags` 返回合法空模型列表 | 显示推荐模型和下载按钮 | +| `running_ready` | `/api/tags` 返回至少一个有效模型 | 进入模型发现和评测 | +| `external_runtime` | 用户配置了可达的 OpenAI 兼容本地地址 | 验证连接后注册,不要求安装 Ollama | +| `broken` | 安装残缺、版本不兼容或服务启动失败 | 显示具体原因、日志位置和修复入口 | + +“一键安装”采用用户可见的任务状态机: + +```text +检测系统与架构 + → 展示来源、版本、许可、下载体积和安装位置 + → 用户确认 + → 下载到临时目录 + → 校验 HTTPS 来源、文件摘要和数字签名(平台支持时) + → 调用官方静默安装器或受控包管理命令 + → 启动本机服务 + → 轮询 loopback 健康状态 + → 显示安装成功或可操作的失败原因 +``` + +首版支持 Windows;macOS 和 Linux 只有在安装方式、权限提示、卸载路径及 CI/实机验收完整后才开放按钮。安装需要管理员权限时,由操作系统权限窗口向用户确认,FreeOS 不保存管理员密码。安装包不内嵌未知版本的 Ollama,也不从第三方镜像执行脚本;版本清单由 FreeOS 发布时固定,并允许后续受签名清单更新。 + +若检测到用户已有 Ollama,FreeOS 只连接并验证,不覆盖安装、不修改服务启动方式。若端口被其他程序占用,则停止流程并显示占用信息,不能通过杀死未知进程来“修复”。 + +### 3.2 模型选择与下载 + +运行环境就绪但没有模型时,FreeOS 根据硬件和剩余磁盘空间显示一个小型推荐目录。目录元数据包括模型标识、量化、下载体积、预计内存/显存需求、上下文和已知能力;这些是筛选信息,不等同于本机实测成绩。 + +默认只推荐少量经过 FreeOS 验证的模型档位,例如低资源聊天、通用聊天、工具模型和视觉模型。用户确认后通过 Ollama API 拉取模型,并获得可取消、可重试的进度。下载使用 Ollama 自己的模型存储和断点机制;FreeOS 不复制模型文件,也不自行解析 Ollama blob。下载前必须检查磁盘余量并保留安全空间,完成后重新读取 `/api/tags`,只有返回对应模型 digest 才记为成功。 + +用户还可以: + +- 跳过推荐,在完整目录中输入合法模型标识并确认下载风险。 +- 使用已经安装的模型,不重复下载。 +- 注册 `127.0.0.1` / `localhost` 上的 OpenAI 兼容服务并测试连接。 +- 暂时跳过本地模型,继续使用已配置的云模型。 + +### 3.3 首次发现与评测 1. FreeOS 扫描已安装并已注册的本地模型。 2. 如果存在两个或更多未评测模型,显示“为本机推荐模型”卡片。 @@ -72,7 +127,7 @@ infra/agents/model_selection/ 首次版本不在无明确提示时自动触发高负载测试。后续可增加“设备空闲时自动复测”,默认关闭。 -### 3.2 日常使用 +### 3.4 日常使用 - 对话创建时根据任务要求选择模型,并在该线程内保持稳定。 - 若用户手动选定模型或智能体设置了 `default_model`,始终优先使用用户设置。 @@ -80,7 +135,7 @@ infra/agents/model_selection/ - 当前模型连续失败时可以切换至同场景备选,但必须在界面显示切换原因。 - 已开始生成有效内容后不自动更换模型;失败重试发生在新一次模型调用上。 -### 3.3 复测条件 +### 3.5 复测条件 以下指纹变化时将结果标记为“需要复测”,而不是立即启动测试: @@ -186,6 +241,16 @@ confidence = 已完成必测项权重 / 场景必测项总权重 新增迁移需要同时提供 SQLite 和 PostgreSQL 版本,并遵循当前未发布 schema 的折叠规则。 +### `local_runtime_state` + +运行环境本身只需要一份当前状态,可优先放入现有 settings;若安装任务需要跨重启恢复,再建立任务表: + +- `runtime_kind`、`detected_version`、`executable_path_hash`、`api_base_url`。 +- `status`、`last_health_check_at`、`last_error_code`。 +- `managed_by_freeos`:标记本次安装是否由 FreeOS 发起,不能据此接管用户原有安装。 +- `install_manifest_version`、`installer_sha256`;不保存提权凭据。 +- 模型下载任务保存 `model_id`、阶段、已下载/总字节、错误分类和时间;不保存 Ollama blob 路径。 + ### `local_model_profiles` 每个模型指纹一行,保存最近聚合结果: @@ -236,6 +301,15 @@ confidence = 已完成必测项权重 / 场景必测项总权重 | 方法 | 路径 | 作用 | |---|---|---| +| `GET` | `/api/local-runtime/status` | 返回运行环境检测、服务健康和可用安装动作 | +| `POST` | `/api/local-runtime/install` | 用户确认后创建受控安装任务 | +| `GET` | `/api/local-runtime/install/{job_id}` | 查询下载、校验、安装、启动和健康检查进度 | +| `DELETE` | `/api/local-runtime/install/{job_id}` | 在可取消阶段停止任务并清理临时文件 | +| `POST` | `/api/local-runtime/start` | 启动已安装但停止的本地运行时 | +| `GET` | `/api/local-models/catalog` | 返回按硬件过滤的推荐模型及资源要求 | +| `POST` | `/api/local-models/pulls` | 用户确认后创建 Ollama 模型下载任务 | +| `GET` | `/api/local-models/pulls/{job_id}` | 查询模型下载和校验进度 | +| `DELETE` | `/api/local-models/pulls/{job_id}` | 取消尚未完成的模型下载 | | `GET` | `/api/local-models/benchmarks` | 列出模型画像、评分、陈旧原因和当前任务 | | `POST` | `/api/local-models/benchmarks` | 创建批量评测任务 | | `GET` | `/api/local-models/benchmarks/{job_id}` | 查询任务与逐模型进度 | @@ -289,6 +363,10 @@ confidence = 已完成必测项权重 / 场景必测项总权重 模型设置页新增“本机模型推荐”区域: +- 未安装运行环境时先显示三步引导:“安装运行环境 → 下载模型 → 本机评测”,当前步骤以外的按钮禁用并说明原因。 +- 安装确认页显示官方来源、固定版本、下载体积、安装位置、管理员权限需求和隐私说明。 +- 模型目录根据硬件分档,显示下载大小、预计内存/显存、能力与磁盘余量;下载进度可取消、失败可重试。 +- 对已有 Ollama、外部 loopback 服务和云模型提供等权入口,不强迫用户重复安装或下载。 - 顶部显示硬件摘要、上次评测时间、基准版本和“开始快速评测”。 - 模型表显示状态、TTFT、tokens/s、稳定性、上下文、视觉、工具、资源占用。 - 推荐卡按场景排列,展示首选、备选、分数、置信度和三条主要理由。 @@ -304,6 +382,10 @@ confidence = 已完成必测项权重 / 场景必测项总权重 - 每项测试设独立超时,总任务有预算;取消必须能终止后续请求并释放模型。 - 所有提示均来自版本化内置基准,不读取用户聊天、知识库或组织数据。 - 日志不得记录模型完整输出、API Key、用户目录或硬件序列号。 +- 安装器只能来自 HTTPS 官方发布地址或操作系统可信包管理器;下载后必须核对发布清单中的 SHA-256,并在平台支持时验证发布者签名。 +- 安装、启动、下载模型分别使用允许列表参数调用,禁止将模型名、路径或服务输出拼接成 shell 命令。 +- 安装前检查系统架构、磁盘余量和当前任务;失败时清理 FreeOS 临时下载,但不删除用户现有 Ollama、模型或配置。 +- FreeOS 不静默卸载运行时;卸载属于独立的用户确认操作,首版可只提供官方卸载指南。 - 远程 URL 即使配置为“本地 provider”,也不能被当成本机基准;首版仅允许 loopback Ollama。 - 基准接口沿用 `ollama_models` 权限;普通用户只能查看和修改自己的推荐偏好。 @@ -311,6 +393,8 @@ confidence = 已完成必测项权重 / 场景必测项总权重 新增结构化指标: +- `local_runtime_install_started/completed/cancelled/failed` +- `local_model_pull_started/completed/cancelled/failed` - `local_model_benchmark_started/completed/cancelled/failed` - `local_model_benchmark_duration_seconds` - `local_model_route_selected{scenario,reason}` @@ -321,6 +405,15 @@ confidence = 已完成必测项权重 / 场景必测项总权重 ## 12. 分阶段交付 +### M0:运行环境与模型就绪 + +- Windows Ollama 检测、一键安装、启动和 loopback 健康检查。 +- 固定版本与摘要清单、官方来源校验、管理员权限提示和失败诊断。 +- 硬件分档模型目录、磁盘预检、下载/取消/重试及完成后 digest 验证。 +- 已有 Ollama、OpenAI 兼容 loopback 服务和云模型的跳过路径。 + +验收:在一台未安装 Ollama 的干净 Windows 虚拟机中,普通用户可从 FreeOS 完成确认、安装、启动、小模型下载并进行一次真实对话;取消安装和取消模型下载不会留下 FreeOS 临时文件;已有 Ollama 和模型不会被覆盖。 + ### M1:可信批量评测 - 后端 profile/run 存储、模型指纹、任务取消与串行执行。 @@ -356,6 +449,8 @@ confidence = 已完成必测项权重 / 场景必测项总权重 单元测试: +- 系统/架构检测、安装状态机、版本清单和摘要校验、允许列表参数及磁盘安全阈值。 +- 模型目录硬件过滤、下载进度解析、取消、重试和 digest 完成校验。 - 指纹稳定性、指标聚合、缺失值、硬约束、归一化和抖动控制。 - 工具/视觉/上下文探测的成功、能力不支持、超时、OOM 和取消。 - 路由优先级、线程粘性、故障分类与冷却。 @@ -363,12 +458,15 @@ confidence = 已完成必测项权重 / 场景必测项总权重 集成测试: +- 伪安装器与伪 Ollama 服务覆盖未安装、已停止、空模型、已有模型、版本不兼容和端口占用。 +- 安装或下载中断后重启 FreeOS,状态可恢复或明确失败,不会误报成功。 - 伪 Ollama SSE 流覆盖冷/热指标及错误分类。 - API 权限、任务并发限制、取消和重启恢复。 - 用户接受推荐后默认模型更新;手动锁定不被覆盖。 真实验收矩阵: +- 干净 Windows 虚拟机一键安装;已有 Ollama;已有模型;无管理员权限;磁盘不足;下载中断;端口占用。 - Windows NVIDIA、Windows 纯 CPU、Apple Silicon、Linux NVIDIA。 - 至少一个文本模型、一个视觉但不支持 tools 的模型、一个工具模型。 - 快速评测、完整评测、应用重启、模型更新、低内存、Ollama 中途退出。 @@ -387,11 +485,12 @@ confidence = 已完成必测项权重 / 场景必测项总权重 ## 15. 开发顺序与估算 -建议四个迭代完成: +建议五个迭代完成: -1. **1 周**:数据结构、指纹、批量任务、现有测速重构和 API。 -2. **1 周**:能力探测、评分解释、模型页 UI。 -3. **1 周**:受控路由、线程粘性、故障转移和审计事件。 -4. **1 周**:四平台真实模型验收、性能调优、文档与灰度开关。 +1. **1 周**:Windows 运行环境检测、一键安装、健康检查、模型目录与下载状态机。 +2. **1 周**:数据结构、指纹、批量任务、现有测速重构和 API。 +3. **1 周**:能力探测、评分解释、模型页 UI。 +4. **1 周**:受控路由、线程粘性、故障转移和审计事件。 +5. **1 周**:干净机器及多硬件真实验收、性能调优、文档与灰度开关。 -M1 和 M2 可以在 0.0.7 默认提供;M3 建议先以“推荐模式”灰度,收集本机验收证据后再把自动路由设为可选。M4 不应阻塞 0.0.7。 +M0、M1 和 M2 可以在 0.0.7 默认提供;M3 建议先以“推荐模式”灰度,收集本机验收证据后再把自动路由设为可选。macOS/Linux 一键安装和 M4 不应阻塞 0.0.7,但必须保留手动连接现有运行时的路径。 From 2dd4686c974fcfdcb8cd8526cee96d759108694f Mon Sep 17 00:00:00 2001 From: XYAIStudio Date: Tue, 29 Sep 2026 00:17:45 +0800 Subject: [PATCH 3/8] feat(local-models): bundle llama.cpp runtime --- dashboard/src/api/modules/localModels.ts | 33 ++- dashboard/src/locales/en.json | 8 +- dashboard/src/locales/zh.json | 8 +- .../components/LocalHardwarePanel.test.tsx | 35 ++- .../Models/components/LocalHardwarePanel.tsx | 90 +++++- desktop/portable/LLAMA_CPP_LICENSE | 21 ++ desktop/portable/llamacpp-runtime.json | 12 + desktop/portable/package.sh | 10 + desktop/portable/stage-llamacpp-runtime.py | 87 ++++++ src/octop/api/routers/local_models.py | 122 +++++++- .../agents/providers/llamacpp_runtime.py | 278 ++++++++++++++++++ .../infra/agents/providers/local_default.py | 113 +++++-- .../infra/agents/providers/local_probe.py | 32 +- tests/unit/agents/test_llamacpp_runtime.py | 92 ++++++ tests/unit/agents/test_local_default.py | 16 + .../desktop/test_stage_llamacpp_runtime.py | 77 +++++ 16 files changed, 981 insertions(+), 53 deletions(-) create mode 100644 desktop/portable/LLAMA_CPP_LICENSE create mode 100644 desktop/portable/llamacpp-runtime.json create mode 100644 desktop/portable/stage-llamacpp-runtime.py create mode 100644 src/octop/infra/agents/providers/llamacpp_runtime.py create mode 100644 tests/unit/agents/test_llamacpp_runtime.py create mode 100644 tests/unit/desktop/test_stage_llamacpp_runtime.py diff --git a/dashboard/src/api/modules/localModels.ts b/dashboard/src/api/modules/localModels.ts index e23db5f5..9b1951bd 100644 --- a/dashboard/src/api/modules/localModels.ts +++ b/dashboard/src/api/modules/localModels.ts @@ -10,6 +10,9 @@ export interface LocalHardware { ollama_installed?: boolean; ollama_reachable: boolean; ollama_path?: string; + llamacpp_binary?: boolean; + llamacpp_reachable?: boolean; + llamacpp_path?: string; } export interface LocalDep { @@ -28,6 +31,11 @@ export interface LocalInstalledModel { source: string; registerable?: boolean; registered?: boolean; + runtime?: string; + managed_by_freeos?: boolean; + base_url?: string; + model_path?: string; + alias?: string; provider_name?: string; is_default?: boolean; } @@ -114,6 +122,23 @@ export const localModelsApi = { request("/local-models/start-ollama", { method: "POST", }), + llamaCppStatus: () => + request("/local-models/llamacpp/status"), + startLlamaCpp: (body: { + model_path: string; + alias?: string; + context_size?: number; + gpu_layers?: number; + }) => + request("/local-models/llamacpp/start", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + stopLlamaCpp: () => + request("/local-models/llamacpp", { + method: "DELETE", + }), ensureDeps: (install: boolean) => request("/local-models/ensure-deps", { method: "POST", @@ -144,18 +169,18 @@ export const localModelsApi = { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }), - speedTest: (name: string, init?: RequestInit) => + speedTest: (name: string, providerName?: string, init?: RequestInit) => request("/local-models/speed-test", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name }), + body: JSON.stringify({ name, provider_name: providerName }), ...init, }), - setDefault: (name: string) => + setDefault: (name: string, providerName?: string) => request("/local-models/default", { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name }), + body: JSON.stringify({ name, provider_name: providerName }), }), clearDefault: (name?: string) => request( diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index 17a0907d..4faec882 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -3483,7 +3483,13 @@ "onnxQuickDownload": "Quick download from catalog…", "useInChat": "Switch in chat", "localInstallRegistered": "Installed {{name}} and registered it. Open Conversations to switch.", - "localInstallNeedProvider": "Downloaded. Enable the Ollama provider on this page, then switch in chat." + "localInstallNeedProvider": "Downloaded. Enable the Ollama provider on this page, then switch in chat.", + "localBuiltinRuntime": "FreeOS built-in runtime", + "localBuiltinMissing": "The built-in local-model runtime is missing", + "localBuiltinMissingHelp": "Repair or update FreeOS to restore the bundled llama.cpp runtime.", + "localRunBuiltin": "Run with FreeOS", + "localBuiltinStarted": "Started {{name}} with the FreeOS local runtime", + "localBuiltinStartFailed": "Failed to start the FreeOS local runtime" }, "advancedSettings": { "description": "Manage runtime configuration and environment variables.", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 55b824b1..c6df9678 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -3480,7 +3480,13 @@ "onnxQuickDownload": "从目录快速下载…", "useInChat": "去对话切换", "localInstallRegistered": "已安装 {{name}} 并登记到模型列表。打开「对话」即可切换。", - "localInstallNeedProvider": "模型已下载。请先在本页启用 Ollama 提供商,再到对话里切换。" + "localInstallNeedProvider": "模型已下载。请先在本页启用 Ollama 提供商,再到对话里切换。", + "localBuiltinRuntime": "FreeOS 内置运行时", + "localBuiltinMissing": "缺少内置本地模型运行时", + "localBuiltinMissingHelp": "请修复或更新 FreeOS,以恢复随软件提供的 llama.cpp 运行时。", + "localRunBuiltin": "使用 FreeOS 运行", + "localBuiltinStarted": "已使用 FreeOS 本地运行时启动 {{name}}", + "localBuiltinStartFailed": "FreeOS 本地运行时启动失败" }, "advancedSettings": { "description": "管理运行配置和环境变量等高级选项。", diff --git a/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.test.tsx b/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.test.tsx index 6d93b45a..fe2d87c8 100644 --- a/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.test.tsx +++ b/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.test.tsx @@ -5,6 +5,7 @@ import { MemoryRouter } from "react-router-dom"; const probe = vi.fn(); const startOllama = vi.fn(); +const startLlamaCpp = vi.fn(); const ensureDeps = vi.fn(); const startScan = vi.fn(); const register = vi.fn(); @@ -16,6 +17,7 @@ vi.mock("../../../../api/modules/localModels", () => ({ localModelsApi: { probe: (...args: unknown[]) => probe(...args), startOllama: (...args: unknown[]) => startOllama(...args), + startLlamaCpp: (...args: unknown[]) => startLlamaCpp(...args), ensureDeps: (...args: unknown[]) => ensureDeps(...args), install: vi.fn(), startScan: (...args: unknown[]) => startScan(...args), @@ -51,6 +53,9 @@ const installedStopped = { ollama_installed: true, ollama_reachable: false, ollama_path: "C:\\\\Ollama\\\\ollama.exe", + llamacpp_binary: true, + llamacpp_reachable: false, + llamacpp_path: "C:\\\\FreeOS\\\\llama.cpp\\\\llama-server.exe", }, deps: [ { @@ -76,6 +81,12 @@ beforeEach(() => { vi.clearAllMocks(); probe.mockResolvedValue(installedStopped); startOllama.mockResolvedValue({ ok: true, installed: true, running: true }); + startLlamaCpp.mockResolvedValue({ + ok: true, + installed: true, + running: true, + registered: true, + }); startScan.mockResolvedValue({ job_id: "job-1", status: "completed", @@ -105,6 +116,20 @@ describe("", () => { await waitFor(() => expect(screen.getByText("tiny")).toBeInTheDocument()); expect(screen.getByText("D:\\\\models\\\\tiny.gguf")).toBeInTheDocument(); expect(screen.getByText("models.localRegister")).toBeInTheDocument(); + expect(screen.getByText("models.localRunBuiltin")).toBeInTheDocument(); + }); + + it("starts a discovered GGUF with the built-in runtime", async () => { + renderPanel(); + await waitFor(() => expect(screen.getByText("tiny")).toBeInTheDocument()); + await userEvent.click(screen.getByText("models.localRunBuiltin")); + await waitFor(() => + expect(startLlamaCpp).toHaveBeenCalledWith({ + model_path: "D:\\\\models\\\\tiny.gguf", + alias: "tiny", + gpu_layers: -1, + }), + ); }); it("starts a local weight search", async () => { @@ -142,10 +167,16 @@ describe("", () => { expect(screen.getByText("models.localSetDefault")).toBeInTheDocument(); await userEvent.click(screen.getByText("models.localSpeedTest")); await waitFor(() => - expect(speedTest).toHaveBeenCalledWith("tiny", expect.anything()), + expect(speedTest).toHaveBeenCalledWith( + "tiny", + undefined, + expect.anything(), + ), ); await userEvent.click(screen.getByText("models.localSetDefault")); - await waitFor(() => expect(setDefault).toHaveBeenCalledWith("tiny")); + await waitFor(() => + expect(setDefault).toHaveBeenCalledWith("tiny", undefined), + ); }); it("shows a default badge and can clear it", async () => { diff --git a/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.tsx b/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.tsx index 9c377cbd..4e4fac8b 100644 --- a/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.tsx +++ b/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.tsx @@ -84,6 +84,7 @@ export function LocalHardwarePanel({ const [starting, setStarting] = useState(false); const [ensuring, setEnsuring] = useState(false); const [registering, setRegistering] = useState(null); + const [startingLocal, setStartingLocal] = useState(null); const [testingKey, setTestingKey] = useState(null); const [settingDefault, setSettingDefault] = useState(null); const [speedResults, setSpeedResults] = useState< @@ -175,7 +176,9 @@ export function LocalHardwarePanel({ const install = async (name: string) => { const hw = probe?.hardware; - const missing = (probe?.deps ?? []).length > 0 || !hw?.ollama_reachable; + const missing = + (probe?.deps ?? []).some((dep) => dep.id === "ollama") || + !hw?.ollama_reachable; if (missing) { Modal.confirm({ title: t("models.localDepsNeededTitle"), @@ -353,6 +356,31 @@ export function LocalHardwarePanel({ } }; + const startWithFreeOS = async (item: LocalInstalledModel) => { + setStartingLocal(item.path || item.name); + try { + const result = await localModelsApi.startLlamaCpp({ + model_path: item.path, + alias: item.name, + gpu_layers: -1, + }); + if (!result.ok) { + showRuntimeError(result, t("models.localBuiltinStartFailed")); + return; + } + notifyModelsChanged(); + await onSaved?.(); + message.success(t("models.localBuiltinStarted", { name: item.name })); + await refresh(); + } catch (err) { + message.error( + apiErrorMessage(err, t("models.localBuiltinStartFailed"), t), + ); + } finally { + setStartingLocal(null); + } + }; + const itemKey = (item: LocalInstalledModel) => speedResultKey(item.source, item.name); @@ -368,9 +396,11 @@ export function LocalHardwarePanel({ }, SPEED_TEST_TIMEOUT_MS); setTestingKey(key); try { - const result = await localModelsApi.speedTest(item.name, { - signal: controller.signal, - }); + const result = await localModelsApi.speedTest( + item.name, + item.provider_name, + { signal: controller.signal }, + ); const stored = saveSpeedResult(item.source, item.name, result); setSpeedResults((prev) => ({ ...prev, [key]: stored })); if (result.ok) { @@ -424,7 +454,10 @@ export function LocalHardwarePanel({ const setAsDefault = async (item: LocalInstalledModel) => { setSettingDefault(item.name); try { - const result = await localModelsApi.setDefault(item.name); + const result = await localModelsApi.setDefault( + item.name, + item.provider_name, + ); if (!result.ok) { message.error( result.action === "not_registered" @@ -495,6 +528,8 @@ export function LocalHardwarePanel({ const hw = probe?.hardware; const ollamaInstalled = Boolean(hw?.ollama_installed || hw?.ollama_binary); const ollamaUp = Boolean(hw?.ollama_reachable); + const llamaCppInstalled = Boolean(hw?.llamacpp_binary); + const llamaCppUp = Boolean(hw?.llamacpp_reachable); const deps = probe?.deps ?? []; const models = useMemo( () => mergeModels(probe?.installed, scan?.found), @@ -538,6 +573,12 @@ export function LocalHardwarePanel({ ? t("models.localOllamaInstalledStopped") : t("organization.off")} + + {t("models.localBuiltinRuntime")}{" "} + {llamaCppUp ? t("organization.on") : t("organization.off")} + )} @@ -585,6 +626,15 @@ export function LocalHardwarePanel({ } /> )} + {deps.some((dep) => dep.id === "llamacpp") && ( + + )} , + ); + } if (canSpeedTest(item)) { + const runtimeUp = item.provider_name?.includes("llama.cpp") + ? llamaCppUp + : ollamaUp; actions.push( testing ? ( + + + )} void install(item.id)} + disabled={installing != null && installing !== item.id} + onClick={() => + void (item.install === "freeos" + ? installCatalogModel(item.id) + : install(item.id)) + } > - {t("models.localInstall")} + {t( + item.install === "freeos" + ? "models.localInstallAndRecommend" + : "models.localInstall", + )} , ]} > - + + {t(`models.localCatalogReason.${item.reason}`)} + {item.size ? {formatBytes(item.size)} : null} + + } + /> )} /> diff --git a/src/octop/api/routers/local_models.py b/src/octop/api/routers/local_models.py index 93f490db..8d32279d 100644 --- a/src/octop/api/routers/local_models.py +++ b/src/octop/api/routers/local_models.py @@ -26,12 +26,18 @@ from octop.infra.agents.providers.llamacpp_runtime import ( upsert_provider as upsert_llamacpp_provider, ) +from octop.infra.agents.providers.local_catalog import catalog from octop.infra.agents.providers.local_default import ( annotate_local_models, provider_base_url, resolve_local_model_ref, resolve_registered_or_usable, ) +from octop.infra.agents.providers.local_download import ( + cancel_download_job, + get_download_job, + start_download_job, +) from octop.infra.agents.providers.local_probe import probe_local_models from octop.infra.agents.providers.local_register import ( ensure_ollama_service_flag, @@ -74,6 +80,10 @@ class LocalInstallBody(BaseModel): name: str = Field(min_length=1, max_length=120, description="Ollama model tag to pull") +class LocalCatalogDownloadBody(BaseModel): + catalog_id: str = Field(min_length=1, max_length=120, description="Trusted catalog model id") + + class LocalScanBody(BaseModel): root: str | None = Field(default=None, max_length=1024, description="Optional folder to scan") full_disk: bool = Field( @@ -330,6 +340,46 @@ async def local_models_install( } +@router.get("/catalog", summary="List pinned GGUF models available for one-click setup") +async def local_models_catalog( + _: Any = Depends(require_permission("ollama_models")), +) -> list[dict[str, Any]]: + return catalog() + + +@router.post("/downloads", summary="Download a GGUF from the trusted model catalog") +async def local_models_download_start( + body: LocalCatalogDownloadBody, + _: Any = Depends(require_permission("ollama_models")), +) -> dict[str, Any]: + try: + return start_download_job(body.catalog_id).snapshot() + except ValueError as exc: + raise OctopError(ErrorCode.NOT_FOUND, str(exc)) from exc + + +@router.get("/downloads/{job_id}", summary="Poll a catalog model download") +async def local_models_download_status( + job_id: str, + _: Any = Depends(require_permission("ollama_models")), +) -> dict[str, Any]: + job = get_download_job(job_id) + if job is None: + raise OctopError(ErrorCode.NOT_FOUND, "download job not found") + return job.snapshot() + + +@router.delete("/downloads/{job_id}", summary="Cancel a catalog model download") +async def local_models_download_cancel( + job_id: str, + _: Any = Depends(require_permission("ollama_models")), +) -> dict[str, Any]: + if not cancel_download_job(job_id): + raise OctopError(ErrorCode.NOT_FOUND, "download job not found") + job = get_download_job(job_id) + return job.snapshot() if job is not None else {"job_id": job_id, "status": "cancelled"} + + @router.post("/scan", summary="Start a background scan for local GGUF / GGML weights") async def local_models_scan( body: LocalScanBody, diff --git a/src/octop/infra/agents/providers/local_catalog.py b/src/octop/infra/agents/providers/local_catalog.py new file mode 100644 index 00000000..97546caf --- /dev/null +++ b/src/octop/infra/agents/providers/local_catalog.py @@ -0,0 +1,49 @@ +"""Pinned, redistributable-friendly GGUF starter catalog for first-run setup.""" + +from __future__ import annotations + +from typing import Any + +_CATALOG: tuple[dict[str, Any], ...] = ( + { + "id": "qwen2.5-1.5b-instruct-q4-k-m", + "name": "qwen2.5-1.5b-instruct", + "display_name": "Qwen2.5 1.5B Instruct (Q4_K_M)", + "filename": "qwen2.5-1.5b-instruct-q4_k_m.gguf", + "url": "https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF/resolve/91cad51170dc346986eccefdc2dd33a9da36ead9/qwen2.5-1.5b-instruct-q4_k_m.gguf", + "sha256": "6a1a2eb6d15622bf3c96857206351ba97e1af16c30d7a74ee38970e434e9407e", + "size": 1_117_320_736, + "min_ram_gb": 6, + "license": "Apache-2.0", + "source_url": "https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF", + }, + { + "id": "qwen2.5-3b-instruct-q4-k-m", + "name": "qwen2.5-3b-instruct", + "display_name": "Qwen2.5 3B Instruct (Q4_K_M)", + "filename": "qwen2.5-3b-instruct-q4_k_m.gguf", + "url": "https://huggingface.co/Qwen/Qwen2.5-3B-Instruct-GGUF/resolve/7dabda4d13d513e3e842b20f0d435c732f172cbe/qwen2.5-3b-instruct-q4_k_m.gguf", + "sha256": "626b4a6678b86442240e33df819e00132d3ba7dddfe1cdc4fbb18e0a9615c62d", + "size": 2_104_932_768, + "min_ram_gb": 10, + "license": "Apache-2.0", + "source_url": "https://huggingface.co/Qwen/Qwen2.5-3B-Instruct-GGUF", + }, +) + + +def catalog() -> list[dict[str, Any]]: + return [dict(item) for item in _CATALOG] + + +def catalog_entry(catalog_id: str) -> dict[str, Any] | None: + return next((dict(item) for item in _CATALOG if item["id"] == catalog_id), None) + + +def recommended_catalog(ram_gb: float) -> list[dict[str, Any]]: + preferred = _CATALOG[1] if ram_gb >= 12 else _CATALOG[0] + other = _CATALOG[0] if preferred is _CATALOG[1] else _CATALOG[1] + return [ + {**dict(preferred), "reason": "recommended_for_hardware", "install": "freeos"}, + {**dict(other), "reason": "lighter_or_stronger_alternative", "install": "freeos"}, + ] diff --git a/src/octop/infra/agents/providers/local_download.py b/src/octop/infra/agents/providers/local_download.py new file mode 100644 index 00000000..8002e9fb --- /dev/null +++ b/src/octop/infra/agents/providers/local_download.py @@ -0,0 +1,139 @@ +"""Background downloads for the pinned FreeOS GGUF catalog.""" + +from __future__ import annotations + +import hashlib +import threading +import time +import urllib.request +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from octop.infra.agents.providers.local_catalog import catalog_entry +from octop.infra.utils.paths import PathLayout + +_MAX_JOBS = 8 + + +@dataclass +class DownloadJob: + job_id: str + catalog_id: str + status: str = "pending" + downloaded_bytes: int = 0 + total_bytes: int = 0 + path: str = "" + error: str | None = None + created_at: float = field(default_factory=time.time) + updated_at: float = field(default_factory=time.time) + cancel: threading.Event = field(default_factory=threading.Event) + + def snapshot(self) -> dict[str, Any]: + percent = ( + round(self.downloaded_bytes * 100 / self.total_bytes, 1) if self.total_bytes else 0 + ) + entry = catalog_entry(self.catalog_id) or {} + return { + "job_id": self.job_id, + "catalog_id": self.catalog_id, + "name": entry.get("name", ""), + "status": self.status, + "downloaded_bytes": self.downloaded_bytes, + "total_bytes": self.total_bytes, + "percent": percent, + "path": self.path, + "error": self.error, + } + + +_lock = threading.Lock() +_jobs: dict[str, DownloadJob] = {} + + +def get_download_job(job_id: str) -> DownloadJob | None: + with _lock: + return _jobs.get(job_id) + + +def cancel_download_job(job_id: str) -> bool: + job = get_download_job(job_id) + if job is None: + return False + job.cancel.set() + if job.status in {"pending", "running"}: + job.status = "cancelled" + job.updated_at = time.time() + return True + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _run(job: DownloadJob) -> None: + entry = catalog_entry(job.catalog_id) + if entry is None: + job.status, job.error = "failed", "model is not in the trusted catalog" + return + destination = PathLayout.from_env().root / "models" / str(entry["filename"]) + partial = destination.with_suffix(destination.suffix + ".part") + job.path = str(destination) + job.total_bytes = int(entry["size"]) + job.status = "running" + try: + destination.parent.mkdir(parents=True, exist_ok=True) + if destination.is_file() and _sha256(destination) == entry["sha256"]: + job.downloaded_bytes = destination.stat().st_size + job.status = "completed" + return + partial.unlink(missing_ok=True) + request = urllib.request.Request(str(entry["url"]), headers={"User-Agent": "FreeOS/0.0.6"}) + with urllib.request.urlopen(request, timeout=60) as response, partial.open("wb") as output: + header_size = int(response.headers.get("Content-Length") or 0) + if header_size: + job.total_bytes = header_size + while not job.cancel.is_set(): + chunk = response.read(1024 * 1024) + if not chunk: + break + output.write(chunk) + job.downloaded_bytes += len(chunk) + job.updated_at = time.time() + if job.cancel.is_set(): + job.status = "cancelled" + return + if _sha256(partial) != entry["sha256"]: + raise ValueError("download checksum mismatch") + partial.replace(destination) + job.downloaded_bytes = destination.stat().st_size + job.status = "completed" + except Exception as exc: # noqa: BLE001 - surfaced to the polling UI + job.status, job.error = "failed", str(exc) + finally: + if job.status != "completed": + partial.unlink(missing_ok=True) + job.updated_at = time.time() + + +def start_download_job(catalog_id: str) -> DownloadJob: + if catalog_entry(catalog_id) is None: + raise ValueError("model is not in the trusted catalog") + job = DownloadJob(job_id=str(uuid.uuid4()), catalog_id=catalog_id) + with _lock: + _jobs[job.job_id] = job + terminal = sorted(_jobs.values(), key=lambda item: item.created_at) + for stale in terminal: + if len(_jobs) <= _MAX_JOBS: + break + if stale.status not in {"pending", "running"}: + _jobs.pop(stale.job_id, None) + threading.Thread( + target=_run, args=(job,), daemon=True, name=f"model-download-{job.job_id}" + ).start() + return job diff --git a/src/octop/infra/agents/providers/local_probe.py b/src/octop/infra/agents/providers/local_probe.py index 45c119fa..29910e40 100644 --- a/src/octop/infra/agents/providers/local_probe.py +++ b/src/octop/infra/agents/providers/local_probe.py @@ -13,6 +13,7 @@ find_llama_server, is_llamacpp_reachable, ) +from octop.infra.agents.providers.local_catalog import recommended_catalog from octop.infra.agents.providers.local_weights import common_model_roots, scan_weight_roots from octop.infra.agents.providers.ollama_install import install_plan from octop.infra.utils.ollama_manager import OllamaModelManager, is_ollama_reachable @@ -95,30 +96,6 @@ def _ollama_models() -> tuple[bool, list[dict[str, Any]]]: return True, models -def recommend_models(ram_gb: float, has_gpu: bool) -> list[dict[str, str]]: - picks: list[tuple[str, str]] - if ram_gb and ram_gb < 8: - picks = [("llama3.2:1b", "1B chat model for 8 GB or less")] - elif ram_gb < 16: - picks = [ - ("llama3.2:3b", "3B chat model for 8–16 GB RAM"), - ("qwen2.5:3b", "Compact Qwen for everyday tasks"), - ] - elif ram_gb < 32: - picks = [ - ("llama3.1:8b", "8B general chat"), - ("qwen2.5:7b", "7B Qwen for Chinese + English"), - ] - else: - picks = [ - ("qwen2.5:14b", "14B when you have 32 GB+ RAM"), - ("llama3.1:8b", "8B fallback if the larger pull is too heavy"), - ] - if has_gpu and ram_gb >= 16: - picks = [("qwen2.5:14b", "GPU present — 14B is usable"), *picks] - return [{"id": mid, "reason": reason, "install": "ollama"} for mid, reason in picks] - - def _deps( *, ollama_installed: bool, @@ -200,5 +177,5 @@ def probe_local_models() -> dict[str, Any]: llamacpp_installed=llamacpp_binary is not None, ), "installed": discovered, - "recommended": recommend_models(ram, bool(gpu)), + "recommended": recommended_catalog(ram), } diff --git a/tests/unit/test_local_catalog_download.py b/tests/unit/test_local_catalog_download.py new file mode 100644 index 00000000..4ae4aa75 --- /dev/null +++ b/tests/unit/test_local_catalog_download.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import hashlib +import io +from pathlib import Path + +from octop.infra.agents.providers import local_catalog, local_download + + +class _Response(io.BytesIO): + headers = {"Content-Length": "11"} + + def __enter__(self) -> _Response: + return self + + def __exit__(self, *_args: object) -> None: + self.close() + + +def test_catalog_recommends_smaller_model_for_low_memory() -> None: + low = local_catalog.recommended_catalog(8) + high = local_catalog.recommended_catalog(16) + + assert low[0]["id"] == "qwen2.5-1.5b-instruct-q4-k-m" + assert high[0]["id"] == "qwen2.5-3b-instruct-q4-k-m" + assert all(item["install"] == "freeos" for item in low) + + +def test_download_verifies_hash_and_moves_to_models(tmp_path: Path, monkeypatch: object) -> None: + content = b"hello gguf!" + entry = { + "id": "test-model", + "name": "test-model", + "filename": "test.gguf", + "url": "https://example.invalid/test.gguf", + "sha256": hashlib.sha256(content).hexdigest(), + "size": len(content), + } + monkeypatch.setenv("FREEOS_HOME", str(tmp_path)) # type: ignore[attr-defined] + monkeypatch.setattr(local_download, "catalog_entry", lambda _model_id: dict(entry)) # type: ignore[attr-defined] + monkeypatch.setattr( + local_download.urllib.request, "urlopen", lambda *_a, **_k: _Response(content) + ) # type: ignore[attr-defined] + job = local_download.DownloadJob(job_id="job", catalog_id="test-model") + + local_download._run(job) + + assert job.status == "completed" + assert Path(job.path).read_bytes() == content + assert not Path(f"{job.path}.part").exists() diff --git a/tests/unit/test_local_probe.py b/tests/unit/test_local_probe.py index c7bf4d10..e2be7bdb 100644 --- a/tests/unit/test_local_probe.py +++ b/tests/unit/test_local_probe.py @@ -1,13 +1,4 @@ -from octop.infra.agents.providers.local_probe import probe_local_models, recommend_models - - -def test_recommend_models_scales_with_ram() -> None: - tiny = recommend_models(4, False) - mid = recommend_models(16, False) - big = recommend_models(64, True) - assert tiny[0]["id"] == "llama3.2:1b" - assert any(item["id"].startswith("llama3.1") or item["id"].startswith("qwen") for item in mid) - assert any("14b" in item["id"] for item in big) +from octop.infra.agents.providers.local_probe import probe_local_models def test_probe_local_models_shape() -> None: From e79e90b6d6867c693ad5657a6db4147f35a7bc4a Mon Sep 17 00:00:00 2001 From: XYAIStudio Date: Tue, 29 Sep 2026 11:15:55 +0800 Subject: [PATCH 5/8] fix(setup): offer to start installed Ollama --- .../Setup/steps/ModelStep.ollama.test.tsx | 78 +++++++++++++++++++ dashboard/src/pages/Setup/steps/ModelStep.tsx | 43 ++++++++++ 2 files changed, 121 insertions(+) create mode 100644 dashboard/src/pages/Setup/steps/ModelStep.ollama.test.tsx diff --git a/dashboard/src/pages/Setup/steps/ModelStep.ollama.test.tsx b/dashboard/src/pages/Setup/steps/ModelStep.ollama.test.tsx new file mode 100644 index 00000000..96acb267 --- /dev/null +++ b/dashboard/src/pages/Setup/steps/ModelStep.ollama.test.tsx @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +const probe = vi.fn(); +const startOllama = vi.fn(); + +vi.mock("../../../api/request", () => ({ + request: vi.fn().mockResolvedValue([ + { + id: "ollama", + name: "Ollama (Local)", + base_url: "http://localhost:11434/v1", + protocol: "openai", + api_key_prefix: "", + models: [{ id: "qwen3:8b", name: "qwen3:8b" }], + }, + ]), +})); + +vi.mock("../../../api/modules/localModels", () => ({ + localModelsApi: { + probe: (...args: unknown[]) => probe(...args), + startOllama: (...args: unknown[]) => startOllama(...args), + }, +})); + +vi.mock("../wizardClient", () => ({ + wizardApi: { testProvider: vi.fn() }, + wizardSession: { saveDraft: vi.fn() }, + resolveSetupProbeToken: vi.fn(), +})); + +import ModelStep from "./ModelStep"; + +describe("ModelStep Ollama startup", () => { + beforeEach(() => { + vi.clearAllMocks(); + probe + .mockResolvedValueOnce({ + hardware: { + ollama_installed: true, + ollama_binary: true, + ollama_reachable: false, + }, + installed: [], + recommended: [], + }) + .mockResolvedValue({ + hardware: { + ollama_installed: true, + ollama_binary: true, + ollama_reachable: true, + }, + installed: [], + recommended: [], + }); + startOllama.mockResolvedValue({ ok: true, installed: true, running: true }); + }); + + it("offers to start an installed Ollama and refreshes its status", async () => { + render( + , + ); + + const button = await screen.findByText("models.localStartOllama"); + await userEvent.click(button); + + await waitFor(() => expect(startOllama).toHaveBeenCalledOnce()); + await waitFor(() => expect(probe).toHaveBeenCalledTimes(2)); + expect(screen.queryByText("models.localStartOllama")).toBeNull(); + }); +}); diff --git a/dashboard/src/pages/Setup/steps/ModelStep.tsx b/dashboard/src/pages/Setup/steps/ModelStep.tsx index e40633d1..262a2841 100644 --- a/dashboard/src/pages/Setup/steps/ModelStep.tsx +++ b/dashboard/src/pages/Setup/steps/ModelStep.tsx @@ -157,6 +157,7 @@ export default function ModelStep({ >([]); const [selectedModelIds, setSelectedModelIds] = useState([]); const [testing, setTesting] = useState(false); + const [startingOllama, setStartingOllama] = useState(false); const [testPassed, setTestPassed] = useState(false); const [variantGroup, setVariantGroup] = useState(null); const apiKeySectionRef = useRef(null); @@ -274,6 +275,36 @@ export default function ModelStep({ return t("wizard.model.ollamaMissing"); })(); + const canStartDetectedOllama = Boolean( + detectLocal && + localProbe && + !localProbe.hardware.ollama_reachable && + (localProbe.hardware.ollama_installed || + localProbe.hardware.ollama_binary), + ); + + const handleStartOllama = async () => { + setStartingOllama(true); + try { + const result = await localModelsApi.startOllama(); + const nextProbe = await localModelsApi.probe(); + setLocalProbe(nextProbe); + if (result.ok && nextProbe.hardware.ollama_reachable) { + message.success(t("models.localOllamaStarted")); + return; + } + message.error( + result.next_step || result.error || t("models.localOllamaStartFailed"), + ); + } catch (err) { + message.error( + err instanceof Error ? err.message : t("models.localOllamaStartFailed"), + ); + } finally { + setStartingOllama(false); + } + }; + const renderStepIntro = (fallback: string) => ( <> @@ -284,6 +315,18 @@ export default function ModelStep({ {localDetectHint} ) : null} + {canStartDetectedOllama ? ( +
+ +
+ ) : null} {detectLocal ? ( {t("wizard.model.nextHint")} From b443fb3433781d86e7514457ba8818b5a4dba3d2 Mon Sep 17 00:00:00 2001 From: XYAIStudio Date: Tue, 29 Sep 2026 15:47:28 +0800 Subject: [PATCH 6/8] feat(local-models): resume downloads and restore runtime --- CHANGELOG.md | 14 ++ dashboard/src/api/modules/localModels.ts | 2 + dashboard/src/locales/en.json | 2 + dashboard/src/locales/zh.json | 2 + .../components/LocalHardwarePanel.test.tsx | 36 +++++ .../Models/components/LocalHardwarePanel.tsx | 46 ++++++ dashboard/src/utils/localSpeedResults.test.ts | 3 +- dashboard/src/utils/localSpeedResults.ts | 5 +- docs/api.md | 8 ++ docs/install-0.0.7.zh-CN.md | 33 +++++ src/octop/api/routers/local_models.py | 8 ++ .../agents/providers/llamacpp_runtime.py | 58 +++++++- .../infra/agents/providers/local_download.py | 133 ++++++++++++++++-- src/octop/infra/server.py | 6 + tests/unit/agents/test_llamacpp_runtime.py | 58 ++++++++ tests/unit/test_local_catalog_download.py | 65 ++++++++- 16 files changed, 458 insertions(+), 21 deletions(-) create mode 100644 docs/install-0.0.7.zh-CN.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c88b2b72..63796141 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ ## [Unreleased] +### 新增 + +- 面向未安装 Ollama、也没有 GGUF 模型的新用户,模型页提供经过固定版本与 SHA-256 校验的 GGUF 模型目录、一键下载、实时进度、自动启动内置 llama.cpp、首次测速并设为默认模型。 +- Windows amd64 桌面包内置固定版本的 llama.cpp sidecar;已安装 Ollama 但服务未启动时,首次配置页允许用户自行选择启动 Ollama。 + +### 变更 + +- 本机模型下载任务和测速结果跨 FreeOS 重启保留;下载中断、取消或临时失败后可从已有部分文件继续,开始前会检查剩余磁盘空间。 +- 用户明确启动过的 llama.cpp 模型会在下次启动 FreeOS 时自动恢复;用户主动停止后不会再次自动拉起。 + +### 修复 + +- 同一目录模型下载自动去重,服务器不支持 HTTP Range 时安全回退为完整重下,完整 `.part` 文件会先校验再直接完成,避免重复流量与并发覆盖。 + ## [0.0.6] - 2026-09-22 ### 安全 diff --git a/dashboard/src/api/modules/localModels.ts b/dashboard/src/api/modules/localModels.ts index 71b59219..edc99ef2 100644 --- a/dashboard/src/api/modules/localModels.ts +++ b/dashboard/src/api/modules/localModels.ts @@ -59,6 +59,7 @@ export interface LocalDownloadJob { percent: number; path: string; error?: string | null; + resumable?: boolean; } export interface LocalProbe { @@ -139,6 +140,7 @@ export const localModelsApi = { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ catalog_id: catalogId }), }), + listDownloads: () => request("/local-models/downloads"), getDownload: (jobId: string) => request( `/local-models/downloads/${encodeURIComponent(jobId)}`, diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index df3cfd86..d361d312 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -3493,6 +3493,8 @@ "localRecommendedHint": "No Ollama required. FreeOS downloads the model, starts its bundled runtime, benchmarks it, and selects it for local chat after a successful test.", "localInstallAndRecommend": "Download and recommend", "localDownloadProgress": "Downloaded {{current}} / {{total}}", + "localDownloadInterrupted": "The previous model download did not finish. You can continue from the saved progress.", + "localDownloadResume": "Resume download", "localAutoSetupDone": "{{name}} was downloaded, benchmarked, and selected for local chat", "localCatalogReason": { "recommended_for_hardware": "Recommended for this computer", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 6686fd7a..3cc0ad3e 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -3490,6 +3490,8 @@ "localRecommendedHint": "无需安装 Ollama。下载完成后,FreeOS 会自动启动内置运行时、测速,并将通过测试的模型设为本地对话推荐。", "localInstallAndRecommend": "一键下载并推荐", "localDownloadProgress": "已下载 {{current}} / {{total}}", + "localDownloadInterrupted": "上次模型下载尚未完成,可以从已有进度继续。", + "localDownloadResume": "继续下载", "localAutoSetupDone": "{{name}} 已下载、测速并设为本地推荐模型", "localCatalogReason": { "recommended_for_hardware": "根据本机内存推荐", diff --git a/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.test.tsx b/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.test.tsx index 377303eb..18d19996 100644 --- a/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.test.tsx +++ b/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.test.tsx @@ -13,6 +13,7 @@ const speedTest = vi.fn(); const setDefault = vi.fn(); const clearDefault = vi.fn(); const startDownload = vi.fn(); +const listDownloads = vi.fn(); vi.mock("../../../../api/modules/localModels", () => ({ localModelsApi: { @@ -22,6 +23,7 @@ vi.mock("../../../../api/modules/localModels", () => ({ ensureDeps: (...args: unknown[]) => ensureDeps(...args), install: vi.fn(), startDownload: (...args: unknown[]) => startDownload(...args), + listDownloads: (...args: unknown[]) => listDownloads(...args), getDownload: vi.fn(), cancelDownload: vi.fn(), startScan: (...args: unknown[]) => startScan(...args), @@ -96,6 +98,7 @@ beforeEach(() => { status: "completed", found: installedStopped.installed, }); + listDownloads.mockResolvedValue([]); }); function renderPanel(props: { onSaved?: () => void | Promise } = {}) { @@ -220,6 +223,39 @@ describe("", () => { window.removeEventListener("octop:models-changed", heard); }); + it("offers to resume an interrupted catalog download", async () => { + listDownloads.mockResolvedValue([ + { + job_id: "download-old", + catalog_id: "starter", + name: "starter-model", + status: "interrupted", + downloaded_bytes: 512, + total_bytes: 1024, + percent: 50, + path: "D:\\models\\starter.gguf", + resumable: true, + }, + ]); + startDownload.mockResolvedValue({ + job_id: "download-new", + catalog_id: "starter", + name: "starter-model", + status: "running", + downloaded_bytes: 512, + total_bytes: 1024, + percent: 50, + path: "D:\\models\\starter.gguf", + resumable: true, + }); + + renderPanel(); + await screen.findByText("models.localDownloadResume"); + await userEvent.click(screen.getByText("models.localDownloadResume")); + + await waitFor(() => expect(startDownload).toHaveBeenCalledWith("starter")); + }); + it("downloads, benchmarks, and selects a catalog model", async () => { probe.mockResolvedValue({ ...installedStopped, diff --git a/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.tsx b/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.tsx index 20b2ac15..80d0f4cd 100644 --- a/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.tsx +++ b/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.tsx @@ -130,11 +130,31 @@ export function LocalHardwarePanel({ useEffect(() => { void refresh(); + void localModelsApi + .listDownloads() + .then((jobs) => { + const recent = jobs.find((job) => + ["pending", "running", "interrupted", "cancelled", "failed"].includes( + job.status, + ), + ); + if (!recent) return; + setDownload(recent); + if (["pending", "running"].includes(recent.status)) { + setInstalling(recent.catalog_id); + pollDownload(recent.job_id); + } + }) + .catch(() => { + /* download history is best-effort; hardware discovery still works */ + }); return () => { stopPoll(); stopDownloadPoll(); speedAbortRef.current?.abort(); }; + // The initial probe owns these timers for the panel lifetime. + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const showRuntimeError = (result: LocalRuntimeResult, fallback: string) => { @@ -952,6 +972,32 @@ export function LocalHardwarePanel({ )} + {download?.resumable && + ["interrupted", "cancelled", "failed"].includes(download.status) && ( + + + {t("models.localDownloadProgress", { + current: formatBytes(download.downloaded_bytes), + total: formatBytes(download.total_bytes), + })} + + + + } + /> + )} { sessionStorage.clear(); + if (typeof localStorage.clear === "function") localStorage.clear(); }); describe("localSpeedResults", () => { - it("round-trips the last speed result in sessionStorage", () => { + it("round-trips the last speed result in persistent localStorage", () => { const stored = saveSpeedResult("ollama", "tiny", { ok: true, latency_ms: 120, diff --git a/dashboard/src/utils/localSpeedResults.ts b/dashboard/src/utils/localSpeedResults.ts index 5cdd016e..a0231597 100644 --- a/dashboard/src/utils/localSpeedResults.ts +++ b/dashboard/src/utils/localSpeedResults.ts @@ -16,7 +16,10 @@ export interface StoredLocalSpeedResult { function storage(): Storage | null { if (typeof window === "undefined") return null; try { - return window.sessionStorage; + const persistent = window.localStorage; + if (typeof persistent?.getItem === "function") return persistent; + const session = window.sessionStorage; + return typeof session?.getItem === "function" ? session : null; } catch { return null; } diff --git a/docs/api.md b/docs/api.md index 15ffc841..cd46c2d9 100644 --- a/docs/api.md +++ b/docs/api.md @@ -217,8 +217,16 @@ require the `ollama_models` permission. |--------|------|------|-------| | `GET` | `/local-models/probe` | user | Hardware, Ollama install/reachability, known-dir GGUF/GGML hits, recommended pulls | | `POST` | `/local-models/start-ollama` | ollama_models | Start an already-installed Ollama app/daemon. Does not download Ollama | +| `GET` | `/local-models/llamacpp/status` | ollama_models | Inspect the bundled llama.cpp runtime and its loopback service | +| `POST` | `/local-models/llamacpp/start` | ollama_models | body `{model_path, alias?, context_size?, gpu_layers?}` — start a GGUF model and remember it for restart recovery | +| `DELETE` | `/local-models/llamacpp` | ollama_models | Stop the managed llama.cpp process and disable automatic restart recovery | | `POST` | `/local-models/ensure-deps` | ollama_models | body `{install?}` — start Ollama, or one-click install only when winget/brew/official script can run | | `POST` | `/local-models/install` | ollama_models | body `{name}` — pull a recommended Ollama tag after the daemon is up | +| `GET` | `/local-models/catalog` | ollama_models | List pinned, checksum-verified GGUF models recommended for this device | +| `POST` | `/local-models/downloads` | ollama_models | body `{catalog_id}` — create or resume a trusted GGUF download | +| `GET` | `/local-models/downloads` | ollama_models | List recent downloads, including interrupted resumable jobs | +| `GET` | `/local-models/downloads/{job_id}` | ollama_models | Poll durable download state and progress | +| `DELETE` | `/local-models/downloads/{job_id}` | ollama_models | Cancel a download while retaining its partial file for later resume | | `POST` | `/local-models/scan` | ollama_models | body `{root?, full_disk?}` — background weight search (progress + cancel) | | `GET` | `/local-models/scan` | ollama_models | latest scan job | | `GET` | `/local-models/scan/{job_id}` | ollama_models | poll scan progress | diff --git a/docs/install-0.0.7.zh-CN.md b/docs/install-0.0.7.zh-CN.md new file mode 100644 index 00000000..1a70f27d --- /dev/null +++ b/docs/install-0.0.7.zh-CN.md @@ -0,0 +1,33 @@ +# FreeOS 0.0.7 安装与本机模型指南 + +0.0.7 仍应只从 FreeOS 的 [GitHub Releases](https://github.com/XYAIStudio/FreeOS/releases) 下载。发布完成前,不要把开发分支构建当作正式安装包。 + +## Windows + +- 常见 Intel/AMD 电脑:下载 `FreeOS-desktop-windows-amd64-0.0.7.exe`。 +- Windows ARM 电脑:下载 `FreeOS-desktop-windows-arm64-0.0.7.exe`。 +- 免安装版:下载对应架构的 `FreeOS-portable-windows-*.zip`,完整解压后运行。 + +升级前先从系统托盘退出旧版 FreeOS,再运行安装程序。安装程序保留 `%USERPROFILE%\.freeos` 中的账号、模型设置、知识库和组织数据。 + +## 首次选择本机模型 + +1. 如果电脑已安装 Ollama,FreeOS 会显示当前状态;服务未启动时,用户可点击“启动 Ollama”。 +2. 如果没有 Ollama,可在“设置 → 模型 → 本地”选择 FreeOS 推荐的 GGUF 模型并点击一键安装。Windows amd64 包已带 llama.cpp 运行时,不需要另装推理程序。 +3. FreeOS 会显示下载进度;关闭程序、网络中断或取消后,再次进入模型页可继续下载。 +4. 下载完成后,FreeOS 会校验文件、启动模型、执行轻量测速并将成功的模型设为默认对话模型。 +5. 测速结果会保留在本机。用户仍可手动测速、切换默认模型或停止 llama.cpp。 + +模型文件通常较大。FreeOS 会在下载前检查目标磁盘的剩余空间,并额外预留 256 MiB。模型默认保存在 FreeOS 数据目录的 `models` 文件夹。 + +## macOS 与 Linux + +下载与设备架构一致的桌面包或 portable 包。当前内置 llama.cpp 运行时首先覆盖 Windows amd64;其他平台可以使用已安装的 Ollama,或接入 LM Studio、llama.cpp server、vLLM 等 OpenAI 兼容本机服务。 + +## 常见问题 + +- **下载中断**:重新进入本地模型页,点击“继续下载”。不要手工删除同名 `.part` 文件。 +- **模型启动失败**:查看 `%USERPROFILE%\.freeos\logs\llama-sidecar.log`,确认显存或内存足够。 +- **重启后模型不可用**:先确认模型文件仍在原路径;用户曾主动点击“停止”时,FreeOS 不会自动恢复。 +- **Ollama 已安装但未运行**:在首次配置或本地模型页点击“启动 Ollama”,FreeOS 不会在未征得用户选择时强制启动它。 +- **诊断资料**:反馈时附上发生时间、FreeOS 版本和相关日志,并移除 API Key、令牌等敏感信息。 diff --git a/src/octop/api/routers/local_models.py b/src/octop/api/routers/local_models.py index 8d32279d..c2eb08fa 100644 --- a/src/octop/api/routers/local_models.py +++ b/src/octop/api/routers/local_models.py @@ -36,6 +36,7 @@ from octop.infra.agents.providers.local_download import ( cancel_download_job, get_download_job, + list_download_jobs, start_download_job, ) from octop.infra.agents.providers.local_probe import probe_local_models @@ -358,6 +359,13 @@ async def local_models_download_start( raise OctopError(ErrorCode.NOT_FOUND, str(exc)) from exc +@router.get("/downloads", summary="List recent GGUF download tasks") +async def local_models_download_list( + _: Any = Depends(require_permission("ollama_models")), +) -> list[dict[str, Any]]: + return [job.snapshot() for job in list_download_jobs()] + + @router.get("/downloads/{job_id}", summary="Poll a catalog model download") async def local_models_download_status( job_id: str, diff --git a/src/octop/infra/agents/providers/llamacpp_runtime.py b/src/octop/infra/agents/providers/llamacpp_runtime.py index 43f51e3f..55de5d1e 100644 --- a/src/octop/infra/agents/providers/llamacpp_runtime.py +++ b/src/octop/infra/agents/providers/llamacpp_runtime.py @@ -14,6 +14,8 @@ from pathlib import Path from typing import Any +from octop.infra.utils.paths import PathLayout + LLAMACPP_PROVIDER_NAME = "FreeOS Local (llama.cpp)" LLAMACPP_HOST = "127.0.0.1" LLAMACPP_PORT = 11435 @@ -113,6 +115,31 @@ def _log_path() -> Path: return path +def _restore_state_path() -> Path: + return PathLayout.from_env().root / "models" / "llamacpp-state.json" + + +def _save_restore_state( + *, model_path: Path, alias: str, context_size: int, gpu_layers: int +) -> None: + target = _restore_state_path() + target.parent.mkdir(parents=True, exist_ok=True) + temporary = target.with_suffix(".json.tmp") + temporary.write_text( + json.dumps( + { + "model_path": str(model_path), + "alias": alias, + "context_size": context_size, + "gpu_layers": gpu_layers, + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + temporary.replace(target) + + def status() -> dict[str, Any]: binary = find_llama_server() with _LOCK: @@ -159,7 +186,7 @@ def start( if _PROCESS is not None and _PROCESS.poll() is None: if str(model) == _PROCESS_MODEL and is_llamacpp_reachable(): return {**status(), "alias": model_alias} - stop() + stop(clear_restore=False) if is_llamacpp_reachable(): return { **status(), @@ -197,13 +224,19 @@ def start( for _ in range(40): if is_llamacpp_reachable(): + _save_restore_state( + model_path=model, + alias=model_alias, + context_size=context_size, + gpu_layers=gpu_layers, + ) return {**status(), "alias": model_alias} with _LOCK: if _PROCESS is None or _PROCESS.poll() is not None: break threading.Event().wait(0.25) failed = status() - stop() + stop(clear_restore=False) return { **failed, "ok": False, @@ -213,7 +246,7 @@ def start( } -def stop() -> dict[str, Any]: +def stop(*, clear_restore: bool = True) -> dict[str, Any]: global _PROCESS, _PROCESS_MODEL with _LOCK: proc = _PROCESS @@ -226,9 +259,28 @@ def stop() -> dict[str, Any]: except subprocess.TimeoutExpired: proc.kill() proc.wait(timeout=3) + if clear_restore: + _restore_state_path().unlink(missing_ok=True) return status() +def restore() -> dict[str, Any] | None: + """Restart the last explicitly started sidecar after a FreeOS restart.""" + path = _restore_state_path() + if not path.is_file() or is_llamacpp_reachable(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + return start( + model_path=str(data["model_path"]), + alias=str(data.get("alias") or ""), + context_size=int(data.get("context_size") or 8192), + gpu_layers=int(data.get("gpu_layers", -1)), + ) + except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError): + return None + + def _shutdown() -> None: """Terminate only the process owned by this interpreter during shutdown.""" global _PROCESS, _PROCESS_MODEL diff --git a/src/octop/infra/agents/providers/local_download.py b/src/octop/infra/agents/providers/local_download.py index 8002e9fb..0f4409e0 100644 --- a/src/octop/infra/agents/providers/local_download.py +++ b/src/octop/infra/agents/providers/local_download.py @@ -3,6 +3,8 @@ from __future__ import annotations import hashlib +import json +import shutil import threading import time import urllib.request @@ -15,6 +17,7 @@ from octop.infra.utils.paths import PathLayout _MAX_JOBS = 8 +_DISK_RESERVE_BYTES = 256 * 1024 * 1024 @dataclass @@ -45,6 +48,11 @@ def snapshot(self) -> dict[str, Any]: "percent": percent, "path": self.path, "error": self.error, + "created_at": self.created_at, + "updated_at": self.updated_at, + "resumable": self.status != "completed" and Path(f"{self.path}.part").is_file() + if self.path + else False, } @@ -52,9 +60,64 @@ def snapshot(self) -> dict[str, Any]: _jobs: dict[str, DownloadJob] = {} +def _state_dir() -> Path: + return PathLayout.from_env().root / "models" / ".downloads" + + +def _state_path(job_id: str) -> Path: + return _state_dir() / f"{job_id}.json" + + +def _save_job(job: DownloadJob) -> None: + directory = _state_dir() + directory.mkdir(parents=True, exist_ok=True) + target = _state_path(job.job_id) + temporary = target.with_suffix(f".json.{threading.get_ident()}.tmp") + temporary.write_text(json.dumps(job.snapshot(), ensure_ascii=False), encoding="utf-8") + temporary.replace(target) + + +def _load_job(job_id: str) -> DownloadJob | None: + path = _state_path(job_id) + if not path.is_file(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + job = DownloadJob(job_id=str(data["job_id"]), catalog_id=str(data["catalog_id"])) + job.status = str(data.get("status") or "failed") + if job.status in {"pending", "running"}: + job.status = "interrupted" + job.error = "FreeOS stopped before the download completed. Retry to continue." + else: + job.error = str(data["error"]) if data.get("error") else None + job.downloaded_bytes = int(data.get("downloaded_bytes") or 0) + job.total_bytes = int(data.get("total_bytes") or 0) + job.path = str(data.get("path") or "") + job.created_at = float(data.get("created_at") or path.stat().st_mtime) + job.updated_at = float(data.get("updated_at") or path.stat().st_mtime) + return job + except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError): + return None + + def get_download_job(job_id: str) -> DownloadJob | None: with _lock: - return _jobs.get(job_id) + job = _jobs.get(job_id) + if job is not None: + return job + job = _load_job(job_id) + if job is not None: + _jobs[job_id] = job + return job + + +def list_download_jobs() -> list[DownloadJob]: + """Return recent jobs from memory and durable state, newest first.""" + state_ids = [path.stem for path in _state_dir().glob("*.json")] if _state_dir().is_dir() else [] + for job_id in state_ids: + get_download_job(job_id) + with _lock: + return sorted(_jobs.values(), key=lambda item: item.updated_at, reverse=True)[:_MAX_JOBS] def cancel_download_job(job_id: str) -> bool: @@ -65,6 +128,7 @@ def cancel_download_job(job_id: str) -> bool: if job.status in {"pending", "running"}: job.status = "cancelled" job.updated_at = time.time() + _save_job(job) return True @@ -86,25 +150,55 @@ def _run(job: DownloadJob) -> None: job.path = str(destination) job.total_bytes = int(entry["size"]) job.status = "running" + job.error = None + _save_job(job) try: destination.parent.mkdir(parents=True, exist_ok=True) if destination.is_file() and _sha256(destination) == entry["sha256"]: job.downloaded_bytes = destination.stat().st_size job.status = "completed" return - partial.unlink(missing_ok=True) - request = urllib.request.Request(str(entry["url"]), headers={"User-Agent": "FreeOS/0.0.6"}) - with urllib.request.urlopen(request, timeout=60) as response, partial.open("wb") as output: + existing = partial.stat().st_size if partial.is_file() else 0 + if existing > job.total_bytes: + partial.unlink(missing_ok=True) + existing = 0 + if existing == job.total_bytes and existing > 0: + if _sha256(partial) == entry["sha256"]: + partial.replace(destination) + job.downloaded_bytes = destination.stat().st_size + job.status = "completed" + return + partial.unlink(missing_ok=True) + existing = 0 + remaining = max(job.total_bytes - existing, 0) + free = shutil.disk_usage(destination.parent).free + if free < remaining + _DISK_RESERVE_BYTES: + raise OSError( + f"Not enough disk space: need {remaining + _DISK_RESERVE_BYTES} bytes " + f"including safety reserve, only {free} bytes available." + ) + headers = {"User-Agent": "FreeOS/0.0.7"} + if existing: + headers["Range"] = f"bytes={existing}-" + request = urllib.request.Request(str(entry["url"]), headers=headers) + with urllib.request.urlopen(request, timeout=60) as response: + resumed = existing > 0 and int(getattr(response, "status", 200)) == 206 + mode = "ab" if resumed else "wb" + if not resumed: + existing = 0 + job.downloaded_bytes = existing header_size = int(response.headers.get("Content-Length") or 0) if header_size: - job.total_bytes = header_size - while not job.cancel.is_set(): - chunk = response.read(1024 * 1024) - if not chunk: - break - output.write(chunk) - job.downloaded_bytes += len(chunk) - job.updated_at = time.time() + job.total_bytes = existing + header_size + with partial.open(mode) as output: + while not job.cancel.is_set(): + chunk = response.read(1024 * 1024) + if not chunk: + break + output.write(chunk) + job.downloaded_bytes += len(chunk) + job.updated_at = time.time() + _save_job(job) if job.cancel.is_set(): job.status = "cancelled" return @@ -116,16 +210,25 @@ def _run(job: DownloadJob) -> None: except Exception as exc: # noqa: BLE001 - surfaced to the polling UI job.status, job.error = "failed", str(exc) finally: - if job.status != "completed": - partial.unlink(missing_ok=True) job.updated_at = time.time() + _save_job(job) def start_download_job(catalog_id: str) -> DownloadJob: if catalog_entry(catalog_id) is None: raise ValueError("model is not in the trusted catalog") - job = DownloadJob(job_id=str(uuid.uuid4()), catalog_id=catalog_id) with _lock: + active = next( + ( + item + for item in _jobs.values() + if item.catalog_id == catalog_id and item.status in {"pending", "running"} + ), + None, + ) + if active is not None: + return active + job = DownloadJob(job_id=str(uuid.uuid4()), catalog_id=catalog_id) _jobs[job.job_id] = job terminal = sorted(_jobs.values(), key=lambda item: item.created_at) for stale in terminal: diff --git a/src/octop/infra/server.py b/src/octop/infra/server.py index fbc48efd..982c4fe2 100644 --- a/src/octop/infra/server.py +++ b/src/octop/infra/server.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import gzip import logging import os @@ -509,6 +510,11 @@ async def _boot_runtime(self, config: OctopConfig) -> None: trajectory_service=trajectory_service, history_archive=history_archive, ) + from octop.infra.agents.providers.llamacpp_runtime import restore # noqa: PLC0415 + + restored = await asyncio.to_thread(restore) + if restored is not None and not restored.get("ok"): + logger.warning("llama.cpp sidecar restore failed: %s", restored.get("error", "")) from octop.infra.knowledge.jobs import resume_pending_index_jobs # noqa: PLC0415 resume_pending_index_jobs(self.services) diff --git a/tests/unit/agents/test_llamacpp_runtime.py b/tests/unit/agents/test_llamacpp_runtime.py index 9379b0ec..d1508a2d 100644 --- a/tests/unit/agents/test_llamacpp_runtime.py +++ b/tests/unit/agents/test_llamacpp_runtime.py @@ -80,6 +80,64 @@ def popen(args: list[str], **_kwargs: object): assert calls assert calls[0][calls[0].index("--host") + 1] == "127.0.0.1" assert calls[0][calls[0].index("--model") + 1] == str(model.resolve()) + state = json.loads( + (tmp_path / "home" / "models" / "llamacpp-state.json").read_text(encoding="utf-8") + ) + assert state["alias"] == "tiny" + assert state["model_path"] == str(model.resolve()) + + +def test_restore_restarts_saved_model(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + model = tmp_path / "tiny.gguf" + model.write_bytes(b"model") + home = tmp_path / "home" + state = home / "models" / "llamacpp-state.json" + state.parent.mkdir(parents=True) + state.write_text( + json.dumps( + { + "model_path": str(model), + "alias": "tiny", + "context_size": 4096, + "gpu_layers": 12, + } + ), + encoding="utf-8", + ) + calls: list[dict[str, object]] = [] + + monkeypatch.setenv("OCTOP_HOME", str(home)) + monkeypatch.setattr(llamacpp_runtime, "is_llamacpp_reachable", lambda: False) + monkeypatch.setattr( + llamacpp_runtime, + "start", + lambda **kwargs: calls.append(kwargs) or {"ok": True}, + ) + + assert llamacpp_runtime.restore() == {"ok": True} + assert calls == [ + { + "model_path": str(model), + "alias": "tiny", + "context_size": 4096, + "gpu_layers": 12, + } + ] + + +def test_stop_disables_restart(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + home = tmp_path / "home" + state = home / "models" / "llamacpp-state.json" + state.parent.mkdir(parents=True) + state.write_text("{}", encoding="utf-8") + monkeypatch.setenv("OCTOP_HOME", str(home)) + monkeypatch.setattr(llamacpp_runtime, "_PROCESS", None) + monkeypatch.setattr(llamacpp_runtime, "_PROCESS_MODEL", None) + monkeypatch.setattr(llamacpp_runtime, "is_llamacpp_reachable", lambda: False) + + llamacpp_runtime.stop() + + assert not state.exists() def test_start_rejects_non_gguf(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/tests/unit/test_local_catalog_download.py b/tests/unit/test_local_catalog_download.py index 4ae4aa75..0a4e8da3 100644 --- a/tests/unit/test_local_catalog_download.py +++ b/tests/unit/test_local_catalog_download.py @@ -8,7 +8,10 @@ class _Response(io.BytesIO): - headers = {"Content-Length": "11"} + def __init__(self, content: bytes, *, status: int = 200) -> None: + super().__init__(content) + self.status = status + self.headers = {"Content-Length": str(len(content))} def __enter__(self) -> _Response: return self @@ -48,3 +51,63 @@ def test_download_verifies_hash_and_moves_to_models(tmp_path: Path, monkeypatch: assert job.status == "completed" assert Path(job.path).read_bytes() == content assert not Path(f"{job.path}.part").exists() + + +def test_download_resumes_existing_partial_file(tmp_path: Path, monkeypatch: object) -> None: + content = b"hello gguf!" + prefix = b"hello " + entry = { + "id": "test-model", + "name": "test-model", + "filename": "test.gguf", + "url": "https://example.invalid/test.gguf", + "sha256": hashlib.sha256(content).hexdigest(), + "size": len(content), + } + monkeypatch.setenv("FREEOS_HOME", str(tmp_path)) # type: ignore[attr-defined] + monkeypatch.setattr(local_download, "catalog_entry", lambda _model_id: dict(entry)) # type: ignore[attr-defined] + partial = tmp_path / "models" / "test.gguf.part" + partial.parent.mkdir(parents=True) + partial.write_bytes(prefix) + seen_range: list[str | None] = [] + + def open_range(request: object, **_kwargs: object) -> _Response: + seen_range.append(getattr(request, "headers", {}).get("Range")) + return _Response(content[len(prefix) :], status=206) + + monkeypatch.setattr(local_download.urllib.request, "urlopen", open_range) # type: ignore[attr-defined] + job = local_download.DownloadJob(job_id="resume", catalog_id="test-model") + + local_download._run(job) + + assert seen_range == [f"bytes={len(prefix)}-"] + assert job.status == "completed" + assert Path(job.path).read_bytes() == content + + +def test_download_job_survives_process_memory_reset(tmp_path: Path, monkeypatch: object) -> None: + entry = { + "id": "test-model", + "name": "test-model", + "filename": "test.gguf", + "url": "https://example.invalid/test.gguf", + "sha256": "unused", + "size": 12, + } + monkeypatch.setenv("FREEOS_HOME", str(tmp_path)) # type: ignore[attr-defined] + monkeypatch.setattr(local_download, "catalog_entry", lambda _model_id: dict(entry)) # type: ignore[attr-defined] + job = local_download.DownloadJob( + job_id="persisted", catalog_id="test-model", status="running", downloaded_bytes=5 + ) + job.path = str(tmp_path / "models" / "test.gguf") + Path(f"{job.path}.part").parent.mkdir(parents=True) + Path(f"{job.path}.part").write_bytes(b"12345") + local_download._save_job(job) + local_download._jobs.clear() + + restored = local_download.get_download_job("persisted") + + assert restored is not None + assert restored.status == "interrupted" + assert restored.downloaded_bytes == 5 + assert restored.snapshot()["resumable"] is True From f14e01911ffc349d0be48204c171f6578102cda8 Mon Sep 17 00:00:00 2001 From: XYAIStudio Date: Tue, 29 Sep 2026 15:50:58 +0800 Subject: [PATCH 7/8] style: format local model integration --- src/octop/api/routers/local_models.py | 16 ++++++++-------- src/octop/infra/server.py | 12 ++++++------ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/octop/api/routers/local_models.py b/src/octop/api/routers/local_models.py index c2eb08fa..342f29d7 100644 --- a/src/octop/api/routers/local_models.py +++ b/src/octop/api/routers/local_models.py @@ -36,7 +36,7 @@ from octop.infra.agents.providers.local_download import ( cancel_download_job, get_download_job, - list_download_jobs, + list_download_jobs, start_download_job, ) from octop.infra.agents.providers.local_probe import probe_local_models @@ -359,13 +359,13 @@ async def local_models_download_start( raise OctopError(ErrorCode.NOT_FOUND, str(exc)) from exc -@router.get("/downloads", summary="List recent GGUF download tasks") -async def local_models_download_list( - _: Any = Depends(require_permission("ollama_models")), -) -> list[dict[str, Any]]: - return [job.snapshot() for job in list_download_jobs()] - - +@router.get("/downloads", summary="List recent GGUF download tasks") +async def local_models_download_list( + _: Any = Depends(require_permission("ollama_models")), +) -> list[dict[str, Any]]: + return [job.snapshot() for job in list_download_jobs()] + + @router.get("/downloads/{job_id}", summary="Poll a catalog model download") async def local_models_download_status( job_id: str, diff --git a/src/octop/infra/server.py b/src/octop/infra/server.py index 982c4fe2..85844edd 100644 --- a/src/octop/infra/server.py +++ b/src/octop/infra/server.py @@ -2,7 +2,7 @@ from __future__ import annotations -import asyncio +import asyncio import gzip import logging import os @@ -510,11 +510,11 @@ async def _boot_runtime(self, config: OctopConfig) -> None: trajectory_service=trajectory_service, history_archive=history_archive, ) - from octop.infra.agents.providers.llamacpp_runtime import restore # noqa: PLC0415 - - restored = await asyncio.to_thread(restore) - if restored is not None and not restored.get("ok"): - logger.warning("llama.cpp sidecar restore failed: %s", restored.get("error", "")) + from octop.infra.agents.providers.llamacpp_runtime import restore # noqa: PLC0415 + + restored = await asyncio.to_thread(restore) + if restored is not None and not restored.get("ok"): + logger.warning("llama.cpp sidecar restore failed: %s", restored.get("error", "")) from octop.infra.knowledge.jobs import resume_pending_index_jobs # noqa: PLC0415 resume_pending_index_jobs(self.services) From ee365f5eb9d2725fce49bfff666bce5260dd4108 Mon Sep 17 00:00:00 2001 From: XYAIStudio Date: Tue, 29 Sep 2026 17:19:21 +0800 Subject: [PATCH 8/8] chore(release): prepare 0.0.7 --- CHANGELOG.md | 2 ++ docs/install-0.0.7.zh-CN.md | 2 +- pyproject.toml | 2 +- src/octop/__init__.py | 2 +- uv.lock | 2 +- 5 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63796141..900f4bdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ ## [Unreleased] +## [0.0.7] - 2026-09-29 + ### 新增 - 面向未安装 Ollama、也没有 GGUF 模型的新用户,模型页提供经过固定版本与 SHA-256 校验的 GGUF 模型目录、一键下载、实时进度、自动启动内置 llama.cpp、首次测速并设为默认模型。 diff --git a/docs/install-0.0.7.zh-CN.md b/docs/install-0.0.7.zh-CN.md index 1a70f27d..7f1e4dee 100644 --- a/docs/install-0.0.7.zh-CN.md +++ b/docs/install-0.0.7.zh-CN.md @@ -1,6 +1,6 @@ # FreeOS 0.0.7 安装与本机模型指南 -0.0.7 仍应只从 FreeOS 的 [GitHub Releases](https://github.com/XYAIStudio/FreeOS/releases) 下载。发布完成前,不要把开发分支构建当作正式安装包。 +0.0.7 应只从 FreeOS 的 [GitHub Release v0.0.7](https://github.com/XYAIStudio/FreeOS/releases/tag/v0.0.7) 下载,不要把开发分支构建当作正式安装包。 ## Windows diff --git a/pyproject.toml b/pyproject.toml index 2b226f42..36a20594 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "octop" -version = "0.0.6" +version = "0.0.7" description = "FreeOS — self-hosted multi-user multi-agent assistant with an optional organization OS module (Octop-compatible package name)" readme = "README.md" license = { text = "MIT" } diff --git a/src/octop/__init__.py b/src/octop/__init__.py index 51428754..ac8e926d 100644 --- a/src/octop/__init__.py +++ b/src/octop/__init__.py @@ -2,4 +2,4 @@ from __future__ import annotations -__version__ = "0.0.6" +__version__ = "0.0.7" diff --git a/uv.lock b/uv.lock index 0e814928..908f0f6b 100644 --- a/uv.lock +++ b/uv.lock @@ -2503,7 +2503,7 @@ wheels = [ [[package]] name = "octop" -version = "0.0.6" +version = "0.0.7" source = { editable = "." } dependencies = [ { name = "acme" },