ugc-lens 0.0.1 → 0.0.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.
package/README.md CHANGED
@@ -15,7 +15,15 @@ npm install -g ugc-lens
15
15
 
16
16
  ## 配置模型
17
17
 
18
- 在运行命令的目录里放一个 `.env`(或设置同名环境变量),选一个提供商并填它的 API key:
18
+ 最简单的方式:
19
+
20
+ ```
21
+ ugc-lens init
22
+ ```
23
+
24
+ 交互式地选提供商、从 `/models` 列表里选模型、填 API key 并发一次测试调用,写入全局配置 `~/.ugc-lens/config.json`(权限 600),所有目录下都能用。
25
+
26
+ 也可以手动配置:在运行命令的目录里放一个 `.env`(或设置同名环境变量),选一个提供商并填它的 API key:
19
27
 
20
28
  ```
21
29
  UGC_LENS_PROVIDER=deepseek
@@ -23,11 +31,13 @@ UGC_LENS_MODEL=deepseek-flash
23
31
  DEEPSEEK_API_KEY=你的 key
24
32
  ```
25
33
 
26
- 支持 DeepSeek、通义千问、Kimi、智谱、MiniMax、OpenAI、Claude、Gemini、Grok、OpenRouter 和本地模型,变量名和差异见 [模型提供商](docs/providers.md)。
34
+ 支持 DeepSeek、通义千问、Kimi、智谱、MiniMax、OpenAI、Claude、Gemini、Grok、OpenRouter 和本地模型,变量名和差异见 [模型提供商](docs/providers.md)。优先级:环境变量 > 当前目录的 `.env` / `.ugc-lens/config.json` > 全局配置。
27
35
 
28
36
  ## 使用
29
37
 
30
38
  ```
39
+ ugc-lens init # 初始化模型配置
40
+
31
41
  # 分析一个文件:报告打印到标准输出,同时保存到 .ugc-lens/reports/
32
42
  ugc-lens analyze bilibili_all.json > report.md
33
43
  ugc-lens analyze bilibili_all.json --focus 价格,售后 # 只看这些方向
package/dist/cli/index.js CHANGED
@@ -9,8 +9,10 @@ import { ImportError } from '../ingest/index.js';
9
9
  import { ANALYZE_USAGE, runAnalyze } from './analyze.js';
10
10
  import { ASK_USAGE, runAsk } from './ask.js';
11
11
  import { UsageError } from './common.js';
12
+ import { INIT_USAGE, runInit } from './init.js';
12
13
  import { runWatch, WATCH_USAGE } from './watch.js';
13
14
  const COMMANDS = {
15
+ init: { run: runInit, usage: INIT_USAGE },
14
16
  analyze: { run: runAnalyze, usage: ANALYZE_USAGE },
15
17
  ask: { run: runAsk, usage: ASK_USAGE },
16
18
  watch: { run: runWatch, usage: WATCH_USAGE },
@@ -18,6 +20,7 @@ const COMMANDS = {
18
20
  const MAIN_USAGE = `ugc-lens:分析评论、弹幕等用户互动内容
19
21
 
20
22
  用法:
23
+ ugc-lens init 初始化:选择提供商和模型,写入全局配置
21
24
  ugc-lens analyze <文件> 生成 Markdown 分析报告
22
25
  ugc-lens ask <文件> <问题> [追问...] 追问,回答带 [ID] 引用
23
26
  ugc-lens watch <目录> 监听目录,新的 *_all.json 写完后自动分析
