u1s1-cli 0.6.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,104 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { agentDir, MODELS, PROVIDER_ID } from "./config.js";
4
+ const BRAND_APPEND = `## u1s1
5
+
6
+ 你是 u1s1(有一说一) —— 说人话的 AI 编程搭子,一个面向编程新手的中文 AI 编程助手。用户很可能不熟悉编程术语:
7
+ - 自我介绍或被问「你是谁」时,自称 u1s1(有一说一),不要自称 pi;只有用户明确问底层实现时才提 pi 引擎。
8
+ - 默认用中文回复;代码、命令、报错原文保持英文。
9
+ - 解释问题时说人话,别堆术语;必要时用一句话打比方。
10
+ - 改动前先说明打算做什么,改完用一两句话总结改了哪里。
11
+ - 用户描述模糊时,先猜最可能的意图并确认,不要长篇追问。
12
+ `;
13
+ /** pi auto-appends <agentDir>/APPEND_SYSTEM.md to its system prompt — our branding hook. */
14
+ export function ensureBrandPrompt() {
15
+ mkdirSync(agentDir, { recursive: true });
16
+ const p = join(agentDir, "APPEND_SYSTEM.md");
17
+ if (!existsSync(p) || readFileSync(p, "utf8") !== BRAND_APPEND) {
18
+ writeFileSync(p, BRAND_APPEND);
19
+ }
20
+ }
21
+ /** Defaults that don't overwrite values the user already set. */
22
+ export function ensureDefaultSettings() {
23
+ mkdirSync(agentDir, { recursive: true });
24
+ const p = join(agentDir, "settings.json");
25
+ let settings = {};
26
+ if (existsSync(p)) {
27
+ try {
28
+ settings = JSON.parse(readFileSync(p, "utf8"));
29
+ }
30
+ catch {
31
+ return;
32
+ }
33
+ }
34
+ let changed = false;
35
+ // Pi default is one-at-a-time (one queued message per turn). u1s1 delivers
36
+ // all queued messages together so burst typing isn't split across turns.
37
+ // Only fill in missing keys so an explicit /settings choice still sticks.
38
+ for (const key of ["steeringMode", "followUpMode"]) {
39
+ if (!(key in settings)) {
40
+ settings[key] = "all";
41
+ changed = true;
42
+ }
43
+ }
44
+ if (!("hideThinkingBlock" in settings)) {
45
+ settings["hideThinkingBlock"] = true;
46
+ changed = true;
47
+ }
48
+ // ≤0.4.0 shipped branded themes and forced them as default; the files are
49
+ // gone now, so a settings.json still pointing at them must fall back to
50
+ // pi's default theme.
51
+ if (typeof settings.theme === "string" && settings.theme.includes("u1s1-")) {
52
+ delete settings.theme;
53
+ changed = true;
54
+ }
55
+ if (changed)
56
+ writeFileSync(p, JSON.stringify(settings, null, 2) + "\n");
57
+ }
58
+ /** ≤0.4.0 wrote u1s1-dark/u1s1-light into the pi themes dir; remove them. */
59
+ export function cleanupBrandThemes() {
60
+ for (const name of ["u1s1-dark", "u1s1-light"]) {
61
+ rmSync(join(agentDir, "themes", `${name}.json`), { force: true });
62
+ }
63
+ }
64
+ /**
65
+ * u1s1 provider for SDK-based frontends (`u1s1 web`): pi composes
66
+ * <agentDir>/models.json above registered providers, so this exposes the same
67
+ * models as the TUI's in-process extension without patching the frontend.
68
+ * apiKey stays an env reference — the launcher sets U1S1_API_KEY, the file
69
+ * itself holds no secret. Rewritten on every launch so baseUrl/model changes
70
+ * propagate; other providers a user may have added are preserved.
71
+ */
72
+ export function ensureProviderModels(cfg) {
73
+ mkdirSync(agentDir, { recursive: true });
74
+ const p = join(agentDir, "models.json");
75
+ let root = {};
76
+ if (existsSync(p)) {
77
+ try {
78
+ root = JSON.parse(readFileSync(p, "utf8"));
79
+ }
80
+ catch {
81
+ root = {};
82
+ }
83
+ }
84
+ const providers = typeof root["providers"] === "object" && root["providers"] !== null
85
+ ? root["providers"]
86
+ : {};
87
+ providers[PROVIDER_ID] = {
88
+ name: "u1s1",
89
+ baseUrl: cfg.baseUrl,
90
+ api: "openai-completions",
91
+ apiKey: "$U1S1_API_KEY",
92
+ models: MODELS.map((m) => ({
93
+ id: m.id,
94
+ name: m.name,
95
+ reasoning: m.reasoning,
96
+ input: ["text"],
97
+ cost: m.cost,
98
+ contextWindow: m.contextWindow,
99
+ maxTokens: m.maxTokens,
100
+ })),
101
+ };
102
+ root["providers"] = providers;
103
+ writeFileSync(p, JSON.stringify(root, null, 2) + "\n");
104
+ }
package/dist/api.js CHANGED
@@ -1,3 +1,20 @@
1
+ export async function fetchModels(cfg) {
2
+ let resp;
3
+ try {
4
+ resp = await fetch(`${cfg.baseUrl}/models`, {
5
+ headers: { authorization: `Bearer ${cfg.apiKey}` },
6
+ });
7
+ }
8
+ catch {
9
+ throw new Error(`连不上 ${cfg.baseUrl},检查一下网络?`);
10
+ }
11
+ if (resp.status === 401)
12
+ throw new Error("这把 Key 不对或已失效,去 https://u1s1.io/dashboard 看看");
13
+ if (!resp.ok)
14
+ throw new Error(`服务端返回 ${resp.status},稍后再试`);
15
+ const body = (await resp.json());
16
+ return body.data;
17
+ }
1
18
  export async function fetchMe(cfg) {
2
19
  if (!cfg.apiKey)
3
20
  throw new Error("没有配置 API Key");
package/dist/config.js CHANGED
@@ -3,6 +3,34 @@ import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  export const DEFAULT_BASE_URL = "https://api.u1s1.io/v1";
5
5
  export const PROVIDER_ID = "u1s1";
6
+ /** Make a short alias from a model id by stripping common prefixes. */
7
+ function defaultAliases(id) {
8
+ const short = id.replace(/^[^/]+\./, "").replace(/^[^/]+\//, "");
9
+ const parts = short.split(/[-_.:]/);
10
+ const aliases = [short, ...parts.filter((p) => p.length > 1)];
11
+ return [...new Set(aliases)];
12
+ }
13
+ /**
14
+ * Convert an API model response to our internal ModelDef.
15
+ * Aliases are derived from the short id; note is left empty (filled by /model command).
16
+ */
17
+ export function apiModelToDef(m) {
18
+ return {
19
+ id: m.id,
20
+ name: m.name,
21
+ aliases: defaultAliases(m.id),
22
+ reasoning: m.reasoning,
23
+ contextWindow: m.context_length,
24
+ maxTokens: m.max_tokens,
25
+ cost: {
26
+ input: m.price.input,
27
+ output: m.price.output,
28
+ cacheRead: m.price.cache_read ?? 0,
29
+ cacheWrite: 0,
30
+ },
31
+ note: "",
32
+ };
33
+ }
6
34
  export const MODELS = [
7
35
  {
8
36
  id: "deepseek/deepseek-v4-flash",
@@ -25,6 +53,11 @@ export const MODELS = [
25
53
  note: "更强 · 但烧额度快约 20 倍,难题再用",
26
54
  },
27
55
  ];
56
+ /** Replace MODELS with a fresh list fetched from server (e.g. at startup). */
57
+ export function setModelsFromApi(apiModels) {
58
+ MODELS.length = 0;
59
+ MODELS.push(...apiModels);
60
+ }
28
61
  export const DEFAULT_MODEL_ID = MODELS[0].id;
29
62
  export function resolveModel(nameOrAlias) {
30
63
  const q = nameOrAlias.trim().toLowerCase();
package/dist/index.js CHANGED
@@ -1,73 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawnSync } from "node:child_process";
3
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
- import { join } from "node:path";
3
+ import { writeFileSync } from "node:fs";
5
4
  import { createRequire } from "node:module";
5
+ import { cleanupBrandThemes, ensureBrandPrompt, ensureDefaultSettings, ensureProviderModels, } from "./agent-setup.js";
6
6
  import { printConsoleBanner } from "./brand.js";
7
- import { agentDir, loadConfig, MODELS, persistPreferredModel, PROVIDER_ID, resolvePreferredModel, } from "./config.js";
7
+ import { agentDir, apiModelToDef, loadConfig, MODELS, persistPreferredModel, PROVIDER_ID, resolvePreferredModel, setModelsFromApi, } from "./config.js";
8
8
  import { applyBrandUi } from "./style.js";
9
+ import { fetchModels } from "./api.js";
9
10
  const require = createRequire(import.meta.url);
10
11
  const VERSION = require("../package.json").version;
11
- const BRAND_APPEND = `## u1s1
12
-
13
- 你是 u1s1(有一说一) —— 说人话的 AI 编程搭子,一个面向编程新手的中文 AI 编程助手。用户很可能不熟悉编程术语:
14
- - 自我介绍或被问「你是谁」时,自称 u1s1(有一说一),不要自称 pi;只有用户明确问底层实现时才提 pi 引擎。
15
- - 默认用中文回复;代码、命令、报错原文保持英文。
16
- - 解释问题时说人话,别堆术语;必要时用一句话打比方。
17
- - 改动前先说明打算做什么,改完用一两句话总结改了哪里。
18
- - 用户描述模糊时,先猜最可能的意图并确认,不要长篇追问。
19
- `;
20
- /** pi auto-appends <agentDir>/APPEND_SYSTEM.md to its system prompt — our branding hook. */
21
- function ensureBrandPrompt() {
22
- mkdirSync(agentDir, { recursive: true });
23
- const p = join(agentDir, "APPEND_SYSTEM.md");
24
- if (!existsSync(p) || readFileSync(p, "utf8") !== BRAND_APPEND) {
25
- writeFileSync(p, BRAND_APPEND);
26
- }
27
- }
28
- /** Defaults that don't overwrite values the user already set. */
29
- function ensureDefaultSettings() {
30
- mkdirSync(agentDir, { recursive: true });
31
- const p = join(agentDir, "settings.json");
32
- let settings = {};
33
- if (existsSync(p)) {
34
- try {
35
- settings = JSON.parse(readFileSync(p, "utf8"));
36
- }
37
- catch {
38
- return;
39
- }
40
- }
41
- let changed = false;
42
- // Pi default is one-at-a-time (one queued message per turn). u1s1 delivers
43
- // all queued messages together so burst typing isn't split across turns.
44
- // Only fill in missing keys so an explicit /settings choice still sticks.
45
- for (const key of ["steeringMode", "followUpMode"]) {
46
- if (!(key in settings)) {
47
- settings[key] = "all";
48
- changed = true;
49
- }
50
- }
51
- if (!("hideThinkingBlock" in settings)) {
52
- settings["hideThinkingBlock"] = true;
53
- changed = true;
54
- }
55
- // ≤0.4.0 shipped branded themes and forced them as default; the files are
56
- // gone now, so a settings.json still pointing at them must fall back to
57
- // pi's default theme.
58
- if (typeof settings.theme === "string" && settings.theme.includes("u1s1-")) {
59
- delete settings.theme;
60
- changed = true;
61
- }
62
- if (changed)
63
- writeFileSync(p, JSON.stringify(settings, null, 2) + "\n");
64
- }
65
- /** ≤0.4.0 wrote u1s1-dark/u1s1-light into the pi themes dir; remove them. */
66
- function cleanupBrandThemes() {
67
- for (const name of ["u1s1-dark", "u1s1-light"]) {
68
- rmSync(join(agentDir, "themes", `${name}.json`), { force: true });
69
- }
70
- }
71
12
  /**
72
13
  * tmux 默认不转发键盘修饰键,会把 Shift+Enter 当成普通回车发出去,消息没写完就被发送。
73
14
  * 只开 extended-keys 不够:默认 terminal-features 里 xterm* 没有 extkeys,tmux 根本不会
@@ -112,6 +53,15 @@ async function runAgent(cfg, args) {
112
53
  cleanupBrandThemes();
113
54
  ensureBrandPrompt();
114
55
  ensureDefaultSettings();
56
+ // Fetch model list from server; fall back to built-in MODELS on error.
57
+ try {
58
+ const apiModels = await fetchModels(cfg);
59
+ setModelsFromApi(apiModels.map(apiModelToDef));
60
+ }
61
+ catch (e) {
62
+ console.error(" 获取模型列表失败,使用内置列表:", e.message);
63
+ }
64
+ ensureProviderModels(cfg);
115
65
  ensureTmuxKeyboardProtocol();
116
66
  // must be set before pi reads them (getAgentDir() reads at call time, env at import is fine too)
117
67
  process.env["PI_CODING_AGENT_DIR"] = agentDir;
@@ -124,6 +74,22 @@ async function runAgent(cfg, args) {
124
74
  // Shift+Enter,输入框就会换行,真·Alt+Enter(\x1b[13;3u)不受影响。
125
75
  class ShiftEnterEditor extends CustomEditor {
126
76
  handleInput(data) {
77
+ // 拦截 /login 和 /logout,不让 pi 弹出内置供应商列表
78
+ if (data === "\r" || data === "\n") {
79
+ const text = this.getText().trim();
80
+ if (text.startsWith("/login")) {
81
+ console.log(" u1s1 不需要额外登录,直接用 /model 切换模型即可");
82
+ this.setText("");
83
+ this.addToHistory?.(text);
84
+ return;
85
+ }
86
+ if (text === "/logout") {
87
+ console.log(" u1s1 统一使用同一个 API Key,不需要单独登出");
88
+ this.setText("");
89
+ this.addToHistory?.(text);
90
+ return;
91
+ }
92
+ }
127
93
  super.handleInput(data === "\x1b\r" ? "\x1b[13;2u" : data);
128
94
  }
129
95
  }
@@ -195,6 +161,21 @@ async function run() {
195
161
  }
196
162
  if (cmd === "--help" || cmd === "-h") {
197
163
  printConsoleBanner(VERSION);
164
+ console.log(" u1s1 命令:web(浏览器网页版)· web shortcut(桌面图标)· login / logout · model · usage · update · import");
165
+ console.log("");
166
+ }
167
+ if (cmd === "web") {
168
+ // shortcut 不需要登录:安装脚本装完立刻调用,不能在这里卡住 device login
169
+ if (args[1] === "shortcut") {
170
+ const { createWebShortcut } = await import("./shortcut.js");
171
+ createWebShortcut();
172
+ return;
173
+ }
174
+ const { ensureAuth } = await import("./login.js");
175
+ const cfg = await ensureAuth();
176
+ const { webCommand } = await import("./web.js");
177
+ await webCommand(cfg, args.slice(1));
178
+ return;
198
179
  }
199
180
  if (cmd === "login") {
200
181
  const { login } = await import("./login.js");
package/dist/login.js CHANGED
@@ -67,6 +67,12 @@ async function pollDeviceLogin(origin, start) {
67
67
  return null;
68
68
  }
69
69
  async function promptForKey() {
70
+ // 桌面图标双击启动时没有可交互的终端,rl.question 会无声挂死;
71
+ // 走到这个兜底说明 device login 不可用(网关太老/断网),只能明确报错。
72
+ if (!process.stdin.isTTY) {
73
+ console.error(" 无法完成浏览器登录(网络问题或网关不可用),请稍后重试。");
74
+ process.exit(1);
75
+ }
70
76
  console.log(" 需要一把 API Key(免费):");
71
77
  console.log(` 1. 打开 ${DASHBOARD_URL} 注册/登录(30 秒,送 $10 额度)`);
72
78
  console.log(" 2. 复制你的 API Key,粘贴到下面");
package/dist/model.js CHANGED
@@ -7,7 +7,12 @@ export async function modelCommand(nameOrAlias) {
7
7
  for (const m of MODELS) {
8
8
  const mark = m.id === current ? "●" : " ";
9
9
  console.log(` ${mark} ${m.aliases[0].padEnd(10)} ${m.name}`);
10
- console.log(` ${m.note} · $${m.cost.input}/$${m.cost.output} 每百万 token`);
10
+ if (m.note) {
11
+ console.log(` ${m.note} · $${m.cost.input}/$${m.cost.output} 每百万 token`);
12
+ }
13
+ else {
14
+ console.log(` $${m.cost.input}/$${m.cost.output} 每百万 token`);
15
+ }
11
16
  }
12
17
  console.log("");
13
18
  console.log(" 切换:u1s1 model grok / u1s1 model deepseek(对话里 /model 同样会记住)");
@@ -0,0 +1,106 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ /**
7
+ * `u1s1 web shortcut` — 在桌面创建「u1s1 网页版」图标,双击即启动网页版。
8
+ * 面向完全不用终端的用户:安装脚本会自动调用,之后的登录(浏览器 device flow)、
9
+ * 聊天全程不需要终端知识。
10
+ *
11
+ * 图标里嵌的是 node 与 CLI 入口的绝对路径 —— GUI 双击环境(macOS .command、
12
+ * Linux .desktop、Windows .lnk)拿不到 npm 全局 bin 的 PATH。
13
+ * 工作目录固定 ~/u1s1(自动创建):给新手一个可预期的「我的项目」目录,
14
+ * 网页里仍可切换;有历史工作区时 UI 会自己恢复上次的。
15
+ */
16
+ const SHORTCUT_NAME = "u1s1 网页版";
17
+ function cliEntry() {
18
+ return fileURLToPath(new URL("./index.js", import.meta.url));
19
+ }
20
+ function defaultWorkspace() {
21
+ const dir = join(homedir(), "u1s1");
22
+ mkdirSync(dir, { recursive: true });
23
+ return dir;
24
+ }
25
+ function linuxDesktopDir() {
26
+ const out = spawnSync("xdg-user-dir", ["DESKTOP"], { encoding: "utf8", timeout: 3000 });
27
+ const dir = out.status === 0 ? out.stdout.trim() : "";
28
+ return dir && dir !== homedir() ? dir : join(homedir(), "Desktop");
29
+ }
30
+ function createLinux(node, entry, cwd) {
31
+ const desktop = `[Desktop Entry]
32
+ Type=Application
33
+ Name=${SHORTCUT_NAME}
34
+ Comment=有一说一 — 说人话的 AI 编程搭子(浏览器版)
35
+ Exec="${node}" "${entry}" web --cwd "${cwd}"
36
+ Terminal=true
37
+ Categories=Development;
38
+ `;
39
+ const appsDir = join(homedir(), ".local", "share", "applications");
40
+ mkdirSync(appsDir, { recursive: true });
41
+ const appFile = join(appsDir, "u1s1-web.desktop");
42
+ writeFileSync(appFile, desktop);
43
+ chmodSync(appFile, 0o755);
44
+ const desktopDir = linuxDesktopDir();
45
+ let placed = appFile;
46
+ if (existsSync(desktopDir)) {
47
+ placed = join(desktopDir, "u1s1-web.desktop");
48
+ writeFileSync(placed, desktop);
49
+ chmodSync(placed, 0o755);
50
+ // GNOME 桌面图标需要 trusted 标记才可双击;失败无妨(应用菜单入口仍可用)
51
+ spawnSync("gio", ["set", placed, "metadata::trusted", "true"], { timeout: 3000 });
52
+ }
53
+ return placed;
54
+ }
55
+ function createMac(node, entry, cwd) {
56
+ const file = join(homedir(), "Desktop", `${SHORTCUT_NAME}.command`);
57
+ writeFileSync(file, `#!/bin/bash
58
+ # 双击启动 u1s1 网页版;关闭这个窗口即停止
59
+ exec "${node}" "${entry}" web --cwd "${cwd}"
60
+ `);
61
+ chmodSync(file, 0o755);
62
+ return file;
63
+ }
64
+ function createWindows(node, entry, cwd) {
65
+ // Desktop 路径交给 .NET 解析(兼容 OneDrive 重定向);.lnk 目标是 node.exe。
66
+ const psQuote = (s) => `'${s.replace(/'/g, "''")}'`;
67
+ const script = [
68
+ `$desktop = [Environment]::GetFolderPath('Desktop')`,
69
+ `$lnk = Join-Path $desktop ${psQuote(`${SHORTCUT_NAME}.lnk`)}`,
70
+ `$ws = New-Object -ComObject WScript.Shell`,
71
+ `$s = $ws.CreateShortcut($lnk)`,
72
+ `$s.TargetPath = ${psQuote(node)}`,
73
+ `$s.Arguments = ('"' + ${psQuote(entry)} + '" web --cwd "' + ${psQuote(cwd)} + '"')`,
74
+ `$s.WorkingDirectory = ${psQuote(cwd)}`,
75
+ `$s.Description = ${psQuote("有一说一 — 说人话的 AI 编程搭子(浏览器版)")}`,
76
+ `$s.Save()`,
77
+ `Write-Output $lnk`,
78
+ ].join("; ");
79
+ const out = spawnSync("powershell", ["-NoProfile", "-NonInteractive", "-Command", script], {
80
+ encoding: "utf8",
81
+ timeout: 20_000,
82
+ });
83
+ if (out.status !== 0) {
84
+ throw new Error(`PowerShell 创建快捷方式失败:${(out.stderr || "").trim().slice(0, 200)}`);
85
+ }
86
+ return out.stdout.trim().split(/\r?\n/).pop() ?? "桌面";
87
+ }
88
+ export function createWebShortcut() {
89
+ const node = process.execPath;
90
+ const entry = cliEntry();
91
+ const cwd = defaultWorkspace();
92
+ try {
93
+ const placed = process.platform === "darwin"
94
+ ? createMac(node, entry, cwd)
95
+ : process.platform === "win32"
96
+ ? createWindows(node, entry, cwd)
97
+ : createLinux(node, entry, cwd);
98
+ console.log(`✓ 已在桌面创建「${SHORTCUT_NAME}」图标:${placed}`);
99
+ console.log(" 双击即可在浏览器里使用 u1s1;项目文件默认放在 ~/u1s1 文件夹。");
100
+ }
101
+ catch (e) {
102
+ console.error(`创建桌面图标失败:${e instanceof Error ? e.message : String(e)}`);
103
+ console.error("可以先手动使用:打开终端输入 u1s1 web");
104
+ process.exitCode = 1;
105
+ }
106
+ }
package/dist/web.js ADDED
@@ -0,0 +1,87 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { mkdirSync } from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import { dirname, join } from "node:path";
5
+ import { cleanupBrandThemes, ensureBrandPrompt, ensureDefaultSettings, ensureProviderModels, } from "./agent-setup.js";
6
+ import { agentDir, apiModelToDef, resolvePreferredModel, setModelsFromApi, u1s1Dir, writeAgentDefaultModel, } from "./config.js";
7
+ import { applyWebUiBranding } from "./webui-brand.js";
8
+ import { fetchModels } from "./api.js";
9
+ const require = createRequire(import.meta.url);
10
+ /**
11
+ * `u1s1 web` — 浏览器网页版。薄包装 pi-web-ui 的服务器:注入我们的 agentDir
12
+ * (品牌 prompt / models.json / 会话与 TUI 共享)和登录 key,其余原样透传
13
+ * (--port / --cwd / --no-browser)。
14
+ */
15
+ export async function webCommand(cfg, args) {
16
+ // pi-web-ui 的 server install/shortcut 生成的服务定义只固化 PORT/CWD,不带
17
+ // PI_CODING_AGENT_DIR 和 U1S1_API_KEY —— 装出来的自启服务是坏的(丢登录态、
18
+ // 会话落到 ~/.pi)。在上游支持透传 env 或我们自己生成服务定义前,直接拦掉。
19
+ if (args[0] === "server") {
20
+ console.error("u1s1 web 暂不支持 server 子命令(开机自启/桌面图标),后续版本会加上。");
21
+ console.error("现在请直接运行 u1s1 web,Ctrl+C 停止。");
22
+ process.exitCode = 1;
23
+ return;
24
+ }
25
+ // pi-web-ui 是 optionalDependency:装 CLI 时它编译/下载失败不会拖垮整个安装,
26
+ // 代价是这里可能不存在 —— 给出人话指引而不是 Cannot find module。
27
+ let bin;
28
+ try {
29
+ bin = require.resolve("pi-web-ui/bin/pi-web-ui.mjs");
30
+ }
31
+ catch {
32
+ console.error("网页版组件(pi-web-ui)没有安装成功,通常是安装时网络不稳或缺少编译工具。");
33
+ console.error("请重新安装试试:npm install -g u1s1-cli");
34
+ console.error("Linux 用户可能还需要:sudo apt install -y build-essential(或对应发行版的 gcc/make)");
35
+ process.exitCode = 1;
36
+ return;
37
+ }
38
+ // 终端面板依赖原生模块 node-pty。Windows/macOS 走官方 prebuilds 一定在;
39
+ // Linux 便携包/无 gcc 环境可能缺编译产物,而服务器是顶层 import —— 缺了会
40
+ // 整个起不来。先探测,坏了就说人话。
41
+ const probe = spawnSync(process.execPath, ["-e", "require(require.resolve('node-pty',{paths:[process.argv[1]]}))", dirname(bin)], { timeout: 10_000 });
42
+ if (probe.status !== 0) {
43
+ console.error("网页版的终端组件(node-pty)在这台机器上没有编译成功,网页版暂时起不来。");
44
+ console.error("Linux 用户先装编译工具再重装:sudo apt install -y build-essential && npm install -g u1s1-cli");
45
+ process.exitCode = 1;
46
+ return;
47
+ }
48
+ cleanupBrandThemes();
49
+ ensureBrandPrompt();
50
+ ensureDefaultSettings();
51
+ // Fetch model list from server; fall back to built-in MODELS on error.
52
+ try {
53
+ const apiModels = await fetchModels(cfg);
54
+ setModelsFromApi(apiModels.map(apiModelToDef));
55
+ }
56
+ catch (e) {
57
+ console.error(" 获取模型列表失败,使用内置列表:", e.message);
58
+ }
59
+ ensureProviderModels(cfg);
60
+ // 网页版新会话从 settings.json 的 defaultModel 取模型(TUI 是每次传 --model),
61
+ // 确保它有值;resolvePreferredModel 优先尊重已有的 in-session 选择,不会回退覆盖。
62
+ writeAgentDefaultModel(resolvePreferredModel(cfg.model));
63
+ const dataDir = join(u1s1Dir, "web");
64
+ mkdirSync(dataDir, { recursive: true });
65
+ applyWebUiBranding(bin);
66
+ console.log("u1s1 网页版启动中… 浏览器会自动打开;关闭请回到这里按 Ctrl+C。");
67
+ const child = spawn(process.execPath, [bin, ...args], {
68
+ stdio: "inherit",
69
+ env: {
70
+ ...process.env,
71
+ PI_CODING_AGENT_DIR: agentDir,
72
+ U1S1_API_KEY: cfg.apiKey,
73
+ PI_WEB_DATA_DIR: dataDir,
74
+ },
75
+ });
76
+ // Ctrl+C 由子进程负责优雅停机(同进程组,信号会直达);父进程只等退出码,
77
+ // 避免父进程先死导致孤儿服务器。
78
+ const ignore = () => { };
79
+ process.on("SIGINT", ignore);
80
+ process.on("SIGTERM", ignore);
81
+ await new Promise((resolve) => {
82
+ child.on("exit", (code) => {
83
+ process.exitCode = code ?? 0;
84
+ resolve();
85
+ });
86
+ });
87
+ }
@@ -0,0 +1,52 @@
1
+ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ /**
4
+ * pi-web-ui 前端产物的启动时品牌补丁。不 fork 上游,只在每次 `u1s1 web` 启动前
5
+ * 改它 web/dist 里的静态文件:重写 <title>、换 favicon、注入一段小脚本把页面
6
+ * chrome 上的 π / pi-web-ui 字样换成 u1s1。
7
+ *
8
+ * - 幂等:index.html 里有版本化标记则跳过;pi-web-ui 升级后文件是新的,自动重打。
9
+ * - 写文件前先 rmSync 断开硬链接 —— pnpm 的 node_modules 是全局 store 的硬链接,
10
+ * 原地写会污染 store;unlink 后新写的是独立 inode,npm 平铺安装则无所谓。
11
+ */
12
+ const PATCH_MARK = "u1s1-brand-v1";
13
+ const PAGE_TITLE = "u1s1 网页版 — 有一说一";
14
+ const FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
15
+ <rect width="64" height="64" rx="14" fill="#101418"/>
16
+ <text x="32" y="42" font-family="ui-monospace,Menlo,Consolas,monospace" font-size="26" font-weight="700" fill="#4ea1ff" text-anchor="middle">u1</text>
17
+ </svg>
18
+ `;
19
+ /** 页面 chrome 品牌替换:改 .brand-logo/.brand-name,盯住 title(React 可能改回去)。 */
20
+ const BRAND_JS = `(function(){
21
+ var NAME = "u1s1", LOGO = "\\u273b"; // ✻,与 TUI 品牌一致
22
+ function apply(){
23
+ if (document.title.indexOf("pi") !== -1 || document.title.indexOf("π") !== -1) document.title = ${JSON.stringify(PAGE_TITLE)};
24
+ var logo = document.querySelector(".brand-logo");
25
+ if (logo && logo.textContent !== LOGO) logo.textContent = LOGO;
26
+ var name = document.querySelector(".brand-name");
27
+ if (name && name.textContent !== NAME) name.textContent = NAME;
28
+ }
29
+ apply();
30
+ new MutationObserver(apply).observe(document.documentElement, { childList: true, subtree: true });
31
+ })();
32
+ `;
33
+ function replaceFile(path, content) {
34
+ rmSync(path, { force: true });
35
+ writeFileSync(path, content);
36
+ }
37
+ /** binPath = <pkg>/bin/pi-web-ui.mjs → 前端产物在 <pkg>/web/dist。 */
38
+ export function applyWebUiBranding(binPath) {
39
+ const dist = join(dirname(binPath), "..", "web", "dist");
40
+ const indexHtml = join(dist, "index.html");
41
+ if (!existsSync(indexHtml))
42
+ return; // 上游布局变了就跳过,页面保持原样也能用
43
+ let html = readFileSync(indexHtml, "utf8");
44
+ if (html.includes(PATCH_MARK))
45
+ return;
46
+ replaceFile(join(dist, "u1s1-brand.js"), BRAND_JS);
47
+ replaceFile(join(dist, "favicon.svg"), FAVICON_SVG);
48
+ html = html
49
+ .replace(/<title>[^<]*<\/title>/, `<title>${PAGE_TITLE}</title>`)
50
+ .replace("</head>", ` <!-- ${PATCH_MARK} -->\n <script defer src="/u1s1-brand.js"></script>\n </head>`);
51
+ replaceFile(indexHtml, html);
52
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "u1s1-cli",
3
- "version": "0.6.0",
3
+ "version": "0.7.2",
4
4
  "description": "u1s1 — 有一说一,最省心的 AI 编程搭子。终端里用中文说需求,AI 帮你读文件、改代码、跑命令。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -34,6 +34,9 @@
34
34
  "@earendil-works/pi-coding-agent": "0.84.2",
35
35
  "@earendil-works/pi-tui": "0.84.2"
36
36
  },
37
+ "optionalDependencies": {
38
+ "pi-web-ui": "0.20.1"
39
+ },
37
40
  "devDependencies": {
38
41
  "@types/node": "^22.15.0",
39
42
  "tsx": "^4.20.0",