HelloSun commited on
Commit
bd5fcd3
·
verified ·
1 Parent(s): b205cb6

Upload installpi.sh

Browse files
Files changed (1) hide show
  1. installpi.sh +642 -0
installpi.sh ADDED
@@ -0,0 +1,642 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # ==============================================================================
5
+ # installpi.sh - 一鍵全自動安裝 pi + llama.cpp + Qwen3.6 35B A3B MTP 模型
6
+ # 特性:無 sudo、無互動、全自動、可斷點續傳、安裝後自動驗證
7
+ # 模型:unsloth/Qwen3.6-35B-A3B-MTP-GGUF (優先 UD-Q4_K_M, 啟用 draft-mtp 加速)
8
+ # 參考: https://huggingface.co/unsloth/Qwen3.6-35B-A3B-MTP-GGUF
9
+ # ==============================================================================
10
+
11
+ HOME_DIR="$HOME"
12
+ LLAMA_DIR="$HOME_DIR/llama-cpp"
13
+ LLAMA_BIN="$LLAMA_DIR/bin"
14
+ MODEL_DIR="$HOME_DIR/models"
15
+ PI_MODELS_JSON="$HOME_DIR/.pi/agent/models.json"
16
+ LLAMA_PORT=8081
17
+ REPO="unsloth/Qwen3.6-35B-A3B-MTP-GGUF"
18
+ MODEL_ID="qwen3.6-35b-a3b"
19
+ PROVIDER_ID="llama-cpp-local"
20
+
21
+ # 基礎輸出函數
22
+ info() { echo -e "\033[36m[INFO]\033[0m $*"; }
23
+ ok() { echo -e "\033[32m[OK]\033[0m $*"; }
24
+ warn() { echo -e "\033[33m[WARN]\033[0m $*" >&2; }
25
+ error() { echo -e "\033[31m[ERROR]\033[0m $*" >&2; exit 1; }
26
+
27
+ # 確保必要目錄
28
+ mkdir -p "$LLAMA_BIN" "$MODEL_DIR" "$(dirname "$PI_MODELS_JSON")" "$HOME_DIR/.local/bin"
29
+
30
+ # 檢查必要工具
31
+ for cmd in curl python3 tar; do
32
+ command -v "$cmd" >/dev/null 2>&1 || error "缺少必要工具: $cmd,請先安裝"
33
+ done
34
+
35
+ # ==============================================================================
36
+ # 0. 確保 Node.js >=22.19.0 (pi 依賴,避免 pi 安裝腳本進入互動模式)
37
+ # ==============================================================================
38
+ ensure_node() {
39
+ if command -v node >/dev/null 2>&1; then
40
+ if node -e 'const [maj,min,patch]=process.versions.node.split(".").map(Number); process.exit(maj>22||(maj===22&&(min>19||(min===19&&patch>=0)))?0:1)' >/dev/null 2>&1; then
41
+ ok "Node.js 已滿足: $(node --version)"
42
+ return 0
43
+ else
44
+ warn "Node.js 版本過舊: $(node --version),將自動安裝新版..."
45
+ fi
46
+ else
47
+ info "未檢測到 Node.js,將自動安裝..."
48
+ fi
49
+
50
+ # 採用 standalone 方式安裝 Node 22.x 到 ~/.local/share/pi-node (無 sudo)
51
+ info "自動安裝 Node.js 22.x (standalone 模式)..."
52
+ NODE_DIST_BASE="https://nodejs.org/dist/latest-v22.x"
53
+ NODE_BASE_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/pi-node"
54
+ NODE_TMP_DIR="/tmp/pi-node-$$"
55
+ mkdir -p "$NODE_TMP_DIR" "$NODE_BASE_DIR"
56
+
57
+ # 判斷平台 / 架構
58
+ OS="$(uname -s)"
59
+ ARCH="$(uname -m)"
60
+ case "$OS" in Linux) PLATFORM="linux" ;; Darwin) PLATFORM="darwin" ;; *) error "不支援的 OS: $OS" ;; esac
61
+ case "$ARCH" in x86_64|amd64) NODE_ARCH="x64" ;; aarch64|arm64) NODE_ARCH="arm64" ;; *) error "不支援的架構: $ARCH" ;; esac
62
+
63
+ info "解析 Node.js 下載連結 ($PLATFORM-$NODE_ARCH)..."
64
+ curl -fsSL --retry 3 --retry-delay 5 "$NODE_DIST_BASE/SHASUMS256.txt" -o "$NODE_TMP_DIR/SHASUMS256.txt" || error "下載 SHASUMS 失敗"
65
+
66
+ NODE_FILE=$(awk -v suffix="-$PLATFORM-$NODE_ARCH.tar.xz" 'index($2,"node-v")==1 && substr($2,length($2)-length(suffix)+1)==suffix{print $2; exit}' "$NODE_TMP_DIR/SHASUMS256.txt")
67
+ [[ -n "$NODE_FILE" ]] || error "找不到對應平台的 Node.js 二進位"
68
+
69
+ info "下載 Node.js $NODE_FILE ..."
70
+ curl -fsSL --retry 3 --retry-delay 5 "$NODE_DIST_BASE/$NODE_FILE" -o "$NODE_TMP_DIR/$NODE_FILE" || error "Node.js 下載失敗"
71
+
72
+ # 校驗
73
+ if command -v sha256sum >/dev/null 2>&1; then
74
+ (cd "$NODE_TMP_DIR" && grep -F "$NODE_FILE" SHASUMS256.txt > SHASUMS256.selected && sha256sum -c SHASUMS256.selected) || warn "SHA256 校驗失敗,繼續安裝..."
75
+ fi
76
+
77
+ # 需要 xz 解壓
78
+ if ! command -v xz >/dev/null 2>&1; then
79
+ warn "未找到 xz,將嘗試使用 tar 自動解壓 (若失敗請手動安裝 xz-utils)"
80
+ fi
81
+
82
+ NODE_EXTRACT_DIR="$NODE_BASE_DIR/${NODE_FILE%.tar.xz}"
83
+ rm -rf "$NODE_EXTRACT_DIR"
84
+ tar -xf "$NODE_TMP_DIR/$NODE_FILE" -C "$NODE_BASE_DIR" || error "解壓 Node.js 失敗"
85
+ rm -f "$NODE_BASE_DIR/current"
86
+ ln -s "$NODE_EXTRACT_DIR" "$NODE_BASE_DIR/current"
87
+ rm -rf "$NODE_TMP_DIR"
88
+
89
+ export PATH="$NODE_BASE_DIR/current/bin:$PATH"
90
+ # 寫入 bashrc / zshrc
91
+ for rc in "$HOME/.bashrc" "$HOME/.zshrc" "$HOME/.profile"; do
92
+ if [[ -f "$rc" ]] || [[ "$rc" == "$HOME/.bashrc" ]]; then
93
+ grep -q "pi-node/current/bin" "$rc" 2>/dev/null || echo "export PATH=\"$NODE_BASE_DIR/current/bin:\$PATH\"" >> "$rc"
94
+ fi
95
+ done
96
+
97
+ if command -v node >/dev/null 2>&1; then
98
+ ok "Node.js 安裝完成: $(node --version) at $(command -v node)"
99
+ else
100
+ error "Node.js 安裝後仍無法找到,請檢查 PATH"
101
+ fi
102
+ }
103
+ ensure_node
104
+
105
+ # 確保 npm 可用
106
+ command -v npm >/dev/null 2>&1 || error "npm 仍不可用,Node 安裝可能失敗"
107
+ info "npm 版本: $(npm --version)"
108
+
109
+
110
+ # ==============================================================================
111
+ # 1. 下載 llama.cpp 預編譯二進位
112
+ # ==============================================================================
113
+ info "1) 下載 llama.cpp 預編譯二進位檔..."
114
+ LLAMA_TAG="b10545"
115
+ LLAMA_URL="https://github.com/ggml-org/llama.cpp/releases/download/${LLAMA_TAG}/llama-${LLAMA_TAG}-bin-ubuntu-x64.tar.gz"
116
+ if [[ ! -x "$LLAMA_BIN/llama-server" ]]; then
117
+ info "下載並解壓 llama.cpp ${LLAMA_TAG}..."
118
+ curl -fL --retry 3 --retry-delay 5 --progress-bar "$LLAMA_URL" -o /tmp/llama.tar.gz || error "llama.cpp 下載失敗"
119
+ tar -xzf /tmp/llama.tar.gz -C /tmp || error "解壓失敗"
120
+ SRC_DIR=$(find /tmp -maxdepth 1 -type d -name "llama-b*" | head -n 1)
121
+ if [[ -n "$SRC_DIR" ]]; then
122
+ cp -a "$SRC_DIR"/llama-* "$LLAMA_BIN/" 2>/dev/null || true
123
+ cp -a "$SRC_DIR"/*.so* "$LLAMA_BIN/" 2>/dev/null || true
124
+ chmod +x "$LLAMA_BIN"/llama-server "$LLAMA_BIN"/llama-cli 2>/dev/null || true
125
+ rm -rf /tmp/llama.tar.gz "$SRC_DIR"
126
+ [[ -x "$LLAMA_BIN/llama-server" ]] || error "llama-server 安裝失敗"
127
+ ok "llama.cpp 安裝完成"
128
+ else
129
+ error "解壓 llama.cpp 失敗,找不到解壓目錄"
130
+ fi
131
+ else
132
+ ok "llama.cpp 已存在,跳過下載"
133
+ fi
134
+
135
+ # 更新 PATH
136
+ export PATH="$LLAMA_BIN:$HOME_DIR/.local/bin:$PATH"
137
+ export LD_LIBRARY_PATH="$LLAMA_BIN:${LD_LIBRARY_PATH:-}"
138
+ # 冪等寫入 rc
139
+ for rc in "$HOME/.bashrc" "$HOME/.zshrc"; do
140
+ touch "$rc" 2>/dev/null || true
141
+ grep -q "llama-cpp/bin" "$rc" 2>/dev/null || echo "export PATH=\"$LLAMA_BIN:\$PATH\"" >> "$rc"
142
+ grep -q "LD_LIBRARY_PATH.*llama-cpp" "$rc" 2>/dev/null || echo "export LD_LIBRARY_PATH=\"$LLAMA_BIN:\$LD_LIBRARY_PATH\"" >> "$rc"
143
+ done
144
+
145
+ # ==============================================================================
146
+ # 2. 使用 curl 下載模型 (unsloth/Qwen3.6-35B-A3B-MTP-GGUF)
147
+ # ==============================================================================
148
+ info "2) 使用 curl 下載模型 ${REPO} (支援斷點續傳)..."
149
+
150
+ # 使用 Python 獲取 gguf 清單,明確優先順序:針對 Qwen3.6-35B-A3B-MTP 優先 UD-Q4_K_M / UD-Q4_K_XL
151
+ info "正在獲取模型檔案列表..."
152
+ FILE_LIST=$(python3 -c "
153
+ import urllib.request, json, sys
154
+ repo = '${REPO}'
155
+ url = f'https://huggingface.co/api/models/{repo}/tree/main'
156
+ try:
157
+ with urllib.request.urlopen(url, timeout=30) as resp:
158
+ data = json.load(resp)
159
+ files = [item['path'] for item in data if item.get('type')=='file' and item['path'].endswith('.gguf')]
160
+ # 排除 mmproj (視覺投影) - Qwen3.6 文本模型不需要,若需要可另外處理
161
+ # 優先順序:UD-Q4_K_M 為 app.py 預設,其次 UD-Q4_K_XL,再其他
162
+ def score(f):
163
+ fl = f.lower()
164
+ if 'ud-q4_k_m' in fl: return 0
165
+ if 'ud-q4_k_xl' in fl: return 1
166
+ if 'q4_k_m' in fl: return 2
167
+ if 'q4_k_xl' in fl: return 3
168
+ if 'q4' in fl: return 4
169
+ if 'q5' in fl or 'q6' in fl or 'q8' in fl: return 5
170
+ if 'q3' in fl or 'iq' in fl or 'q2' in fl: return 6
171
+ return 10
172
+ # 對於 MTP 倉庫,主模型本身已含 MTP heads,無需單獨 mtp 檔案;僅排除 mmproj
173
+ # 早期 gemma 倉庫有獨立 mtp 檔,保留相容邏輯但對 Qwen3.6 MTP 無獨立檔
174
+ main_candidates = [f for f in files if 'mmproj' not in f.lower()]
175
+ # 對 MTP 倉庫:優先選含 MTP 的同名檔案其實就是主模型;若倉庫名含 MTP,全部主模型皆為 MTP 版本
176
+ is_mtp_repo = 'mtp' in repo.lower()
177
+ if not is_mtp_repo:
178
+ main_candidates = [f for f in main_candidates if 'mtp' not in f.lower()]
179
+ main_candidates.sort(key=score)
180
+ main = main_candidates[0] if main_candidates else ''
181
+ # mtp 單獨檔 (僅非 MTP 倉庫需要)
182
+ mtp = ''
183
+ if not is_mtp_repo:
184
+ mtp = next((f for f in files if 'mtp' in f.lower()), '')
185
+ # 若是 MTP 倉庫,標記主模型即為 MTP 版本
186
+ mtp_marker = 'MTP integrated' if is_mtp_repo else mtp
187
+ print(f'MAIN:{main}')
188
+ print(f'MTP:{mtp}')
189
+ print(f'MTP_MARKER:{mtp_marker}')
190
+ print(f'ALL:{ \",\".join(files)}', file=sys.stderr)
191
+ except Exception as e:
192
+ print(f'ERROR:{e}')
193
+ sys.exit(1)
194
+ " 2>/tmp/hf_file_list.log)
195
+
196
+ echo "$FILE_LIST"
197
+ cat /tmp/hf_file_list.log >&2 || true
198
+
199
+ MAIN_FILE=$(echo "$FILE_LIST" | grep "^MAIN:" | cut -d: -f2- | xargs)
200
+ MTP_FILE=$(echo "$FILE_LIST" | grep "^MTP:" | cut -d: -f2- | xargs)
201
+
202
+ if [[ -z "$MAIN_FILE" ]]; then
203
+ error "無法獲取主模型檔案,請檢查網路或 Repo: $REPO (詳見 /tmp/hf_file_list.log)"
204
+ fi
205
+
206
+ info "找到主模型: $MAIN_FILE"
207
+ DOWNLOAD_URL="https://huggingface.co/${REPO}/resolve/main/${MAIN_FILE}"
208
+
209
+ if [[ ! -f "$MODEL_DIR/$MAIN_FILE" ]]; then
210
+ info "開始下載主模型 (支援斷點續傳,若中斷可重新執行此腳本)..."
211
+ # -C - 斷點續傳, -L 跟隨重定向, --retry 自動重試
212
+ curl -L -C - --retry 3 --retry-delay 5 --progress-bar "$DOWNLOAD_URL" -o "$MODEL_DIR/$MAIN_FILE" || error "主模型下載失敗"
213
+ ok "主模型下載完成: $MODEL_DIR/$MAIN_FILE"
214
+ else
215
+ # 檢查檔案大小是否合理 (>1GB)
216
+ SIZE=$(stat -c%s "$MODEL_DIR/$MAIN_FILE" 2>/dev/null || stat -f%z "$MODEL_DIR/$MAIN_FILE" 2>/dev/null || echo 0)
217
+ if [[ "$SIZE" -lt 1000000000 ]]; then
218
+ warn "主模型檔案過小 (${SIZE} bytes),可能下載不完整,將嘗試續傳..."
219
+ curl -L -C - --retry 3 --retry-delay 5 --progress-bar "$DOWNLOAD_URL" -o "$MODEL_DIR/$MAIN_FILE" || error "續傳失敗"
220
+ fi
221
+ ok "主模型已存在,跳過下載: $MODEL_DIR/$MAIN_FILE ($SIZE bytes)"
222
+ fi
223
+ MODEL_PATH="$MODEL_DIR/$MAIN_FILE"
224
+
225
+ MTP_PATH=""
226
+ if [[ -n "$MTP_FILE" ]]; then
227
+ info "找到 MTP 加速模型: $MTP_FILE"
228
+ MTP_DOWNLOAD_URL="https://huggingface.co/${REPO}/resolve/main/${MTP_FILE}"
229
+ if [[ ! -f "$MODEL_DIR/$MTP_FILE" ]]; then
230
+ info "開始下載 MTP 模型..."
231
+ curl -L -C - --retry 3 --retry-delay 5 --progress-bar "$MTP_DOWNLOAD_URL" -o "$MODEL_DIR/$MTP_FILE" || warn "MTP 模型下載失敗,將僅使用主模型"
232
+ else
233
+ ok "MTP 模型已存在,跳過下載"
234
+ fi
235
+ if [[ -f "$MODEL_DIR/$MTP_FILE" ]]; then
236
+ MTP_PATH="$MODEL_DIR/$MTP_FILE"
237
+ fi
238
+ else
239
+ info "未找到 MTP 模型,將僅使用主模型"
240
+ fi
241
+
242
+ # ==============================================================================
243
+ # 3. 啟動 llama-server
244
+ # ==============================================================================
245
+ info "3) 啟動 llama-server (port $LLAMA_PORT)..."
246
+ # 清理舊進程
247
+ if [[ -f "$HOME_DIR/llama-server.pid" ]]; then
248
+ OLD_PID=$(cat "$HOME_DIR/llama-server.pid" 2>/dev/null || echo "")
249
+ if [[ -n "$OLD_PID" ]] && kill -0 "$OLD_PID" 2>/dev/null; then
250
+ info "停止舊 llama-server (PID $OLD_PID)..."
251
+ kill "$OLD_PID" 2>/dev/null || true
252
+ sleep 2
253
+ kill -9 "$OLD_PID" 2>/dev/null || true
254
+ fi
255
+ fi
256
+ pkill -f "llama-server.*${LLAMA_PORT}" 2>/dev/null || true
257
+ sleep 1
258
+
259
+ THREADS=$(nproc 2>/dev/null || echo 8)
260
+ [[ "$THREADS" -gt 16 ]] && THREADS=16
261
+
262
+ CMD=("$LLAMA_BIN/llama-server"
263
+ -m "$MODEL_PATH"
264
+ --host 0.0.0.0
265
+ --port "$LLAMA_PORT"
266
+ -c 8192
267
+ -t "$THREADS"
268
+ --flash-attn on
269
+ )
270
+
271
+ # MTP 加速:若為 MTP 倉庫 (unsloth/*-MTP-GGUF) 且 llama-server 支援 spec-type,則啟用 draft-mtp
272
+ # Qwen3.6-35B-A3B-MTP 官方建議 --spec-draft-n-max 2 , Qwen3.5-4B-MTP 為 6
273
+ IS_MTP_REPO=0
274
+ if [[ "$REPO" == *"-MTP-"* ]] || [[ "$REPO" == *"-MTP"* ]]; then
275
+ IS_MTP_REPO=1
276
+ fi
277
+ if [[ "$IS_MTP_REPO" -eq 1 ]] && "$LLAMA_BIN/llama-server" --help 2>&1 | grep -q "spec-type"; then
278
+ # 根據模型大小選擇 n-max:35B 用 2,4B 用 6,向後兼容
279
+ SPEC_N_MAX=2
280
+ if [[ "$MODEL_PATH" == *"4B"* ]]; then SPEC_N_MAX=6; fi
281
+ if [[ -n "$MTP_PATH" ]]; then
282
+ # 舊 gemma 有獨立 mtp 檔,需 --model-draft
283
+ CMD+=(--spec-type draft-mtp --spec-draft-n-max "$SPEC_N_MAX" --model-draft "$MTP_PATH")
284
+ else
285
+ # Qwen3.6 MTP 為單檔整合 MTP heads,直接啟用
286
+ CMD+=(--spec-type draft-mtp --spec-draft-n-max "$SPEC_N_MAX")
287
+ fi
288
+ info "✅ 已啟用 MTP speculative decoding 加速 (--spec-type draft-mtp --spec-draft-n-max $SPEC_N_MAX)"
289
+ elif [[ -n "$MTP_PATH" ]]; then
290
+ info "MTP 模型已下載至 $MTP_PATH (預設未啟用,如需加速可手動啟用 --spec-type draft-mtp)"
291
+ fi
292
+
293
+ info "啟動命令: ${CMD[*]}"
294
+ # 確保 log 可寫
295
+ touch "$HOME_DIR/llama-server.log" 2>/dev/null || true
296
+ nohup "${CMD[@]}" > "$HOME_DIR/llama-server.log" 2>&1 &
297
+ echo $! > "$HOME_DIR/llama-server.pid"
298
+ info "llama-server PID: $(cat "$HOME_DIR/llama-server.pid")"
299
+
300
+ # 等待伺服器就緒 (最長 5 分鐘)
301
+ info "等待 llama-server 就緒 (最多 5 分鐘)..."
302
+ READY=0
303
+ for i in {1..60}; do
304
+ if curl -sf "http://127.0.0.1:${LLAMA_PORT}/health" >/dev/null 2>&1; then
305
+ ok "llama-server 已就緒 (http://127.0.0.1:${LLAMA_PORT})"
306
+ READY=1
307
+ break
308
+ fi
309
+ # 也嘗試 /v1/models 作為備用健康檢查
310
+ if curl -sf "http://127.0.0.1:${LLAMA_PORT}/v1/models" >/dev/null 2>&1; then
311
+ ok "llama-server 已就緒 (via /v1/models)"
312
+ READY=1
313
+ break
314
+ fi
315
+ if ! kill -0 $(cat "$HOME_DIR/llama-server.pid" 2>/dev/null || echo 0) 2>/dev/null; then
316
+ error "llama-server 啟動失敗,請查看 ~/llama-server.log (最後 50 行):\n$(tail -n 50 "$HOME_DIR/llama-server.log" 2>/dev/null)"
317
+ fi
318
+ echo -n "."
319
+ sleep 5
320
+ done
321
+ echo ""
322
+ if [[ "$READY" -ne 1 ]]; then
323
+ error "llama-server 在 5 分鐘內未就緒,請查看 ~/llama-server.log"
324
+ fi
325
+
326
+ # 額外確認 OpenAI 相容端點
327
+ if curl -sf "http://127.0.0.1:${LLAMA_PORT}/v1/models" >/dev/null 2>&1; then
328
+ ok "OpenAI API 端點正常"
329
+ else
330
+ warn "OpenAI API 端點未回應,但 health 檢查已通過"
331
+ fi
332
+
333
+ # ==============================================================================
334
+ # 4. 安裝 pi agent (全自動,無互動)
335
+ # ==============================================================================
336
+ info "4) 安裝 pi agent (全自動)..."
337
+
338
+ # 確保 npm prefix 可寫,避免需要 sudo
339
+ NPM_PREFIX=$(npm prefix -g 2>/dev/null || npm config get prefix 2>/dev/null || echo "$HOME/.local")
340
+ info "npm 全域前綴: $NPM_PREFIX"
341
+
342
+ # 如果 pi 已安裝,先檢查版本
343
+ NEED_INSTALL=1
344
+ if command -v pi >/dev/null 2>&1; then
345
+ ok "pi 已安裝: $(pi --version 2>&1 | head -n 1)"
346
+ # 嘗試更新到最新 (非強制)
347
+ NEED_INSTALL=0
348
+ # 若明確需要重裝,可設定 PI_FORCE_REINSTALL=1
349
+ if [[ "${PI_FORCE_REINSTALL:-0}" == "1" ]]; then
350
+ info "PI_FORCE_REINSTALL=1,強制重新安裝..."
351
+ NEED_INSTALL=1
352
+ fi
353
+ fi
354
+
355
+ if [[ "$NEED_INSTALL" -eq 1 ]]; then
356
+ info "執行 pi 官方安裝腳本 (全自動模式)..."
357
+
358
+ # 關鍵:確保無 TTY 互動,pi 安裝腳本在無 TTY 時會自動選擇 install/reinstall
359
+ # 同時透過環境變數避免任何提示
360
+ # 若 npm prefix 不可寫,腳本會自動使用 ~/.local
361
+
362
+ # 方法1:直接透過 npm 安裝 (最可靠、全自動、無互動)
363
+ # 使用與官方腳本相同的參數
364
+ PI_PACKAGE="@earendil-works/pi-coding-agent"
365
+ # 偵測安裝前綴
366
+ if npm prefix -g 2>/dev/null | xargs test -w 2>/dev/null; then
367
+ INSTALL_PREFIX=""
368
+ else
369
+ INSTALL_PREFIX="$HOME/.local"
370
+ info "全域 prefix 不可寫,將安裝到 $INSTALL_PREFIX"
371
+ fi
372
+
373
+ set +e
374
+ if [[ -n "$INSTALL_PREFIX" ]]; then
375
+ npm install -g --ignore-scripts --no-fund --no-audit --progress=false --prefix "$INSTALL_PREFIX" "$PI_PACKAGE" 2>&1 | tee /tmp/pi-install.log
376
+ NPM_STATUS=${PIPESTATUS[0]}
377
+ else
378
+ npm install -g --ignore-scripts --no-fund --no-audit --progress=false "$PI_PACKAGE" 2>&1 | tee /tmp/pi-install.log
379
+ NPM_STATUS=${PIPESTATUS[0]}
380
+ fi
381
+ set -e
382
+
383
+ if [[ "$NPM_STATUS" -ne 0 ]]; then
384
+ warn "npm 安裝 pi 失敗 (code $NPM_STATUS),嘗試備用方法: curl 官方腳本..."
385
+ # 備用:使用官方腳本,並透過無 TTY 自動安裝
386
+ # 強制無互動:將 stdin 重定向為空,依賴腳本的「無終端時自動安裝」邏輯
387
+ curl -fsSL https://pi.dev/install.sh | bash -s -- 2>&1 | tee -a /tmp/pi-install.log || true
388
+ fi
389
+
390
+ # 再次檢查
391
+ # 更新 PATH 以包含可能的安裝位置
392
+ export PATH="$HOME/.local/bin:$HOME/.pi/bin:$NPM_PREFIX/bin:$PATH"
393
+ if [[ -n "${INSTALL_PREFIX:-}" ]]; then
394
+ export PATH="$INSTALL_PREFIX/bin:$PATH"
395
+ fi
396
+ # 也包含 standalone node 的情況
397
+ NODE_BIN_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/pi-node/current/bin"
398
+ export PATH="$NODE_BIN_DIR:$PATH"
399
+
400
+ hash -r 2>/dev/null || true
401
+ fi
402
+
403
+ # 最終確認 pi 可用
404
+ PI_BIN=$(command -v pi 2>/dev/null || echo "")
405
+ if [[ -z "$PI_BIN" ]]; then
406
+ # 嘗試常見路徑
407
+ for cand in "$HOME/.local/bin/pi" "$HOME/.pi/bin/pi" "$NPM_PREFIX/bin/pi" "/usr/local/bin/pi"; do
408
+ if [[ -x "$cand" ]]; then
409
+ PI_BIN="$cand"
410
+ export PATH="$(dirname "$cand"):$PATH"
411
+ break
412
+ fi
413
+ done
414
+ fi
415
+
416
+ if [[ -z "$PI_BIN" ]] || [[ ! -x "$PI_BIN" ]]; then
417
+ error "pi 安裝後仍找不到執行檔,請查看 /tmp/pi-install.log"
418
+ fi
419
+
420
+ ok "pi 可用: $PI_BIN ($(pi --version 2>&1 | head -n 1))"
421
+
422
+ # 確保 PATH 寫入 rc
423
+ for rc in "$HOME/.bashrc" "$HOME/.zshrc" "$HOME/.profile"; do
424
+ touch "$rc" 2>/dev/null || true
425
+ # pi bin 目錄
426
+ PI_BIN_DIR="$(dirname "$PI_BIN")"
427
+ if [[ "$PI_BIN_DIR" != "" ]]; then
428
+ grep -qF "$PI_BIN_DIR" "$rc" 2>/dev/null || echo "export PATH=\"$PI_BIN_DIR:\$PATH\"" >> "$rc"
429
+ fi
430
+ # 也確保 ~/.local/bin 在 PATH
431
+ grep -qF ".local/bin" "$rc" 2>/dev/null || echo "export PATH=\"\$HOME/.local/bin:\$PATH\"" >> "$rc"
432
+ done
433
+
434
+ # ==============================================================================
435
+ # 5. 配置 pi 使用本地模型
436
+ # ==============================================================================
437
+ info "5) 配置 pi models.json ($PI_MODELS_JSON)..."
438
+
439
+ # 確保 pi 設定目錄存在
440
+ mkdir -p "$(dirname "$PI_MODELS_JSON")"
441
+
442
+ python3 - "$PI_MODELS_JSON" "$LLAMA_PORT" "$MODEL_ID" "$PROVIDER_ID" << 'PY'
443
+ import json, os, sys
444
+ path, port, model_id, provider = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
445
+ os.makedirs(os.path.dirname(path), exist_ok=True)
446
+ cfg = {"providers": {}}
447
+ if os.path.exists(path):
448
+ try:
449
+ with open(path) as f:
450
+ loaded = json.load(f)
451
+ if isinstance(loaded, dict):
452
+ cfg = loaded
453
+ if "providers" not in cfg:
454
+ cfg["providers"] = {}
455
+ except Exception as e:
456
+ print(f"[WARN] 無法解析舊 models.json,將覆蓋: {e}")
457
+
458
+ cfg["providers"][provider] = {
459
+ "baseUrl": f"http://127.0.0.1:{port}/v1",
460
+ "api": "openai-completions",
461
+ "apiKey": "not-needed",
462
+ "compat": {"supportsDeveloperRole": True, "supportsReasoningEffort": False},
463
+ "models": [{
464
+ "id": model_id,
465
+ "name": "Qwen3.6 35B A3B MTP (Local)",
466
+ "reasoning": True,
467
+ "input": ["text", "image"],
468
+ "contextWindow": 16384,
469
+ "maxTokens": 8192,
470
+ "cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0}
471
+ }]
472
+ }
473
+ with open(path, "w") as f:
474
+ json.dump(cfg, f, indent=2)
475
+ print(f"[OK] 已寫入 {path}")
476
+ print(json.dumps(cfg, indent=2))
477
+ PY
478
+
479
+ ok "pi 配置完成"
480
+
481
+ # 建立 alias 和 wrapper (冪等)
482
+ WRAPPER="$HOME/.local/bin/pi-local"
483
+ mkdir -p "$(dirname "$WRAPPER")"
484
+ cat > "$WRAPPER" << EOS
485
+ #!/usr/bin/env bash
486
+ exec pi --provider $PROVIDER_ID --model $MODEL_ID "\$@"
487
+ EOS
488
+ chmod +x "$WRAPPER"
489
+ ok "已建立 pi-local wrapper: $WRAPPER"
490
+
491
+ # 寫入 alias (避免重複)
492
+ for rc in "$HOME/.bashrc" "$HOME/.zshrc"; do
493
+ touch "$rc" 2>/dev/null || true
494
+ grep -q "alias pi-local" "$rc" 2>/dev/null || echo "alias pi-local=\"pi --provider $PROVIDER_ID --model $MODEL_ID\"" >> "$rc"
495
+ done
496
+
497
+ # ==============================================================================
498
+ # 6. 安裝後驗證 (必須通過才算安裝成功)
499
+ # ==============================================================================
500
+ info "6) 安裝後驗證..."
501
+
502
+ VERIFICATION_FAILED=0
503
+
504
+ # 6.1 檢查 llama-server
505
+ info "[驗證 1/4] 檢查 llama-server 健康狀態..."
506
+ if curl -sf "http://127.0.0.1:${LLAMA_PORT}/health" >/dev/null 2>&1; then
507
+ ok "llama-server 健康檢查通過"
508
+ elif curl -sf "http://127.0.0.1:${LLAMA_PORT}/v1/models" >/dev/null 2>&1; then
509
+ ok "llama-server (via /v1/models) 健康檢查通過"
510
+ else
511
+ warn "llama-server 健康檢查失敗"
512
+ VERIFICATION_FAILED=1
513
+ fi
514
+
515
+ # 6.2 檢查 OpenAI API 實際推理
516
+ info "[驗證 2/4] 測試 OpenAI 相容 API 推理..."
517
+ API_TEST_RESPONSE=$(curl -sf "http://127.0.0.1:${LLAMA_PORT}/v1/chat/completions" \
518
+ -H "Content-Type: application/json" \
519
+ -d '{
520
+ "model": "'$MODEL_ID'",
521
+ "messages": [{"role": "user", "content": "用一句話介紹你自己,不要超過20字"}],
522
+ "max_tokens": 50,
523
+ "temperature": 0.7
524
+ }' 2>&1 || echo "CURL_FAILED")
525
+
526
+ if echo "$API_TEST_RESPONSE" | grep -q '"choices"' && echo "$API_TEST_RESPONSE" | grep -q '"content"'; then
527
+ ok "OpenAI API 推理測試通過"
528
+ echo "$API_TEST_RESPONSE" | python3 -m json.tool 2>/dev/null | head -n 30 || echo "$API_TEST_RESPONSE" | head -n 20
529
+ elif echo "$API_TEST_RESPONSE" | grep -q "CURL_FAILED"; then
530
+ warn "API 推理 curl 失敗: $API_TEST_RESPONSE"
531
+ VERIFICATION_FAILED=1
532
+ else
533
+ warn "API 推理回應異常,可能是模型仍在載入中..."
534
+ echo "$API_TEST_RESPONSE" | head -n 50 >&2
535
+ # 不立即標記失敗,等待 pi-local 測試
536
+ fi
537
+
538
+ # 6.3 檢查 pi 是否可用
539
+ info "[驗證 3/4] 檢查 pi 指令..."
540
+ if pi --version >/dev/null 2>&1; then
541
+ ok "pi --version: $(pi --version 2>&1 | head -n 1)"
542
+ else
543
+ warn "pi --version 執行失敗"
544
+ VERIFICATION_FAILED=1
545
+ fi
546
+
547
+ if pi --help >/dev/null 2>&1; then
548
+ ok "pi --help 正常"
549
+ fi
550
+
551
+ # 檢查 models.json 是否正確
552
+ if python3 -c "import json; d=json.load(open('$PI_MODELS_JSON')); assert '$PROVIDER_ID' in d.get('providers',{}); assert any(m['id']=='$MODEL_ID' for m in d['providers']['$PROVIDER_ID'].get('models',[]))" 2>/dev/null; then
553
+ ok "pi models.json 配置正確"
554
+ else
555
+ warn "pi models.json 配置檢查失敗"
556
+ cat "$PI_MODELS_JSON" 2>/dev/null || echo "檔案不存在"
557
+ VERIFICATION_FAILED=1
558
+ fi
559
+
560
+ if [[ -x "$WRAPPER" ]]; then
561
+ ok "pi-local wrapper 存在且可執行"
562
+ else
563
+ warn "pi-local wrapper 缺失"
564
+ VERIFICATION_FAILED=1
565
+ fi
566
+
567
+ # 6.4 測試 pi-local 實際可用 (最關鍵)
568
+ info "[驗證 4/4] 測試 pi-local 實際推理 (超時 90 秒)..."
569
+ # 先確保 PATH 包含 pi
570
+ export PATH="$HOME/.local/bin:$HOME/.pi/bin:$(dirname "$PI_BIN"):$PATH"
571
+
572
+ # 使用 timeout 防止卡死,並捕獲輸出
573
+ set +e
574
+ PI_TEST_OUTPUT=$(timeout 90 pi-local -p "用一句話介紹你自己,不超過20字" 2>&1)
575
+ PI_EXIT_CODE=$?
576
+ set -e
577
+
578
+ if [[ $PI_EXIT_CODE -eq 0 ]] && [[ -n "$PI_TEST_OUTPUT" ]]; then
579
+ ok "pi-local 推理測試通過 (exit 0)"
580
+ echo "--- pi-local 輸出 ---"
581
+ echo "$PI_TEST_OUTPUT" | head -n 30
582
+ echo "---------------------"
583
+ elif [[ $PI_EXIT_CODE -eq 124 ]]; then
584
+ warn "pi-local 測試超時 (90s),可能模型載入較慢或首次啟動較久。請稍後手動重試: pi-local -p \"你好\""
585
+ # 不算致命失敗,因為 API 已通過
586
+ else
587
+ warn "pi-local 測試失敗 (exit $PI_EXIT_CODE),輸出:"
588
+ echo "$PI_TEST_OUTPUT" | head -n 100 >&2
589
+ # 如果 API 測試已通過,pi-local 失敗可能是設定問題,標記警告而非致命
590
+ if [[ "$VERIFICATION_FAILED" -eq 0 ]]; then
591
+ # 嘗試直接用 pi 而非 wrapper 再測一次
592
+ info "嘗試直接執行 pi 指令..."
593
+ set +e
594
+ PI_DIRECT_OUTPUT=$(timeout 90 pi --provider "$PROVIDER_ID" --model "$MODEL_ID" -p "你好" 2>&1)
595
+ PI_DIRECT_CODE=$?
596
+ set -e
597
+ if [[ $PI_DIRECT_CODE -eq 0 ]]; then
598
+ ok "pi 直接呼叫測試通過"
599
+ else
600
+ warn "pi 直接呼叫也失敗 (exit $PI_DIRECT_CODE): $PI_DIRECT_OUTPUT"
601
+ VERIFICATION_FAILED=1
602
+ fi
603
+ else
604
+ VERIFICATION_FAILED=1
605
+ fi
606
+ fi
607
+
608
+ # ==============================================================================
609
+ # 總結
610
+ # ==============================================================================
611
+ echo ""
612
+ echo "================================================================"
613
+ if [[ "$VERIFICATION_FAILED" -eq 0 ]]; then
614
+ echo -e "\033[32m✅ 安裝完成且驗證通過!\033[0m"
615
+ else
616
+ echo -e "\033[33m⚠️ 安裝完成,但部分驗證未通過,請查看上方警告\033[0m"
617
+ fi
618
+ echo "================================================================"
619
+ echo "- 模型: $REPO"
620
+ echo " 主模型: $MODEL_PATH"
621
+ if [[ -n "$MTP_PATH" ]]; then echo " MTP 模型: $MTP_PATH"; fi
622
+ echo "- llama-server: http://127.0.0.1:$LLAMA_PORT (log: ~/llama-server.log, pid: ~/llama-server.pid)"
623
+ echo "- pi: $PI_BIN ($(pi --version 2>&1 | head -n 1))"
624
+ echo "- pi 配置: $PI_MODELS_JSON (provider: $PROVIDER_ID, model: $MODEL_ID)"
625
+ echo "- 快速使用:"
626
+ echo " source ~/.bashrc # 或 source ~/.zshrc"
627
+ echo " pi-local -p \"你好\""
628
+ echo " pi --provider $PROVIDER_ID --model $MODEL_ID -p \"你好\""
629
+ echo "- 停止伺服器: kill \$(cat ~/llama-server.pid)"
630
+ echo "- 重啟伺服器: bash ~/llama-cpp/bin/ # 或重新執行本腳本"
631
+ echo "- 重新驗證: curl http://127.0.0.1:$LLAMA_PORT/v1/models && pi-local -p \"測試\""
632
+ echo "================================================================"
633
+
634
+ if [[ "$VERIFICATION_FAILED" -ne 0 ]]; then
635
+ echo "請檢查日誌:"
636
+ echo " - llama-server: ~/llama-server.log (tail -n 100 ~/llama-server.log)"
637
+ echo " - pi 安裝: /tmp/pi-install.log"
638
+ echo " - 模型: $MODEL_DIR/"
639
+ exit 1
640
+ fi
641
+
642
+ exit 0