@@ -0,0 +1,23 @@
1
+ import { type LlmConfigFile } from '../config.js';
2
+ export declare const INIT_USAGE = "\u7528\u6CD5\uFF1Augc-lens init\n\n\u4EA4\u4E92\u5F0F\u521D\u59CB\u5316\uFF1A\u9009\u62E9\u63D0\u4F9B\u5546\u548C\u6A21\u578B\u3001\u586B API key\u3001\u53D1\u4E00\u6B21\u6D4B\u8BD5\u8C03\u7528\uFF0C\n\u5199\u5165\u5168\u5C40\u914D\u7F6E ~/.ugc-lens/config.json\uFF08\u6743\u9650 600\uFF09\uFF0C\u6240\u6709\u76EE\u5F55\u4E0B\u751F\u6548\u3002\n\u9879\u76EE\u76EE\u5F55\u7684 .env \u6216 .ugc-lens/config.json \u4ECD\u7136\u53EF\u4EE5\u8986\u76D6\u5B83\u3002";
3
+ /** 交互的输入输出,测试时换成假的 */
4
+ export interface InitIo {
5
+ ask(question: string): Promise<string>;
6
+ print(text: string): void;
7
+ }
8
+ export interface InitDeps {
9
+ io: InitIo;
10
+ homeDir: string;
11
+ /** 同时读取这个目录的 .env,默认当前目录:init 也能发现项目里已有的 key */
12
+ cwd?: string;
13
+ /** 列出提供商的模型;默认真的请求 GET {baseUrl}/models */
14
+ fetchModels?: (config: {
15
+ baseUrl: string;
16
+ apiKey?: string;
17
+ }) => Promise<string[]>;
18
+ /** 发一次测试调用,返回错误消息;undefined 表示通过。默认用真实客户端 */
19
+ verifyModel?: (config: LlmConfigFile) => Promise<string | undefined>;
20
+ }
21
+ /** 交互式初始化流程;返回写入的配置,用户取消时返回 undefined */
22
+ export declare function runInitFlow(deps: InitDeps): Promise<LlmConfigFile | undefined>;
23
+ export declare function runInit(): Promise<void>;
@@ -0,0 +1,185 @@
1
+ import { createInterface } from 'node:readline/promises';
2
+ import { loadDotEnv, loadLlmConfig, readGlobalConfig, writeGlobalConfig } from '../config.js';
3
+ import { isProviderId, PROVIDERS, resolveBaseUrl } from '../llm/providers.js';
4
+ import { createChatClient } from '../llm/client.js';
5
+ import { createLlmModel } from '../agent/llm_model.js';
6
+ import { UsageError } from './common.js';
7
+ export const INIT_USAGE = `用法:ugc-lens init
8
+
9
+ 交互式初始化:选择提供商和模型、填 API key、发一次测试调用,
10
+ 写入全局配置 ~/.ugc-lens/config.json(权限 600),所有目录下生效。
11
+ 项目目录的 .env 或 .ugc-lens/config.json 仍然可以覆盖它。`;
12
+ const PROVIDER_IDS = [...Object.keys(PROVIDERS), 'custom'];
13
+ /** GET {baseUrl}/models,10 秒超时;返回模型 id 列表 */
14
+ async function fetchModelsReal({ baseUrl, apiKey }) {
15
+ const response = await fetch(`${baseUrl}/models`, {
16
+ headers: apiKey ? { authorization: `Bearer ${apiKey}` } : {},
17
+ signal: AbortSignal.timeout(10_000),
18
+ });
19
+ if (!response.ok)
20
+ throw new Error(`HTTP ${response.status}`);
21
+ const body = (await response.json());
22
+ const ids = (body.data ?? []).map((item) => item.id).filter((id) => typeof id === 'string');
23
+ if (ids.length === 0)
24
+ throw new Error('返回的列表是空的');
25
+ return ids;
26
+ }
27
+ /** 一次最小的调用,并要求调用一个假工具,顺便验证 function call 支持 */
28
+ async function verifyModelReal(file) {
29
+ try {
30
+ const config = loadLlmConfig({ ...file, timeout_ms: 30_000, max_retries: 0 });
31
+ if (!config.model)
32
+ return '没有选模型';
33
+ const model = createLlmModel(createChatClient(config), { temperature: 0, maxTokens: 30, disableThinking: true });
34
+ const result = await model.complete({
35
+ messages: [{ role: 'user', content: '现在几点?用工具回答。' }],
36
+ tools: [
37
+ {
38
+ type: 'function',
39
+ function: {
40
+ name: 'get_current_time',
41
+ description: '获取当前时间',
42
+ parameters: { type: 'object', properties: {}, additionalProperties: false },
43
+ },
44
+ },
45
+ ],
46
+ toolChoice: { type: 'function', function: { name: 'get_current_time' } },
47
+ signal: AbortSignal.timeout(35_000),
48
+ });
49
+ if (!result.message.tool_calls?.length)
50
+ return '模型返回了文字而不是工具调用,这个模型可能不支持 function call';
51
+ return undefined;
52
+ }
53
+ catch (error) {
54
+ return error.message;
55
+ }
56
+ }
57
+ function pickProviderId(input) {
58
+ const value = input.trim().toLowerCase();
59
+ const byNumber = PROVIDER_IDS[Number(value) - 1];
60
+ if (byNumber)
61
+ return byNumber;
62
+ return isProviderId(value) ? value : undefined;
63
+ }
64
+ /** 交互式初始化流程;返回写入的配置,用户取消时返回 undefined */
65
+ export async function runInitFlow(deps) {
66
+ const { io } = deps;
67
+ loadDotEnv(deps.cwd ?? process.cwd());
68
+ const existing = readGlobalConfig(deps.homeDir);
69
+ const fetchModels = deps.fetchModels ?? fetchModelsReal;
70
+ const verifyModel = deps.verifyModel ?? verifyModelReal;
71
+ if (existing.provider) {
72
+ io.print(`当前全局配置:${existing.provider}${existing.model ? ` / ${existing.model}` : ''}${existing.api_key ? '(已保存 key)' : ''}`);
73
+ if ((await io.ask('重新设置?[y/N] ')).toLowerCase() !== 'y')
74
+ return undefined;
75
+ }
76
+ io.print('\n选择模型提供商:');
77
+ PROVIDER_IDS.forEach((id, i) => {
78
+ io.print(` ${i + 1}. ${id === 'custom' ? '自定义(任意 OpenAI 兼容接口)' : PROVIDERS[id].name}(${id})`);
79
+ });
80
+ let provider;
81
+ while (!provider) {
82
+ provider = pickProviderId(await io.ask(`输入编号或 id${existing.provider ? `(回车保持 ${existing.provider})` : ''}:`));
83
+ if (!provider && existing.provider)
84
+ provider = existing.provider;
85
+ if (!provider)
86
+ io.print('没认出来,再试一次。');
87
+ }
88
+ const preset = provider === 'custom' ? undefined : PROVIDERS[provider];
89
+ const file = { provider };
90
+ let region = 'cn';
91
+ if (preset && typeof preset.baseUrl !== 'string') {
92
+ const answer = await io.ask(`国内还是国际接口?[1] 国内 [2] 国际(回车默认国内):`);
93
+ region = answer.trim() === '2' ? 'global' : 'cn';
94
+ if (region !== 'cn')
95
+ file.region = region;
96
+ }
97
+ if (provider === 'custom') {
98
+ const baseUrl = (await io.ask('接口地址(如 http://localhost:8000/v1):')).trim();
99
+ if (!baseUrl)
100
+ throw new UsageError('自定义提供商必须填接口地址');
101
+ file.base_url = baseUrl;
102
+ }
103
+ const keyEnv = preset?.apiKeyEnv;
104
+ const needsKey = Boolean(keyEnv) && provider !== 'lmstudio';
105
+ if (provider !== 'ollama') {
106
+ const envKey = keyEnv ? process.env[keyEnv] : undefined;
107
+ const hints = [
108
+ envKey ? `环境变量 ${keyEnv} 里已经有,回车用它` : '',
109
+ existing.api_key ? '回车保持已保存的 key' : '',
110
+ needsKey ? '' : '没有就回车跳过',
111
+ ].filter(Boolean);
112
+ const key = (await io.ask(`API key${hints.length > 0 ? `(${hints.join(';')})` : ''}:`)).trim();
113
+ if (key)
114
+ file.api_key = key;
115
+ else if (existing.api_key)
116
+ file.api_key = existing.api_key;
117
+ if (!file.api_key && !envKey && needsKey) {
118
+ io.print(`注意:没有 key,运行前还需要设置 ${keyEnv}。`);
119
+ }
120
+ }
121
+ const baseUrl = file.base_url ?? (preset ? resolveBaseUrl(preset, region) : '');
122
+ const effectiveKey = file.api_key ?? (keyEnv ? process.env[keyEnv] : undefined);
123
+ try {
124
+ io.print('正在获取模型列表……');
125
+ const models = await fetchModels({ baseUrl, apiKey: effectiveKey });
126
+ const shown = models.slice(0, 30);
127
+ shown.forEach((id, i) => io.print(` ${i + 1}. ${id}`));
128
+ if (models.length > shown.length)
129
+ io.print(` ……共 ${models.length} 个,也可以直接输入名字`);
130
+ while (!file.model) {
131
+ const answer = (await io.ask(`选择模型,输入编号或名字${existing.model ? `(回车保持 ${existing.model})` : ''}:`)).trim();
132
+ if (!answer && existing.model)
133
+ file.model = existing.model;
134
+ else if (models[Number(answer) - 1])
135
+ file.model = models[Number(answer) - 1];
136
+ else if (answer)
137
+ file.model = answer;
138
+ else
139
+ io.print('需要一个模型。');
140
+ }
141
+ }
142
+ catch (error) {
143
+ io.print(`获取模型列表失败:${error.message},手动输入。`);
144
+ while (!file.model) {
145
+ const answer = (await io.ask(`模型名字${existing.model ? `(回车保持 ${existing.model})` : ''}:`)).trim();
146
+ if (answer)
147
+ file.model = answer;
148
+ else if (existing.model)
149
+ file.model = existing.model;
150
+ else
151
+ io.print('需要一个模型。');
152
+ }
153
+ }
154
+ const verify = (await io.ask('发一次测试调用验证模型和 key?[Y/n] ')).trim().toLowerCase();
155
+ if (verify !== 'n') {
156
+ const problem = await verifyModel({ ...existing, ...file });
157
+ if (problem) {
158
+ io.print(`测试调用失败:${problem}`);
159
+ const save = (await io.ask('仍然保存配置?[y/N] ')).trim().toLowerCase();
160
+ if (save !== 'y')
161
+ return undefined;
162
+ }
163
+ else {
164
+ io.print('测试调用通过。');
165
+ }
166
+ }
167
+ const path = writeGlobalConfig(file, deps.homeDir);
168
+ io.print(`已写入 ${path}(权限 600)。某个项目要用别的配置时,在该目录放 .env 或 .ugc-lens/config.json 即可覆盖。`);
169
+ return file;
170
+ }
171
+ export async function runInit() {
172
+ if (!process.stdin.isTTY)
173
+ throw new UsageError('init 需要在终端里交互运行');
174
+ // 整个流程共用一个接口:每问新建会把用户一次粘贴的多行回答丢掉
175
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
176
+ try {
177
+ await runInitFlow({
178
+ io: { ask: (question) => rl.question(question), print: (text) => console.log(text) },
179
+ homeDir: process.env.HOME ?? process.cwd(),
180
+ });
181
+ }
182
+ finally {
183
+ rl.close();
184
+ }
185
+ }
package/dist/config.d.ts CHANGED
@@ -6,6 +6,8 @@ export interface LlmConfigFile {
6
6
  region?: Region;
7
7
  base_url?: string | null;
8
8
  api_key_env?: string | null;
9
+ /** 直接写 key,只建议放在全局配置 ~/.ugc-lens/config.json 里 */
10
+ api_key?: string | null;
9
11
  timeout_ms?: number;
10
12
  max_retries?: number;
11
13
  }
