u1s1-cli 0.9.1 → 0.9.3
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.
- package/dist/agent-setup.js +25 -1
- package/dist/api.js +10 -5
- package/dist/brand.js +3 -2
- package/dist/config.js +19 -1
- package/dist/index.js +27 -21
- package/dist/style.js +20 -7
- package/dist/tools.js +107 -66
- package/dist/update.js +37 -3
- package/dist/usage.js +32 -5
- package/dist/web.js +9 -3
- package/package.json +1 -1
package/dist/agent-setup.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { agentDir, MODELS, PROVIDER_ID } from "./config.js";
|
|
3
|
+
import { agentDir, MODELS, PROVIDER_ID, VERSION } from "./config.js";
|
|
4
4
|
const BRAND_APPEND = `## u1s1
|
|
5
5
|
|
|
6
6
|
你是 u1s1(有一说一) —— 说人话的 AI 编程搭子,一个面向编程新手的中文 AI 编程助手。用户很可能不熟悉编程术语:
|
|
@@ -84,6 +84,28 @@ export function cleanupBrandThemes() {
|
|
|
84
84
|
* itself holds no secret. Rewritten on every launch so baseUrl/model changes
|
|
85
85
|
* propagate; other providers a user may have added are preserved.
|
|
86
86
|
*/
|
|
87
|
+
/**
|
|
88
|
+
* 联网工具经 <agentDir>/extensions 投影:pi 的资源加载器对 TUI 和 `u1s1 web`
|
|
89
|
+
* 都会扫描这个目录,两个前端从此共用同一份工具注册,不用各接一遍。
|
|
90
|
+
* 文件每次启动重写(baseUrl/服务端开关变化随之生效);apiKey 走 U1S1_API_KEY
|
|
91
|
+
* 环境变量,文件里不落密钥。U1S1_TOOLS_VIA_EXTENSION 守卫:旧版 CLI 仍在
|
|
92
|
+
* 进程内注册工具且不设该变量,残留的本文件在旧版下自动空转,避免双重注册。
|
|
93
|
+
*/
|
|
94
|
+
export function writeWebToolsExtension(cfg, webSearchEnabled) {
|
|
95
|
+
const dir = join(agentDir, "extensions");
|
|
96
|
+
mkdirSync(dir, { recursive: true });
|
|
97
|
+
const toolsUrl = new URL("./tools.js", import.meta.url).href;
|
|
98
|
+
const searchLine = webSearchEnabled
|
|
99
|
+
? ` pi.registerTool(tools.createSearchTool({ baseUrl: ${JSON.stringify(cfg.baseUrl)}, apiKey: process.env.U1S1_API_KEY }));\n`
|
|
100
|
+
: "";
|
|
101
|
+
writeFileSync(join(dir, "u1s1-tools.js"), `// 由 u1s1 每次启动自动生成,请勿手改\n` +
|
|
102
|
+
`export default async function (pi) {\n` +
|
|
103
|
+
` if (process.env.U1S1_TOOLS_VIA_EXTENSION !== "1") return;\n` +
|
|
104
|
+
` const tools = await import(${JSON.stringify(toolsUrl)});\n` +
|
|
105
|
+
searchLine +
|
|
106
|
+
` pi.registerTool(tools.webFetchTool);\n` +
|
|
107
|
+
`}\n`);
|
|
108
|
+
}
|
|
87
109
|
export function ensureProviderModels(cfg) {
|
|
88
110
|
mkdirSync(agentDir, { recursive: true });
|
|
89
111
|
const p = join(agentDir, "models.json");
|
|
@@ -104,6 +126,8 @@ export function ensureProviderModels(cfg) {
|
|
|
104
126
|
baseUrl: cfg.baseUrl,
|
|
105
127
|
api: "openai-completions",
|
|
106
128
|
apiKey: "$U1S1_API_KEY",
|
|
129
|
+
// 网关按这个头识别客户端版本;不带头的旧版会在会话首轮被追加升级提示
|
|
130
|
+
headers: { "x-u1s1-version": VERSION },
|
|
107
131
|
models: MODELS.map((m) => ({
|
|
108
132
|
id: m.id,
|
|
109
133
|
name: m.name,
|
package/dist/api.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
import { VERSION } from "./config.js";
|
|
2
|
+
/** 网关按 x-u1s1-version 识别客户端版本(旧版 CLI 不带,提示升级)。 */
|
|
3
|
+
function authHeaders(apiKey) {
|
|
4
|
+
return { authorization: `Bearer ${apiKey}`, "x-u1s1-version": VERSION };
|
|
5
|
+
}
|
|
1
6
|
export async function fetchModels(cfg) {
|
|
2
7
|
let resp;
|
|
3
8
|
try {
|
|
4
9
|
resp = await fetch(`${cfg.baseUrl}/models`, {
|
|
5
|
-
headers:
|
|
10
|
+
headers: authHeaders(cfg.apiKey),
|
|
6
11
|
});
|
|
7
12
|
}
|
|
8
13
|
catch {
|
|
@@ -13,9 +18,9 @@ export async function fetchModels(cfg) {
|
|
|
13
18
|
if (!resp.ok)
|
|
14
19
|
throw new Error(`服务端返回 ${resp.status},稍后再试`);
|
|
15
20
|
const body = (await resp.json());
|
|
16
|
-
return body.data;
|
|
21
|
+
return { models: body.data, features: body.features ?? {} };
|
|
17
22
|
}
|
|
18
|
-
/** 联网搜索走网关代理(上游 key 只在服务端)。 */
|
|
23
|
+
/** 联网搜索走网关代理(上游 key 只在服务端)。maxResults 不传时由服务端决定默认值。 */
|
|
19
24
|
export async function searchWeb(cfg, query, maxResults, signal) {
|
|
20
25
|
if (!cfg.apiKey)
|
|
21
26
|
throw new Error("没有配置 API Key");
|
|
@@ -24,7 +29,7 @@ export async function searchWeb(cfg, query, maxResults, signal) {
|
|
|
24
29
|
resp = await fetch(`${cfg.baseUrl}/search`, {
|
|
25
30
|
method: "POST",
|
|
26
31
|
headers: {
|
|
27
|
-
|
|
32
|
+
...authHeaders(cfg.apiKey),
|
|
28
33
|
"content-type": "application/json",
|
|
29
34
|
},
|
|
30
35
|
body: JSON.stringify({ query, max_results: maxResults }),
|
|
@@ -48,7 +53,7 @@ export async function fetchMe(cfg) {
|
|
|
48
53
|
let resp;
|
|
49
54
|
try {
|
|
50
55
|
resp = await fetch(`${cfg.baseUrl}/me`, {
|
|
51
|
-
headers:
|
|
56
|
+
headers: authHeaders(cfg.apiKey),
|
|
52
57
|
});
|
|
53
58
|
}
|
|
54
59
|
catch {
|
package/dist/brand.js
CHANGED
|
@@ -46,8 +46,9 @@ function paintArt(theme, line) {
|
|
|
46
46
|
* Startup hero, responsive to terminal width:
|
|
47
47
|
* wide → wordmark with info column beside it; medium → stacked; narrow → one-liner.
|
|
48
48
|
*/
|
|
49
|
-
export function renderBrandHeader(theme, version, cwd, width) {
|
|
50
|
-
const name = theme.bold(theme.fg("text", `${BRAND_NAME} v${version}`))
|
|
49
|
+
export function renderBrandHeader(theme, version, cwd, width, notice) {
|
|
50
|
+
const name = theme.bold(theme.fg("text", `${BRAND_NAME} v${version}`)) +
|
|
51
|
+
(notice ? ` ${theme.fg("accent", notice)}` : "");
|
|
51
52
|
const brand = theme.fg("muted", `${BRAND_CN} · ${BRAND_TAGLINE}`);
|
|
52
53
|
const dir = theme.fg("dim", `cwd: ${formatHomePath(cwd)}`);
|
|
53
54
|
const hints = theme.fg("dim", "/help 看命令 · Shift+Enter 换行 · Esc 中断");
|
package/dist/config.js
CHANGED
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
2
3
|
import { homedir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
+
import { basename, dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
export const VERSION = require("../package.json").version;
|
|
8
|
+
/**
|
|
9
|
+
* 便携包安装(install.sh / install.ps1):包根旁边带自己的 node 运行时,
|
|
10
|
+
* npm 更新碰不到这份拷贝,升级只能整包重装。npm 全局安装的包在 node_modules
|
|
11
|
+
* 下,按父目录名先排除,避免撞上恰好叫 node 的目录误判。
|
|
12
|
+
*/
|
|
13
|
+
export function isPortableInstall() {
|
|
14
|
+
const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); // dist/.. = 包根
|
|
15
|
+
if (basename(dirname(pkgRoot)) === "node_modules")
|
|
16
|
+
return false;
|
|
17
|
+
const portableNode = process.platform === "win32"
|
|
18
|
+
? join(pkgRoot, "..", "node", "node.exe")
|
|
19
|
+
: join(pkgRoot, "..", "node", "bin", "node");
|
|
20
|
+
return existsSync(portableNode);
|
|
21
|
+
}
|
|
4
22
|
export const DEFAULT_BASE_URL = "https://api.u1s1.io/v1";
|
|
5
23
|
export const PROVIDER_ID = "u1s1";
|
|
6
24
|
/** Make a short alias from a model id by stripping common prefixes. */
|
package/dist/index.js
CHANGED
|
@@ -1,24 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
3
|
import { writeFileSync } from "node:fs";
|
|
4
|
-
import {
|
|
5
|
-
import { cleanupBrandThemes, ensureBrandPrompt, ensureDefaultSettings, ensureProviderModels, } from "./agent-setup.js";
|
|
4
|
+
import { cleanupBrandThemes, ensureBrandPrompt, ensureDefaultSettings, ensureProviderModels, writeWebToolsExtension, } from "./agent-setup.js";
|
|
6
5
|
import { printConsoleBanner } from "./brand.js";
|
|
7
|
-
import { agentDir, apiModelToDef, loadConfig, MODELS, persistPreferredModel, PROVIDER_ID, readSettings, resolvePreferredModel, setModelsFromApi, } from "./config.js";
|
|
8
|
-
import { applyBrandUi } from "./style.js";
|
|
9
|
-
import { createFetchTool, createSearchTool } from "./tools.js";
|
|
6
|
+
import { agentDir, apiModelToDef, isPortableInstall, loadConfig, MODELS, persistPreferredModel, PROVIDER_ID, readSettings, resolvePreferredModel, setModelsFromApi, VERSION, } from "./config.js";
|
|
7
|
+
import { applyBrandUi, setUpdateNotice } from "./style.js";
|
|
10
8
|
import { fetchModels } from "./api.js";
|
|
11
|
-
const require = createRequire(import.meta.url);
|
|
12
|
-
const VERSION = require("../package.json").version;
|
|
13
9
|
const PACKAGE_NAME = "u1s1-cli";
|
|
14
10
|
/**
|
|
15
|
-
* 启动时自动检查 npm
|
|
16
|
-
*
|
|
11
|
+
* 启动时自动检查 npm 最新版:autoUpdate 开着就静默安装,关着也在启动横幅的
|
|
12
|
+
* 版本号后面提示一句(u1s1 vX.Y.Z 后跟升级状态,见 setUpdateNotice)。
|
|
13
|
+
* 不阻塞启动流程,失败也不报错(留到手动 `u1s1 update`)。
|
|
17
14
|
*/
|
|
18
15
|
async function checkAndAutoUpdate() {
|
|
19
|
-
const settings = readSettings();
|
|
20
|
-
if (settings.autoUpdate === false)
|
|
21
|
-
return;
|
|
22
16
|
let latest;
|
|
23
17
|
try {
|
|
24
18
|
const url = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
|
|
@@ -50,6 +44,12 @@ async function checkAndAutoUpdate() {
|
|
|
50
44
|
}
|
|
51
45
|
if (!newer)
|
|
52
46
|
return;
|
|
47
|
+
const settings = readSettings();
|
|
48
|
+
// 便携版没法用 npm 自更新;autoUpdate 关闭同理只提示。u1s1 update 会给出正确升级方式
|
|
49
|
+
if (settings.autoUpdate === false || isPortableInstall()) {
|
|
50
|
+
setUpdateNotice(`⬆ 新版 v${latest} 可用 · u1s1 update 升级`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
53
|
// 检测包管理器
|
|
54
54
|
const userAgent = process.env.npm_config_user_agent ?? "";
|
|
55
55
|
const pm = userAgent.startsWith("pnpm") ? "pnpm"
|
|
@@ -59,14 +59,17 @@ async function checkAndAutoUpdate() {
|
|
|
59
59
|
const installCmd = pm === "npm"
|
|
60
60
|
? `npm install -g ${PACKAGE_NAME}@latest`
|
|
61
61
|
: `${pm} add -g ${PACKAGE_NAME}@latest`;
|
|
62
|
+
setUpdateNotice(`⬆ 发现新版 v${latest},自动更新中…`);
|
|
62
63
|
try {
|
|
63
|
-
//
|
|
64
|
-
const {
|
|
65
|
-
|
|
66
|
-
|
|
64
|
+
// 异步安装:execSync 会卡死事件循环,TUI 打字会冻住
|
|
65
|
+
const { exec } = await import("node:child_process");
|
|
66
|
+
const { promisify } = await import("node:util");
|
|
67
|
+
await promisify(exec)(installCmd, { timeout: 60_000 });
|
|
68
|
+
setUpdateNotice(`✨ 已更新到 v${latest},重启后生效`);
|
|
67
69
|
}
|
|
68
70
|
catch {
|
|
69
71
|
// 自动更新失败不阻塞,用户可手动 `u1s1 update`
|
|
72
|
+
setUpdateNotice(`⬆ 新版 v${latest} 可用 · u1s1 update 升级`);
|
|
70
73
|
}
|
|
71
74
|
}
|
|
72
75
|
/**
|
|
@@ -114,18 +117,24 @@ async function runAgent(cfg, args) {
|
|
|
114
117
|
ensureBrandPrompt();
|
|
115
118
|
ensureDefaultSettings();
|
|
116
119
|
// Fetch model list from server; fall back to built-in MODELS on error.
|
|
120
|
+
// 服务端没开搜索(或老网关没有 features 字段)时不注册 web_search,模型就不会白调
|
|
121
|
+
let webSearchEnabled = true;
|
|
117
122
|
try {
|
|
118
|
-
const
|
|
119
|
-
setModelsFromApi(
|
|
123
|
+
const { models, features } = await fetchModels(cfg);
|
|
124
|
+
setModelsFromApi(models.map(apiModelToDef));
|
|
125
|
+
webSearchEnabled = features.web_search !== false;
|
|
120
126
|
}
|
|
121
127
|
catch (e) {
|
|
122
128
|
console.error(" 获取模型列表失败,使用内置列表:", e.message);
|
|
123
129
|
}
|
|
124
130
|
ensureProviderModels(cfg);
|
|
131
|
+
// 联网工具经 agentDir/extensions 投影,TUI 和 u1s1 web 共用一份注册
|
|
132
|
+
writeWebToolsExtension(cfg, webSearchEnabled);
|
|
125
133
|
ensureTmuxKeyboardProtocol();
|
|
126
134
|
// must be set before pi reads them (getAgentDir() reads at call time, env at import is fine too)
|
|
127
135
|
process.env["PI_CODING_AGENT_DIR"] = agentDir;
|
|
128
136
|
process.env["U1S1_API_KEY"] = cfg.apiKey;
|
|
137
|
+
process.env["U1S1_TOOLS_VIA_EXTENSION"] = "1";
|
|
129
138
|
// hide pi's own "Run pi update" banner; users should run `u1s1 update` instead
|
|
130
139
|
process.env["PI_SKIP_VERSION_CHECK"] = "1";
|
|
131
140
|
const { CustomEditor, main } = await import("@earendil-works/pi-coding-agent");
|
|
@@ -158,9 +167,6 @@ async function runAgent(cfg, args) {
|
|
|
158
167
|
name: "u1s1",
|
|
159
168
|
factory: (pi) => {
|
|
160
169
|
applyBrandUi(pi, VERSION);
|
|
161
|
-
// 联网能力:搜索走网关(上游 key 只在服务端),抓网页在本地直接出网
|
|
162
|
-
pi.registerTool(createSearchTool(cfg));
|
|
163
|
-
pi.registerTool(createFetchTool());
|
|
164
170
|
// pi 内置只有 /quit;补一个更常见的 /exit,方便新手退出
|
|
165
171
|
pi.registerCommand("exit", {
|
|
166
172
|
description: "退出 u1s1",
|
package/dist/style.js
CHANGED
|
@@ -2,6 +2,16 @@ import { basename } from "node:path";
|
|
|
2
2
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
3
|
import { readSettings } from "./config.js";
|
|
4
4
|
import { renderBrandHeader } from "./brand.js";
|
|
5
|
+
/**
|
|
6
|
+
* 启动横幅版本号后面的升级状态(如「发现新版 v0.9.3,自动更新中…」)。
|
|
7
|
+
* checkAndAutoUpdate 异步写入;横幅已挂载时主动触发一次重绘,否则等首帧渲染。
|
|
8
|
+
*/
|
|
9
|
+
let updateNotice = "";
|
|
10
|
+
let refreshHeader;
|
|
11
|
+
export function setUpdateNotice(notice) {
|
|
12
|
+
updateNotice = notice;
|
|
13
|
+
refreshHeader?.();
|
|
14
|
+
}
|
|
5
15
|
/**
|
|
6
16
|
* Brand chrome is just the startup hero + window title; everything else
|
|
7
17
|
* (tool rows, thinking blocks, spinner) stays Pi's default UI.
|
|
@@ -13,13 +23,16 @@ export function applyBrandUi(pi, version) {
|
|
|
13
23
|
// 检查设置:关掉就不显示启动横幅
|
|
14
24
|
const settings = readSettings();
|
|
15
25
|
if (settings.showStartupBanner !== false) {
|
|
16
|
-
ctx.ui.setHeader((
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
26
|
+
ctx.ui.setHeader((tui, theme) => {
|
|
27
|
+
refreshHeader = () => tui.requestRender();
|
|
28
|
+
return {
|
|
29
|
+
render(width) {
|
|
30
|
+
// pi-tui crashes on lines wider than the terminal, so truncate defensively.
|
|
31
|
+
return renderBrandHeader(theme, version, process.cwd(), width, updateNotice).map((line) => truncateToWidth(line, width));
|
|
32
|
+
},
|
|
33
|
+
invalidate() { },
|
|
34
|
+
};
|
|
35
|
+
});
|
|
23
36
|
}
|
|
24
37
|
ctx.ui.setTitle(`u1s1 — ${basename(process.cwd())}`);
|
|
25
38
|
});
|
package/dist/tools.js
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
1
2
|
import { Type } from "typebox";
|
|
2
3
|
import { searchWeb } from "./api.js";
|
|
3
4
|
const FETCH_TIMEOUT_MS = 20_000;
|
|
4
5
|
const MAX_FETCH_BYTES = 2_000_000;
|
|
5
6
|
const MAX_TEXT_CHARS = 30_000;
|
|
7
|
+
const HTML_ENTITIES = {
|
|
8
|
+
" ": " ",
|
|
9
|
+
"&": "&",
|
|
10
|
+
"<": "<",
|
|
11
|
+
">": ">",
|
|
12
|
+
""": '"',
|
|
13
|
+
"'": "'",
|
|
14
|
+
};
|
|
6
15
|
/** 把网页 HTML 榨成纯文本:去掉脚本样式和标签,压掉多余空白。 */
|
|
7
16
|
function htmlToText(html) {
|
|
8
17
|
return html
|
|
@@ -11,12 +20,7 @@ function htmlToText(html) {
|
|
|
11
20
|
.replace(/<br\s*\/?>/gi, "\n")
|
|
12
21
|
.replace(/<\/(p|div|li|tr|h[1-6])>/gi, "\n")
|
|
13
22
|
.replace(/<[^>]+>/g, " ")
|
|
14
|
-
.replace(/ /g,
|
|
15
|
-
.replace(/&/g, "&")
|
|
16
|
-
.replace(/</g, "<")
|
|
17
|
-
.replace(/>/g, ">")
|
|
18
|
-
.replace(/"/g, '"')
|
|
19
|
-
.replace(/'/g, "'")
|
|
23
|
+
.replace(/&(?:nbsp|amp|lt|gt|quot|#39);/g, (m) => HTML_ENTITIES[m] ?? m)
|
|
20
24
|
.replace(/[ \t]+/g, " ")
|
|
21
25
|
.replace(/\n\s*\n\s*\n+/g, "\n\n")
|
|
22
26
|
.trim();
|
|
@@ -26,9 +30,48 @@ function truncate(text) {
|
|
|
26
30
|
return text;
|
|
27
31
|
return `${text.slice(0, MAX_TEXT_CHARS)}\n\n…(内容过长已截断,共 ${text.length} 字符)`;
|
|
28
32
|
}
|
|
33
|
+
/** 只下载前 maxBytes 就断流,超大页面不用整个拉完再丢。 */
|
|
34
|
+
async function readBodyCapped(resp, maxBytes) {
|
|
35
|
+
if (!resp.body)
|
|
36
|
+
return new Uint8Array(await resp.arrayBuffer()).subarray(0, maxBytes);
|
|
37
|
+
const reader = resp.body.getReader();
|
|
38
|
+
const chunks = [];
|
|
39
|
+
let total = 0;
|
|
40
|
+
for (;;) {
|
|
41
|
+
const { done, value } = await reader.read();
|
|
42
|
+
if (done)
|
|
43
|
+
break;
|
|
44
|
+
chunks.push(value);
|
|
45
|
+
total += value.byteLength;
|
|
46
|
+
if (total >= maxBytes) {
|
|
47
|
+
void reader.cancel().catch(() => { });
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const out = new Uint8Array(Math.min(total, maxBytes));
|
|
52
|
+
let offset = 0;
|
|
53
|
+
for (const chunk of chunks) {
|
|
54
|
+
const room = out.byteLength - offset;
|
|
55
|
+
if (room <= 0)
|
|
56
|
+
break;
|
|
57
|
+
out.set(room < chunk.byteLength ? chunk.subarray(0, room) : chunk, offset);
|
|
58
|
+
offset += chunk.byteLength;
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
/** 按响应头声明的 charset 解码(GBK 等中文页面不至于乱码),没有或不认识就按 UTF-8。 */
|
|
63
|
+
function decodeBody(bytes, contentType) {
|
|
64
|
+
const charset = /charset=["']?([\w-]+)/i.exec(contentType)?.[1];
|
|
65
|
+
try {
|
|
66
|
+
return new TextDecoder(charset || "utf-8").decode(bytes);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return new TextDecoder("utf-8").decode(bytes);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
29
72
|
/** 联网搜索工具:走 u1s1 网关代理,上游 key 不落到用户机器上。 */
|
|
30
73
|
export function createSearchTool(cfg) {
|
|
31
|
-
return {
|
|
74
|
+
return defineTool({
|
|
32
75
|
name: "web_search",
|
|
33
76
|
label: "联网搜索",
|
|
34
77
|
description: "Search the web for current information. Use it for anything time-sensitive or outside the codebase: library docs, error messages, latest versions, recent events. Returns a short answer plus ranked results with URLs and snippets. Follow up with web_fetch to read a promising result in full.",
|
|
@@ -42,7 +85,7 @@ export function createSearchTool(cfg) {
|
|
|
42
85
|
maxResults: Type.Optional(Type.Number({ description: "How many results to return (1-10, default 5).", minimum: 1, maximum: 10 })),
|
|
43
86
|
}),
|
|
44
87
|
async execute(_toolCallId, params, signal) {
|
|
45
|
-
const data = await searchWeb(cfg, params.query, params.maxResults
|
|
88
|
+
const data = await searchWeb(cfg, params.query, params.maxResults, signal);
|
|
46
89
|
const lines = [];
|
|
47
90
|
if (data.answer)
|
|
48
91
|
lines.push(`Answer: ${data.answer}`, "");
|
|
@@ -59,63 +102,61 @@ export function createSearchTool(cfg) {
|
|
|
59
102
|
details: { query: data.query, count: data.results.length },
|
|
60
103
|
};
|
|
61
104
|
},
|
|
62
|
-
};
|
|
105
|
+
});
|
|
63
106
|
}
|
|
64
107
|
/** 抓网页:纯客户端出网,不经过我们的服务器,也不额外计费。 */
|
|
65
|
-
export
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
};
|
|
121
|
-
}
|
|
108
|
+
export const webFetchTool = defineTool({
|
|
109
|
+
name: "web_fetch",
|
|
110
|
+
label: "读取网页",
|
|
111
|
+
description: "Fetch a URL and return its readable text content (HTML is stripped to text; JSON and plain text are returned as-is). Use it to read a page found via web_search, or any URL the user pasted.",
|
|
112
|
+
promptSnippet: "抓取指定网址并转成可读文本",
|
|
113
|
+
promptGuidelines: [
|
|
114
|
+
"Use web_fetch to read a specific URL, and web_search when you still need to find the URL.",
|
|
115
|
+
],
|
|
116
|
+
parameters: Type.Object({
|
|
117
|
+
url: Type.String({ description: "Absolute http(s) URL to fetch." }),
|
|
118
|
+
}),
|
|
119
|
+
async execute(_toolCallId, params, signal) {
|
|
120
|
+
let url;
|
|
121
|
+
try {
|
|
122
|
+
// 用户从聊天工具粘来的链接常带 "@https://…" 前缀,顺手剥掉
|
|
123
|
+
url = new URL(params.url.trim().replace(/^@/, ""));
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
throw new Error(`不是合法的网址: ${params.url}`);
|
|
127
|
+
}
|
|
128
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
129
|
+
throw new Error(`只支持 http/https,收到 ${url.protocol}`);
|
|
130
|
+
}
|
|
131
|
+
const timeout = AbortSignal.timeout(FETCH_TIMEOUT_MS);
|
|
132
|
+
const abort = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
133
|
+
let resp;
|
|
134
|
+
try {
|
|
135
|
+
resp = await fetch(url, {
|
|
136
|
+
redirect: "follow",
|
|
137
|
+
headers: {
|
|
138
|
+
accept: "text/html,application/xhtml+xml,application/json,text/plain;q=0.9,*/*;q=0.8",
|
|
139
|
+
"user-agent": "u1s1-cli",
|
|
140
|
+
},
|
|
141
|
+
signal: abort,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
catch (e) {
|
|
145
|
+
throw new Error(`打不开 ${url.href}: ${e.message}`);
|
|
146
|
+
}
|
|
147
|
+
if (!resp.ok)
|
|
148
|
+
throw new Error(`${url.href} 返回 ${resp.status} ${resp.statusText}`);
|
|
149
|
+
const type = resp.headers.get("content-type") ?? "";
|
|
150
|
+
if (!/text\/|json|xml|javascript/i.test(type)) {
|
|
151
|
+
throw new Error(`${url.href} 不是文本内容 (${type || "unknown"}),读不了`);
|
|
152
|
+
}
|
|
153
|
+
const raw = decodeBody(await readBodyCapped(resp, MAX_FETCH_BYTES), type);
|
|
154
|
+
const text = /html|xml/i.test(type) ? htmlToText(raw) : raw.trim();
|
|
155
|
+
return {
|
|
156
|
+
content: [
|
|
157
|
+
{ type: "text", text: truncate(`# ${url.href}\n\n${text || "(空白页面)"}`) },
|
|
158
|
+
],
|
|
159
|
+
details: { url: url.href, contentType: type, chars: text.length },
|
|
160
|
+
};
|
|
161
|
+
},
|
|
162
|
+
});
|
package/dist/update.js
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import { execSync } from "node:child_process";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
|
+
import { isPortableInstall } from "./config.js";
|
|
3
4
|
const require = createRequire(import.meta.url);
|
|
4
|
-
|
|
5
|
+
const pkg = require("../package.json");
|
|
6
|
+
export const VERSION = pkg.version;
|
|
5
7
|
export const PACKAGE_NAME = "u1s1-cli";
|
|
8
|
+
/** engines.node 里要求的最低版本(如 ">=22.19.0" → "22.19.0");解析不出返回 undefined。 */
|
|
9
|
+
function requiredNodeVersion() {
|
|
10
|
+
const m = /(\d+\.\d+\.\d+)/.exec(pkg.engines?.node ?? "");
|
|
11
|
+
return m?.[1];
|
|
12
|
+
}
|
|
6
13
|
/** Detect the package manager that installed u1s1. */
|
|
7
14
|
export function detectPackageManager() {
|
|
8
15
|
// Check common global install locations for clues
|
|
@@ -53,11 +60,38 @@ export async function update() {
|
|
|
53
60
|
console.log(`当前 v${VERSION} 已是最新版本 ✓`);
|
|
54
61
|
return;
|
|
55
62
|
}
|
|
56
|
-
const pm = detectPackageManager();
|
|
57
63
|
console.log(`发现新版本 v${latest} (当前 v${VERSION})`);
|
|
64
|
+
// 便携版:npm 装到全局目录,但 PATH 先命中便携目录,怎么更都还是旧版。
|
|
65
|
+
// 唯一正确的升级方式是重跑官网安装命令整包替换,这里不再碰 npm。
|
|
66
|
+
if (isPortableInstall()) {
|
|
67
|
+
const isWin = process.platform === "win32";
|
|
68
|
+
console.log("");
|
|
69
|
+
console.log("你装的是便携版(自带 Node),npm 更新不了它。升级分两步:");
|
|
70
|
+
console.log(" 1. 关闭所有 u1s1 窗口");
|
|
71
|
+
console.log(isWin
|
|
72
|
+
? " 2. 打开 PowerShell 运行: irm https://u1s1.io/releases/install.ps1 | iex"
|
|
73
|
+
: " 2. 在终端运行: curl -fsSL https://u1s1.io/releases/install.sh | bash");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const pm = detectPackageManager();
|
|
77
|
+
// 系统 Node 低于新版要求时,先说人话:推荐官网安装命令(便携包自带合适的
|
|
78
|
+
// Node,不用自己折腾)。npm 更新仍继续——引擎不匹配只是警告,装上大概率能跑。
|
|
79
|
+
const required = requiredNodeVersion();
|
|
80
|
+
if (required && compareVersions(process.versions.node, required) < 0) {
|
|
81
|
+
const cmd = process.platform === "win32"
|
|
82
|
+
? "irm https://u1s1.io/releases/install.ps1 | iex (在 PowerShell 里运行)"
|
|
83
|
+
: "curl -fsSL https://u1s1.io/releases/install.sh | bash";
|
|
84
|
+
console.log("");
|
|
85
|
+
console.log(`⚠ 你电脑的 Node.js 是 v${process.versions.node},新版 u1s1 建议 v${required} 或更新。`);
|
|
86
|
+
console.log(" 推荐用官网安装命令重装,自带合适的 Node,一步到位:");
|
|
87
|
+
console.log(` ${cmd}`);
|
|
88
|
+
console.log(" 下面仍会继续用 npm 更新;更新后如果运行异常,再用上面的命令重装即可。");
|
|
89
|
+
console.log("");
|
|
90
|
+
}
|
|
58
91
|
console.log(`正在用 ${pm} 更新 ${PACKAGE_NAME}…`);
|
|
59
92
|
try {
|
|
60
|
-
|
|
93
|
+
// npm 压掉 EBADENGINE 等警告墙,对新手只有噪音;出错时 error 仍会显示
|
|
94
|
+
const installCmd = pm === "npm" ? `npm install -g --loglevel=error ${PACKAGE_NAME}@latest` : `${pm} add -g ${PACKAGE_NAME}@latest`;
|
|
61
95
|
execSync(installCmd, { stdio: "inherit" });
|
|
62
96
|
console.log(`\n✅ 已更新到 v${latest},重启 u1s1 后生效。`);
|
|
63
97
|
}
|
package/dist/usage.js
CHANGED
|
@@ -4,6 +4,21 @@ function bar(ratio, width = 24) {
|
|
|
4
4
|
const filled = Math.round(Math.max(0, Math.min(1, ratio)) * width);
|
|
5
5
|
return "█".repeat(filled) + "░".repeat(width - filled);
|
|
6
6
|
}
|
|
7
|
+
// 与官网 app.js 的 fmtTokensCn 保持一致:万/亿单位,2 位有效数字
|
|
8
|
+
function fmtTokensCn(tokens) {
|
|
9
|
+
const t = Number(tokens);
|
|
10
|
+
if (!Number.isFinite(t) || t <= 0)
|
|
11
|
+
return "0";
|
|
12
|
+
const sig2 = (n) => {
|
|
13
|
+
const m = Math.pow(10, Math.max(0, String(Math.round(n)).length - 2));
|
|
14
|
+
return Math.round(n / m) * m;
|
|
15
|
+
};
|
|
16
|
+
if (t >= 1e8)
|
|
17
|
+
return (Math.round((t / 1e8) * 10) / 10).toLocaleString("en-US") + " 亿";
|
|
18
|
+
if (t >= 1e4)
|
|
19
|
+
return sig2(t / 1e4).toLocaleString("en-US") + " 万";
|
|
20
|
+
return sig2(t).toLocaleString("en-US");
|
|
21
|
+
}
|
|
7
22
|
export async function usage() {
|
|
8
23
|
const cfg = loadConfig();
|
|
9
24
|
if (!cfg.apiKey) {
|
|
@@ -14,16 +29,28 @@ export async function usage() {
|
|
|
14
29
|
console.error(e.message);
|
|
15
30
|
process.exit(1);
|
|
16
31
|
});
|
|
17
|
-
const remain = me.remaining_usd;
|
|
18
32
|
const freeRemain = me.daily_free_remaining_usd;
|
|
19
33
|
const freeTotal = me.daily_free_usd;
|
|
20
34
|
const freeRatio = freeTotal > 0 ? freeRemain / freeTotal : 0;
|
|
35
|
+
const tpu = me.tokens_per_usd ?? 0;
|
|
21
36
|
console.log("");
|
|
22
37
|
console.log(` 账号 ${me.email ?? "(未绑定邮箱)"}`);
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
38
|
+
if (tpu > 0) {
|
|
39
|
+
const tok = (usd) => `${fmtTokensCn(usd * tpu)} Token`;
|
|
40
|
+
console.log(` 今日免费 还剩 ${fmtTokensCn(freeRemain * tpu)} / ${tok(freeTotal)} ${bar(freeRatio)}`);
|
|
41
|
+
console.log(" DeepSeek V4 Flash · 北京时间 0 点恢复");
|
|
42
|
+
console.log(` 永久余额 ${tok(me.remaining_usd)}`);
|
|
43
|
+
console.log(` 本月已用 约 ${tok(me.mtd_usd)}($${me.mtd_usd.toFixed(2)})`);
|
|
44
|
+
console.log("");
|
|
45
|
+
console.log(" Token 数按默认模型单价折算,为约数;用更贵的模型时消耗更快。");
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
// 老网关没下发 tokens_per_usd,退回金额显示
|
|
49
|
+
console.log(` 今日免费 $${freeRemain} / $${freeTotal} ${bar(freeRatio)}`);
|
|
50
|
+
console.log(" DeepSeek V4 Flash · 北京时间 0 点恢复");
|
|
51
|
+
console.log(` 永久余额 $${me.remaining_usd}`);
|
|
52
|
+
console.log(` 本月成本 $${me.mtd_usd}`);
|
|
53
|
+
}
|
|
27
54
|
console.log("");
|
|
28
55
|
console.log(" 免费额度每天自动恢复;邀请朋友双方各得永久加量 → https://u1s1.io/dashboard");
|
|
29
56
|
console.log("");
|
package/dist/web.js
CHANGED
|
@@ -2,7 +2,7 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
2
2
|
import { mkdirSync } from "node:fs";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
|
-
import { cleanupBrandThemes, ensureBrandPrompt, ensureDefaultSettings, ensureProviderModels, } from "./agent-setup.js";
|
|
5
|
+
import { cleanupBrandThemes, ensureBrandPrompt, ensureDefaultSettings, ensureProviderModels, writeWebToolsExtension, } from "./agent-setup.js";
|
|
6
6
|
import { agentDir, apiModelToDef, resolvePreferredModel, setModelsFromApi, u1s1Dir, writeAgentDefaultModel, } from "./config.js";
|
|
7
7
|
import { applyWebUiBranding } from "./webui-brand.js";
|
|
8
8
|
import { fetchModels } from "./api.js";
|
|
@@ -49,14 +49,19 @@ export async function webCommand(cfg, args) {
|
|
|
49
49
|
ensureBrandPrompt();
|
|
50
50
|
ensureDefaultSettings();
|
|
51
51
|
// Fetch model list from server; fall back to built-in MODELS on error.
|
|
52
|
+
// 服务端没开搜索(或老网关没有 features 字段)时不注册 web_search
|
|
53
|
+
let webSearchEnabled = true;
|
|
52
54
|
try {
|
|
53
|
-
const
|
|
54
|
-
setModelsFromApi(
|
|
55
|
+
const { models, features } = await fetchModels(cfg);
|
|
56
|
+
setModelsFromApi(models.map(apiModelToDef));
|
|
57
|
+
webSearchEnabled = features.web_search !== false;
|
|
55
58
|
}
|
|
56
59
|
catch (e) {
|
|
57
60
|
console.error(" 获取模型列表失败,使用内置列表:", e.message);
|
|
58
61
|
}
|
|
59
62
|
ensureProviderModels(cfg);
|
|
63
|
+
// 联网工具经 agentDir/extensions 投影,和 TUI 共用一份注册
|
|
64
|
+
writeWebToolsExtension(cfg, webSearchEnabled);
|
|
60
65
|
// 网页版新会话从 settings.json 的 defaultModel 取模型(TUI 是每次传 --model),
|
|
61
66
|
// 确保它有值;resolvePreferredModel 优先尊重已有的 in-session 选择,不会回退覆盖。
|
|
62
67
|
writeAgentDefaultModel(resolvePreferredModel(cfg.model));
|
|
@@ -70,6 +75,7 @@ export async function webCommand(cfg, args) {
|
|
|
70
75
|
...process.env,
|
|
71
76
|
PI_CODING_AGENT_DIR: agentDir,
|
|
72
77
|
U1S1_API_KEY: cfg.apiKey,
|
|
78
|
+
U1S1_TOOLS_VIA_EXTENSION: "1",
|
|
73
79
|
PI_WEB_DATA_DIR: dataDir,
|
|
74
80
|
},
|
|
75
81
|
});
|