@@ -22,7 +24,14 @@ export interface LlmConfig {
22
24
  export declare class ConfigError extends Error {
23
25
  name: string;
24
26
  }
27
+ /** 读取 cwd/.env 进环境变量(不覆盖已有的),没有就什么都不做 */
28
+ export declare function loadDotEnv(cwd: string): void;
29
+ export declare function globalConfigPath(homeDir?: string): string;
30
+ /** 读取全局配置(~/.ugc-lens/config.json)里的 llm 字段;没有时返回空对象 */
31
+ export declare function readGlobalConfig(homeDir?: string): LlmConfigFile;
32
+ /** 写入全局配置:替换 llm 字段,其他顶层字段保留;权限 600,因为里面有 key */
33
+ export declare function writeGlobalConfig(llm: LlmConfigFile, homeDir?: string): string;
25
34
  /**
26
- * 读取模型配置。优先级:参数 overrides > 环境变量 > 配置文件 > 提供商预设。
35
+ * 读取模型配置。每一项的优先级:参数 overrides > 环境变量 > 项目配置(cwd/.ugc-lens)> 全局配置(~/.ugc-lens)> 提供商预设。
27
36
  */
28
- export declare function loadLlmConfig(overrides?: LlmConfigFile, cwd?: string): LlmConfig;
37
+ export declare function loadLlmConfig(overrides?: LlmConfigFile, cwd?: string, homeDir?: string): LlmConfig;
package/dist/config.js CHANGED
@@ -1,11 +1,13 @@
1
- import { existsSync, readFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
2
3
  import { join } from 'node:path';
3
4
  import { PROVIDERS, isProviderId, resolveBaseUrl, } from './llm/providers.js';
4
5
  export class ConfigError extends Error {
5
6
  name = 'ConfigError';
6
7
  }
7
8
  const CONFIG_DIR = '.ugc-lens';
8
- function loadDotEnv(cwd) {
9
+ /** 读取 cwd/.env 进环境变量(不覆盖已有的),没有就什么都不做 */
10
+ export function loadDotEnv(cwd) {
9
11
  const file = join(cwd, '.env');
10
12
  if (!existsSync(file))
11
13
  return;
@@ -15,6 +17,33 @@ function loadDotEnv(cwd) {
15
17
  // 已存在的环境变量不会被 .env 覆盖,保证命令行里临时指定的值优先
16
18
  process.loadEnvFile(file);
17
19
  }
20
+ export function globalConfigPath(homeDir = homedir()) {
21
+ return join(homeDir, CONFIG_DIR, 'config.json');
22
+ }
23
+ /** 读取全局配置(~/.ugc-lens/config.json)里的 llm 字段;没有时返回空对象 */
24
+ export function readGlobalConfig(homeDir = homedir()) {
25
+ return readConfigFile(homeDir);
26
+ }
27
+ /** 写入全局配置:替换 llm 字段,其他顶层字段保留;权限 600,因为里面有 key */
28
+ export function writeGlobalConfig(llm, homeDir = homedir()) {
29
+ const path = globalConfigPath(homeDir);
30
+ let rest = {};
31
+ if (existsSync(path)) {
32
+ try {
33
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
34
+ if (parsed && typeof parsed === 'object') {
35
+ const { llm: _old, ...others } = parsed;
36
+ rest = others;
37
+ }
38
+ }
39
+ catch (error) {
40
+ throw new ConfigError(`无法解析 ${path}: ${error.message}`);
41
+ }
42
+ }
43
+ mkdirSync(join(homeDir, CONFIG_DIR), { recursive: true });
44
+ writeFileSync(path, `${JSON.stringify({ ...rest, llm }, null, 2)}\n`, { mode: 0o600 });
45
+ return path;
46
+ }
18
47
  function readConfigFile(cwd) {
19
48
  const file = join(cwd, CONFIG_DIR, 'config.json');
20
49
  if (!existsSync(file))
@@ -36,42 +65,43 @@ function pick(...values) {
36
65
  return undefined;
37
66
  }
38
67
  /**
39
- * 读取模型配置。优先级:参数 overrides > 环境变量 > 配置文件 > 提供商预设。
68
+ * 读取模型配置。每一项的优先级:参数 overrides > 环境变量 > 项目配置(cwd/.ugc-lens)> 全局配置(~/.ugc-lens)> 提供商预设。
40
69
  */
41
- export function loadLlmConfig(overrides = {}, cwd = process.cwd()) {
70
+ export function loadLlmConfig(overrides = {}, cwd = process.cwd(), homeDir = homedir()) {
42
71
  loadDotEnv(cwd);
43
72
  const file = readConfigFile(cwd);
73
+ const global = existsSync(globalConfigPath(homeDir)) ? readConfigFile(homeDir) : {};
44
74
  const env = process.env;
45
- const provider = pick(overrides.provider, env.UGC_LENS_PROVIDER, file.provider);
75
+ const provider = pick(overrides.provider, env.UGC_LENS_PROVIDER, file.provider, global.provider);
46
76
  if (!provider) {
47
- throw new ConfigError('未选择模型提供商:设置 UGC_LENS_PROVIDER 或配置文件中的 llm.provider');
77
+ throw new ConfigError('未选择模型提供商:设置 UGC_LENS_PROVIDER,或运行 ugc-lens init 写入全局配置');
48
78
  }
49
79
  if (!isProviderId(provider)) {
50
80
  const ids = [...Object.keys(PROVIDERS), 'custom'].join(', ');
51
81
  throw new ConfigError(`未知的提供商 "${provider}",可选:${ids}`);
52
82
  }
53
- const region = (pick(overrides.region, env.UGC_LENS_REGION, file.region) ?? 'cn');
83
+ const region = (pick(overrides.region, env.UGC_LENS_REGION, file.region, global.region) ?? 'cn');
54
84
  if (region !== 'cn' && region !== 'global') {
55
85
  throw new ConfigError(`region 只能是 cn 或 global,当前为 "${region}"`);
56
86
  }
57
87
  const preset = provider === 'custom' ? undefined : PROVIDERS[provider];
58
- const baseUrl = pick(overrides.base_url, env.UGC_LENS_BASE_URL, file.base_url, preset && resolveBaseUrl(preset, region));
88
+ const baseUrl = pick(overrides.base_url, env.UGC_LENS_BASE_URL, file.base_url, global.base_url, preset && resolveBaseUrl(preset, region));
59
89
  if (!baseUrl) {
60
90
  throw new ConfigError('custom 提供商必须设置 base_url(UGC_LENS_BASE_URL 或 llm.base_url)');
61
91
  }
62
- const apiKeyEnv = pick(overrides.api_key_env, file.api_key_env, preset?.apiKeyEnv);
63
- const apiKey = apiKeyEnv ? pick(env[apiKeyEnv]) : undefined;
92
+ const apiKeyEnv = pick(overrides.api_key_env, file.api_key_env, global.api_key_env, preset?.apiKeyEnv);
93
+ const apiKey = pick(overrides.api_key, apiKeyEnv ? env[apiKeyEnv] : undefined, file.api_key, global.api_key);
64
94
  // 本地服务(ollama、lmstudio、custom)可以没有 key
65
95
  if (preset?.apiKeyEnv && !apiKey && provider !== 'lmstudio') {
66
- throw new ConfigError(`缺少 API key:请在环境变量或 .env 中设置 ${apiKeyEnv}`);
96
+ throw new ConfigError(`缺少 API key:设置 ${apiKeyEnv},或运行 ugc-lens init 写入全局配置`);
67
97
  }
68
- const model = pick(overrides.model, env.UGC_LENS_MODEL, file.model) ?? '';
98
+ const model = pick(overrides.model, env.UGC_LENS_MODEL, file.model, global.model) ?? '';
69
99
  return {
70
100
  provider,
71
101
  model,
72
102
  baseUrl: baseUrl.replace(/\/+$/, ''),
73
103
  apiKey,
74
- timeoutMs: overrides.timeout_ms ?? file.timeout_ms ?? 120_000,
75
- maxRetries: overrides.max_retries ?? file.max_retries ?? 3,
104
+ timeoutMs: overrides.timeout_ms ?? file.timeout_ms ?? global.timeout_ms ?? 120_000,
105
+ maxRetries: overrides.max_retries ?? file.max_retries ?? global.max_retries ?? 3,
76
106
  };
77
107
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ugc-lens",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "面向评论、弹幕等用户互动内容的轻量分析 Agent",
5
5
  "keywords": [
6
6
  "ugc",