@hedgehog-finance/hedgehog-plugin 1.0.11
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/LICENSE +21 -0
- package/index.ts +24 -0
- package/openclaw.plugin.json +14 -0
- package/package.json +47 -0
- package/src/channel.ts +704 -0
- package/src/core/database.ts +146 -0
- package/src/core/logger.ts +22 -0
- package/src/features/index.ts +25 -0
- package/src/features/watchlist/logic.ts +297 -0
- package/src/features/watchlist/schema.ts +67 -0
- package/src/features/watchlist/store.ts +2 -0
- package/src/features/watchlist/tools.ts +469 -0
- package/src/runtime.ts +43 -0
- package/src/types.ts +73 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// src/db/sqlite.ts
|
|
2
|
+
// @ts-ignore
|
|
3
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
import { mkdirSync, existsSync, copyFileSync, readdirSync, statSync, unlinkSync } from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { getDbPath, getBackupDir } from "../runtime";
|
|
7
|
+
import { logger } from "./logger";
|
|
8
|
+
|
|
9
|
+
let _db: any = null;
|
|
10
|
+
let _backupJobStarted = false;
|
|
11
|
+
|
|
12
|
+
const MAX_BACKUPS = 10;
|
|
13
|
+
|
|
14
|
+
function performBackup(trigger: 'startup' | 'cron' = 'cron') {
|
|
15
|
+
const dbPath = getDbPath();
|
|
16
|
+
const backupDir = getBackupDir();
|
|
17
|
+
if (!existsSync(dbPath)) return;
|
|
18
|
+
try {
|
|
19
|
+
const safeTimestamp = new Date().toISOString().replace(/[:]/g, '-');
|
|
20
|
+
const backupFilePath = path.join(backupDir, `business_backup_${safeTimestamp}_${trigger}.db`);
|
|
21
|
+
copyFileSync(dbPath, backupFilePath);
|
|
22
|
+
logger.info({ trigger, backupFilePath }, "数据库已自动备份");
|
|
23
|
+
const files = readdirSync(backupDir);
|
|
24
|
+
const backupFiles = files
|
|
25
|
+
.filter((f: string) => f.startsWith("business_backup_") && f.endsWith(".db"))
|
|
26
|
+
.map((f: string) => {
|
|
27
|
+
const fullPath = path.join(backupDir, f);
|
|
28
|
+
return { path: fullPath, time: statSync(fullPath).mtimeMs };
|
|
29
|
+
});
|
|
30
|
+
if (backupFiles.length > MAX_BACKUPS) {
|
|
31
|
+
backupFiles.sort((a, b) => a.time - b.time);
|
|
32
|
+
const filesToDelete = backupFiles.slice(0, backupFiles.length - MAX_BACKUPS);
|
|
33
|
+
for (const fileObj of filesToDelete) {
|
|
34
|
+
unlinkSync(fileObj.path);
|
|
35
|
+
logger.info({ fileName: path.basename(fileObj.path) }, "清理过期备份");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
} catch (e) {
|
|
39
|
+
logger.error({ err: e, trigger }, "数据库备份或清理失败");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function startDailyBackupJob() {
|
|
44
|
+
const scheduleNextBackup = () => {
|
|
45
|
+
const now = new Date();
|
|
46
|
+
const nextBackupTime = new Date(
|
|
47
|
+
now.getFullYear(),
|
|
48
|
+
now.getMonth(),
|
|
49
|
+
now.getDate() + 1,
|
|
50
|
+
3, 0, 0, 0
|
|
51
|
+
);
|
|
52
|
+
const delayMs = nextBackupTime.getTime() - now.getTime();
|
|
53
|
+
setTimeout(() => {
|
|
54
|
+
performBackup('cron');
|
|
55
|
+
scheduleNextBackup();
|
|
56
|
+
}, delayMs);
|
|
57
|
+
logger.info({ nextBackupTime: nextBackupTime.toLocaleString() }, "已安排下一次定时备份");
|
|
58
|
+
};
|
|
59
|
+
scheduleNextBackup();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function getDB(): DatabaseSync {
|
|
63
|
+
if (!_db) {
|
|
64
|
+
const dbPath = getDbPath();
|
|
65
|
+
const backupDir = getBackupDir();
|
|
66
|
+
mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
67
|
+
mkdirSync(backupDir, { recursive: true });
|
|
68
|
+
|
|
69
|
+
if (!_backupJobStarted) {
|
|
70
|
+
performBackup('startup');
|
|
71
|
+
startDailyBackupJob();
|
|
72
|
+
_backupJobStarted = true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
_db = new DatabaseSync(dbPath);
|
|
76
|
+
_db.exec("PRAGMA journal_mode = WAL");
|
|
77
|
+
_db.exec("PRAGMA synchronous = NORMAL");
|
|
78
|
+
|
|
79
|
+
// 1. 基础表结构
|
|
80
|
+
_db.exec(`
|
|
81
|
+
CREATE TABLE IF NOT EXISTS watchlist (
|
|
82
|
+
id TEXT PRIMARY KEY,
|
|
83
|
+
userId TEXT NOT NULL,
|
|
84
|
+
stockCode TEXT NOT NULL,
|
|
85
|
+
exchange TEXT NOT NULL,
|
|
86
|
+
market TEXT NOT NULL,
|
|
87
|
+
stockName TEXT NOT NULL,
|
|
88
|
+
sortOrder REAL DEFAULT 0,
|
|
89
|
+
isDeleted INTEGER DEFAULT 0 CHECK (isDeleted IN (0, 1)),
|
|
90
|
+
createdAt DATETIME DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'NOW')),
|
|
91
|
+
updatedAt DATETIME DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'NOW')),
|
|
92
|
+
UNIQUE(userId, stockCode, exchange)
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
CREATE TABLE IF NOT EXISTS global_stock_metadata (
|
|
96
|
+
stockCode TEXT NOT NULL,
|
|
97
|
+
exchange TEXT NOT NULL,
|
|
98
|
+
stockName TEXT NOT NULL,
|
|
99
|
+
industryJson TEXT,
|
|
100
|
+
themeJson TEXT,
|
|
101
|
+
lastUpdated DATETIME DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'NOW')),
|
|
102
|
+
PRIMARY KEY(stockCode, exchange)
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
CREATE INDEX IF NOT EXISTS idx_watchlist_main ON watchlist(userId, isDeleted, sortOrder ASC);
|
|
106
|
+
|
|
107
|
+
CREATE TABLE IF NOT EXISTS watchlist_categories (
|
|
108
|
+
id TEXT PRIMARY KEY,
|
|
109
|
+
remoteId TEXT,
|
|
110
|
+
userId TEXT NOT NULL,
|
|
111
|
+
name TEXT NOT NULL,
|
|
112
|
+
type TEXT NOT NULL CHECK (type IN ('industry', 'theme')),
|
|
113
|
+
weight REAL DEFAULT 0,
|
|
114
|
+
sortOrder REAL DEFAULT 0,
|
|
115
|
+
createdAt DATETIME DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'NOW')),
|
|
116
|
+
updatedAt DATETIME DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'NOW')),
|
|
117
|
+
UNIQUE(userId, remoteId),
|
|
118
|
+
UNIQUE(userId, name, type)
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
CREATE TABLE IF NOT EXISTS watchlist_industry_items (
|
|
122
|
+
id TEXT PRIMARY KEY,
|
|
123
|
+
watchlistId TEXT NOT NULL,
|
|
124
|
+
userId TEXT NOT NULL,
|
|
125
|
+
categoryId TEXT NOT NULL,
|
|
126
|
+
weight REAL DEFAULT 0,
|
|
127
|
+
createdAt DATETIME DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'NOW')),
|
|
128
|
+
UNIQUE(watchlistId, categoryId)
|
|
129
|
+
);
|
|
130
|
+
CREATE INDEX IF NOT EXISTS idx_watchlist_industry_user ON watchlist_industry_items(userId);
|
|
131
|
+
|
|
132
|
+
CREATE TABLE IF NOT EXISTS watchlist_theme_items (
|
|
133
|
+
id TEXT PRIMARY KEY,
|
|
134
|
+
watchlistId TEXT NOT NULL,
|
|
135
|
+
userId TEXT NOT NULL,
|
|
136
|
+
categoryId TEXT NOT NULL,
|
|
137
|
+
weight REAL DEFAULT 0,
|
|
138
|
+
createdAt DATETIME DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'NOW')),
|
|
139
|
+
UNIQUE(watchlistId, categoryId)
|
|
140
|
+
);
|
|
141
|
+
CREATE INDEX IF NOT EXISTS idx_watchlist_theme_user ON watchlist_theme_items(userId);
|
|
142
|
+
`);
|
|
143
|
+
|
|
144
|
+
}
|
|
145
|
+
return _db;
|
|
146
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import pino from 'pino';
|
|
2
|
+
|
|
3
|
+
const isProduction = process.env.NODE_ENV === 'production';
|
|
4
|
+
|
|
5
|
+
export const logger = pino({
|
|
6
|
+
level: process.env.LOG_LEVEL || (isProduction ? 'info' : 'debug'),
|
|
7
|
+
transport: isProduction
|
|
8
|
+
? undefined
|
|
9
|
+
: {
|
|
10
|
+
target: 'pino-pretty',
|
|
11
|
+
options: {
|
|
12
|
+
colorize: true,
|
|
13
|
+
translateTime: 'HH:MM:ss Z',
|
|
14
|
+
ignore: 'pid,hostname',
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
base: {
|
|
18
|
+
name: 'hedgehog-app',
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export default logger;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { watchlistTools } from "./watchlist/tools";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Runtime tool shape used for dynamic RPC dispatch and registerTool.
|
|
5
|
+
*
|
|
6
|
+
* - `label` is required by OpenClaw's AgentTool interface.
|
|
7
|
+
* - `execute` uses bivariant-friendly method syntax so that
|
|
8
|
+
* specific param types (AddToWatchlistParams etc.) are assignable
|
|
9
|
+
* without fighting TypeScript's strict function contravariance.
|
|
10
|
+
*/
|
|
11
|
+
export interface RuntimeTool {
|
|
12
|
+
name: string;
|
|
13
|
+
label?: string;
|
|
14
|
+
description: string;
|
|
15
|
+
parameters: unknown;
|
|
16
|
+
// bivariant method signature — allows specific param types
|
|
17
|
+
execute(params: unknown, ctx: { userId: string }): Promise<string>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Aggregated export of all tools across all features.
|
|
22
|
+
*/
|
|
23
|
+
export const allFeaturesTools: Record<string, RuntimeTool> = {
|
|
24
|
+
...watchlistTools
|
|
25
|
+
};
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { PluginRuntime } from "openclaw/plugin-sdk";
|
|
4
|
+
import { getDB } from "../../core/database";
|
|
5
|
+
import { StockClassification, StockClassificationSchema } from "../../types";
|
|
6
|
+
|
|
7
|
+
interface GlobalStockMetadataRow {
|
|
8
|
+
industryJson: string;
|
|
9
|
+
themeJson: string;
|
|
10
|
+
stockName: string;
|
|
11
|
+
lastUpdated: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 智能分类元数据引擎
|
|
16
|
+
*/
|
|
17
|
+
export const watchlistLogic = {
|
|
18
|
+
/**
|
|
19
|
+
* 获取单只股票的分类与权重(带全局缓存)
|
|
20
|
+
*/
|
|
21
|
+
async getStockClassification(
|
|
22
|
+
rt: PluginRuntime,
|
|
23
|
+
stockName: string,
|
|
24
|
+
stockCode: string,
|
|
25
|
+
exchange: string,
|
|
26
|
+
_userId: string
|
|
27
|
+
): Promise<StockClassification | null> {
|
|
28
|
+
const db = getDB();
|
|
29
|
+
|
|
30
|
+
// 1. 尝试从全局缓存读取
|
|
31
|
+
const cached = db.prepare(`
|
|
32
|
+
SELECT industryJson, themeJson FROM global_stock_metadata
|
|
33
|
+
WHERE stockCode = ? AND exchange = ?
|
|
34
|
+
`).get(stockCode, exchange) as GlobalStockMetadataRow | undefined;
|
|
35
|
+
|
|
36
|
+
if (cached && cached.industryJson) {
|
|
37
|
+
try {
|
|
38
|
+
return {
|
|
39
|
+
industry: JSON.parse(cached.industryJson),
|
|
40
|
+
theme: JSON.parse(cached.themeJson || '[]'),
|
|
41
|
+
weight: 50
|
|
42
|
+
};
|
|
43
|
+
} catch (e) {
|
|
44
|
+
// 容错
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 2. 缓存未命中,调用 AI 进行推断
|
|
49
|
+
const classification = await watchlistLogic._autoClassifyWithAI(rt, stockName, stockCode, exchange);
|
|
50
|
+
|
|
51
|
+
if (classification) {
|
|
52
|
+
// 3. 结果存入全局缓存
|
|
53
|
+
db.prepare(`
|
|
54
|
+
INSERT OR REPLACE INTO global_stock_metadata (stockCode, exchange, stockName, industryJson, themeJson)
|
|
55
|
+
VALUES (?, ?, ?, ?, ?)
|
|
56
|
+
`).run(
|
|
57
|
+
stockCode,
|
|
58
|
+
exchange,
|
|
59
|
+
stockName,
|
|
60
|
+
JSON.stringify(classification.industry),
|
|
61
|
+
JSON.stringify(classification.theme)
|
|
62
|
+
);
|
|
63
|
+
return classification;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return null;
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 批量获取股票分类
|
|
71
|
+
*/
|
|
72
|
+
async getBatchStockClassification(
|
|
73
|
+
rt: PluginRuntime,
|
|
74
|
+
stocks: any[],
|
|
75
|
+
_userId: string
|
|
76
|
+
): Promise<(StockClassification | null)[]> {
|
|
77
|
+
const db = getDB();
|
|
78
|
+
const results = new Array(stocks.length).fill(null);
|
|
79
|
+
const pendingStocks: { idx: number, name: string, code: string, exchange: string }[] = [];
|
|
80
|
+
|
|
81
|
+
stocks.forEach((s, i) => {
|
|
82
|
+
const cached = db.prepare(`SELECT industryJson, themeJson FROM global_stock_metadata WHERE stockCode = ? AND exchange = ?`).get(s.stockCode, s.exchange) as GlobalStockMetadataRow | undefined;
|
|
83
|
+
if (cached && cached.industryJson) {
|
|
84
|
+
try {
|
|
85
|
+
results[i] = {
|
|
86
|
+
industry: JSON.parse(cached.industryJson),
|
|
87
|
+
theme: JSON.parse(cached.themeJson || '[]'),
|
|
88
|
+
weight: 50
|
|
89
|
+
};
|
|
90
|
+
} catch (e) {
|
|
91
|
+
pendingStocks.push({ idx: i, name: s.stockName, code: s.stockCode, exchange: s.exchange });
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
pendingStocks.push({ idx: i, name: s.stockName, code: s.stockCode, exchange: s.exchange });
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
if (pendingStocks.length > 0) {
|
|
99
|
+
const CHUNK_SIZE = 5;
|
|
100
|
+
const chunks: (typeof pendingStocks)[] = [];
|
|
101
|
+
for (let i = 0; i < pendingStocks.length; i += CHUNK_SIZE) {
|
|
102
|
+
chunks.push(pendingStocks.slice(i, i + CHUNK_SIZE));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const cats = watchlistLogic._getKnownCategories(db);
|
|
106
|
+
if (cats.industries.length === 0) return results;
|
|
107
|
+
|
|
108
|
+
await Promise.all(chunks.map(async (chunk) => {
|
|
109
|
+
const stocksList = chunk.map(s => `- ${s.name} (${s.code})`).join("\n");
|
|
110
|
+
const prompt = watchlistLogic._buildAiPrompt(cats.industries, cats.themes, stocksList, true);
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const aiText = await watchlistLogic._callEmbeddedAi(rt, `classify-batch-${chunk[0].code}`, prompt, 120000);
|
|
114
|
+
const jsonMatch = aiText.match(/\[.*\]/s);
|
|
115
|
+
if (jsonMatch) {
|
|
116
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
117
|
+
chunk.forEach((ps, i) => {
|
|
118
|
+
const raw = parsed[i];
|
|
119
|
+
if (raw && Array.isArray(raw.category)) {
|
|
120
|
+
const industryItem = raw.category.find((c: any) => cats.industries.includes(watchlistLogic._anchorToCategory(c.name, cats.industries)));
|
|
121
|
+
const themesItems = raw.category.filter((c: any) => c !== industryItem);
|
|
122
|
+
|
|
123
|
+
if (industryItem) {
|
|
124
|
+
const data: StockClassification = {
|
|
125
|
+
industry: {
|
|
126
|
+
name: watchlistLogic._anchorToCategory(industryItem.name, cats.industries),
|
|
127
|
+
weight: industryItem.weight || 100
|
|
128
|
+
},
|
|
129
|
+
theme: themesItems.map((t: any) => ({
|
|
130
|
+
name: watchlistLogic._anchorToCategory(t.name, cats.themes),
|
|
131
|
+
weight: t.weight || 0
|
|
132
|
+
})),
|
|
133
|
+
weight: 50
|
|
134
|
+
};
|
|
135
|
+
results[ps.idx] = data;
|
|
136
|
+
db.prepare(`INSERT OR REPLACE INTO global_stock_metadata (stockCode, exchange, stockName, industryJson, themeJson) VALUES (?, ?, ?, ?, ?)`)
|
|
137
|
+
.run(ps.code, ps.exchange, ps.name, JSON.stringify(data.industry), JSON.stringify(data.theme));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
} catch (e) {
|
|
143
|
+
console.error(`[Watchlist] 分块 AI 分类失败:`, e);
|
|
144
|
+
}
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
return results;
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
_getKnownCategories(db: any) {
|
|
151
|
+
const industries = db.prepare("SELECT name FROM watchlist_categories WHERE type = 'industry'").all() as any[];
|
|
152
|
+
const themes = db.prepare("SELECT name FROM watchlist_categories WHERE type = 'theme'").all() as any[];
|
|
153
|
+
return {
|
|
154
|
+
industries: industries.map(i => i.name),
|
|
155
|
+
themes: themes.map(t => t.name)
|
|
156
|
+
};
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 内部 AI 实现 (适配新协议)
|
|
161
|
+
*/
|
|
162
|
+
async _autoClassifyWithAI(
|
|
163
|
+
rt: PluginRuntime,
|
|
164
|
+
stockName: string,
|
|
165
|
+
stockCode: string,
|
|
166
|
+
exchange: string
|
|
167
|
+
): Promise<StockClassification | null> {
|
|
168
|
+
const db = getDB();
|
|
169
|
+
const cats = watchlistLogic._getKnownCategories(db);
|
|
170
|
+
|
|
171
|
+
if (cats.industries.length === 0 || cats.themes.length === 0) {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const prompt = watchlistLogic._buildAiPrompt(cats.industries, cats.themes, `${stockName} (${stockCode})`, false);
|
|
176
|
+
const aiText = await watchlistLogic._callEmbeddedAi(rt, `classify-${stockCode}`, prompt, 60000);
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
const jsonMatch = aiText.match(/\[.*\]/s);
|
|
180
|
+
if (jsonMatch) {
|
|
181
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
182
|
+
const raw = parsed[0]; // 单只股票也是数组格式
|
|
183
|
+
|
|
184
|
+
if (raw && Array.isArray(raw.category)) {
|
|
185
|
+
const industryItem = raw.category.find((c: any) => cats.industries.includes(watchlistLogic._anchorToCategory(c.name, cats.industries)));
|
|
186
|
+
const themesItems = raw.category.filter((c: any) => c !== industryItem);
|
|
187
|
+
|
|
188
|
+
if (industryItem) {
|
|
189
|
+
const data: StockClassification = {
|
|
190
|
+
industry: {
|
|
191
|
+
name: watchlistLogic._anchorToCategory(industryItem.name, cats.industries),
|
|
192
|
+
weight: industryItem.weight || 100
|
|
193
|
+
},
|
|
194
|
+
theme: themesItems.map((t: any) => ({
|
|
195
|
+
name: watchlistLogic._anchorToCategory(t.name, cats.themes),
|
|
196
|
+
weight: t.weight || 0
|
|
197
|
+
})),
|
|
198
|
+
weight: 50
|
|
199
|
+
};
|
|
200
|
+
return data;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
} catch (e) {
|
|
205
|
+
console.error("[Watchlist] AI 分类解析异常:", e);
|
|
206
|
+
}
|
|
207
|
+
return null;
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
async _callEmbeddedAi(rt: PluginRuntime, sessionId: string, prompt: string, timeoutMs: number): Promise<string> {
|
|
211
|
+
const fullCfg = rt.config.loadConfig();
|
|
212
|
+
const workspaceDir = rt.agent.resolveAgentWorkspaceDir(fullCfg, "hedgehog-workspace");
|
|
213
|
+
const sessionFile = path.join(workspaceDir, "data", "sessions", `${sessionId}.json`);
|
|
214
|
+
let provider: string | undefined;
|
|
215
|
+
let model: string | undefined;
|
|
216
|
+
const resolveModelRef = (agentId: string): string | null => {
|
|
217
|
+
const agent = fullCfg.agents?.list?.find(a => a.id === agentId);
|
|
218
|
+
const modelCfg = agent?.model || fullCfg.agents?.defaults?.model;
|
|
219
|
+
if (!modelCfg) return null;
|
|
220
|
+
if (typeof modelCfg === 'string') return modelCfg;
|
|
221
|
+
return (modelCfg as { primary?: string }).primary || null;
|
|
222
|
+
};
|
|
223
|
+
const modelRef = resolveModelRef("hedgehog-workspace") || resolveModelRef(fullCfg.agents?.list?.[0]?.id || "");
|
|
224
|
+
if (modelRef) {
|
|
225
|
+
const parts = modelRef.split('/');
|
|
226
|
+
if (parts.length >= 2) {
|
|
227
|
+
provider = parts[0];
|
|
228
|
+
model = parts.slice(1).join('/');
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const result = await rt.agent.runEmbeddedAgent({
|
|
232
|
+
sessionId, runId: randomUUID(), timeoutMs: 30000,
|
|
233
|
+
provider: provider || String(rt.agent.defaults.provider),
|
|
234
|
+
model: model || String(rt.agent.defaults.model),
|
|
235
|
+
workspaceDir, sessionFile, prompt,
|
|
236
|
+
bootstrapContextMode: "lightweight",
|
|
237
|
+
extraSystemPrompt: "你是一个金融专家,只输出纯 JSON。请基于公司主营业务进行客观分类,确保相同逻辑下结果唯一。绝对禁止输出任何推理过程。"
|
|
238
|
+
});
|
|
239
|
+
return result.meta.finalAssistantVisibleText || "";
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
_ensureCategory(db: any, name: string, type: 'industry' | 'theme', userId: string): string {
|
|
243
|
+
const existing = db.prepare("SELECT id FROM watchlist_categories WHERE userId = ? AND name = ? AND type = ?").get(userId, name, type) as { id: string } | undefined;
|
|
244
|
+
if (existing) return existing.id;
|
|
245
|
+
const maxOrderRow = db.prepare("SELECT MAX(sortOrder) as max FROM watchlist_categories WHERE userId = ?").get(userId) as { max: number } | undefined;
|
|
246
|
+
const nextOrder = (maxOrderRow?.max || 0) + 10;
|
|
247
|
+
const id = randomUUID();
|
|
248
|
+
db.prepare(`INSERT INTO watchlist_categories (id, userId, name, type, sortOrder, weight) VALUES (?, ?, ?, ?, ?, 0)`).run(id, userId, name, type, nextOrder);
|
|
249
|
+
return id;
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
_buildSmartSortPrompt(stocks: { name: string, code: string }[]): string {
|
|
253
|
+
return JSON.stringify({
|
|
254
|
+
cw_content: `# 股票智能排序\n## 股票列表\n${JSON.stringify(stocks)}\n## 指令和要求\n根据股票被提及的热度(30%)、总市值(30%)、用户记忆中近两周提及该股票的次数(30%)、最近一周该股票的波动性(10%)进行加权综合排序,总权重(最高100分)高的排在前面。\n`,
|
|
255
|
+
cw_output: `严格按照JSON格式输出:\n\n[\n { "code": "000000", \n "name": "xxxx",\n "weight": 0\n }\n]\n`
|
|
256
|
+
}, null, 2);
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
async applySmartSort(rt: PluginRuntime, sessionId: string, stocks: { name: string, code: string }[]): Promise<any[]> {
|
|
260
|
+
const prompt = watchlistLogic._buildSmartSortPrompt(stocks);
|
|
261
|
+
const aiText = await watchlistLogic._callEmbeddedAi(rt, `smart-sort-${sessionId}`, prompt, 60000);
|
|
262
|
+
const jsonMatch = aiText.match(/\[.*\]/s);
|
|
263
|
+
if (jsonMatch) {
|
|
264
|
+
try { return JSON.parse(jsonMatch[0]); } catch (e) { return []; }
|
|
265
|
+
}
|
|
266
|
+
return [];
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
_buildAiPrompt(industries: string[], themes: string[], input: string, isBatch: boolean): string {
|
|
270
|
+
let stocksJson = input;
|
|
271
|
+
if (isBatch) {
|
|
272
|
+
const stocks = input.split('\n').filter(line => line.trim()).map(line => {
|
|
273
|
+
const match = line.match(/-\s*(.*?)\s*\((.*?)\)/);
|
|
274
|
+
return match ? { name: match[1], code: match[2] } : null;
|
|
275
|
+
}).filter(Boolean);
|
|
276
|
+
stocksJson = JSON.stringify(stocks);
|
|
277
|
+
} else {
|
|
278
|
+
const match = input.match(/(.*?)\s*\((.*?)\)/);
|
|
279
|
+
stocksJson = JSON.stringify([match ? { name: match[1], code: match[2] } : { name: input, code: "" }]);
|
|
280
|
+
}
|
|
281
|
+
return JSON.stringify({
|
|
282
|
+
cw_context: `**行业分类**: [${industries.join(", ")}]\n**主题分类**: [${themes.join(", ")}]\n`,
|
|
283
|
+
cw_content: `# 股票智能分类\n## 股票列表\n${stocksJson}\n## 指令和要求\n根据其基本面和金融市场知识,把上面\`股票列表\`中的每个股票归属的行业分类和主题分类提取出来,并根据相关性给出权重值(0-100的整数,相关性越高分数越高)。\n\`行业分类\`和\`主题分类\`必须在上下文中提供的列表内选择,不得自己创建词汇。每个股票只能且必须选择一个行业分类,每个股票可以选择0~2个主题分类,不是必选的,不要刻意去选择相关性不高的主题分类。\n股票的\`行业分类\`和\`主题分类\`统称为\`分类Category\`,输出时可以放在一个Category数组中。\n`,
|
|
284
|
+
cw_output: `严格按照JSON格式输出:\n\n[\n { "code": "000000", \n "name": "xxxx",\n "category": [{"name": "xxx", "weight": 80}, {"name": "xxx", "weight": 0}]\n }\n]\n`
|
|
285
|
+
}, null, 2);
|
|
286
|
+
},
|
|
287
|
+
|
|
288
|
+
_anchorToCategory(value: string, categories: string[]): string {
|
|
289
|
+
if (!value || categories.length === 0) return "其他";
|
|
290
|
+
const trimmed = value.trim();
|
|
291
|
+
const exact = categories.find(c => c === trimmed);
|
|
292
|
+
if (exact) return exact;
|
|
293
|
+
const candidates = categories.filter(c => c.includes(trimmed) || trimmed.includes(c));
|
|
294
|
+
if (candidates.length === 1) return candidates[0];
|
|
295
|
+
return "其他";
|
|
296
|
+
}
|
|
297
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { z } from "openclaw/plugin-sdk/zod";
|
|
2
|
+
|
|
3
|
+
export const GetWatchlistParamsSchema = z.object({
|
|
4
|
+
categoryId: z.string().optional().describe("分类 ID,不传返回所有"),
|
|
5
|
+
categoryType: z.enum(["industry", "theme"]).optional().describe("分类类型")
|
|
6
|
+
});
|
|
7
|
+
export type GetWatchlistParams = z.infer<typeof GetWatchlistParamsSchema>;
|
|
8
|
+
|
|
9
|
+
export const SyncCategoriesParamsSchema = z.object({
|
|
10
|
+
industries: z.array(z.string()).optional(),
|
|
11
|
+
themes: z.array(z.string()).optional()
|
|
12
|
+
});
|
|
13
|
+
export type SyncCategoriesParams = z.infer<typeof SyncCategoriesParamsSchema>;
|
|
14
|
+
|
|
15
|
+
export const BatchUpdateSortOrdersParamsSchema = z.object({
|
|
16
|
+
orderedIds: z.array(z.string())
|
|
17
|
+
});
|
|
18
|
+
export type BatchUpdateSortOrdersParams = z.infer<typeof BatchUpdateSortOrdersParamsSchema>;
|
|
19
|
+
|
|
20
|
+
const ExchangeEnum = z.enum(["SSE", "SZSE", "NASDAQ", "NYSE", "AMEX", "HKEX"]);
|
|
21
|
+
const MarketEnum = z.enum(["A_SHARE", "US_SHARE", "HK_SHARE", "FUTURES", "FUND", "OTHER"]);
|
|
22
|
+
|
|
23
|
+
export const AddToWatchlistParamsSchema = z.object({
|
|
24
|
+
stockCode: z.string(),
|
|
25
|
+
stockName: z.string(),
|
|
26
|
+
exchange: ExchangeEnum,
|
|
27
|
+
market: MarketEnum,
|
|
28
|
+
// 支持:1. 字符串 "白酒" 2. 数组 ["白酒"] 3. 带权重的对象 { name: "白酒", weight: 99 }
|
|
29
|
+
industry: z.union([z.string(), z.array(z.string()), z.any()]).optional(),
|
|
30
|
+
theme: z.union([z.string(), z.array(z.string()), z.any()]).optional()
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export type AddToWatchlistParams = z.infer<typeof AddToWatchlistParamsSchema>;
|
|
34
|
+
|
|
35
|
+
export const BatchAddToWatchlistParamsSchema = z.object({
|
|
36
|
+
stocks: z.array(AddToWatchlistParamsSchema)
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export type BatchAddToWatchlistParams = z.infer<typeof BatchAddToWatchlistParamsSchema>;
|
|
40
|
+
|
|
41
|
+
export const UpdateWatchlistItemSchema = z.object({
|
|
42
|
+
id: z.string(),
|
|
43
|
+
stockName: z.string().optional(),
|
|
44
|
+
sortOrder: z.number().optional()
|
|
45
|
+
});
|
|
46
|
+
export type UpdateWatchlistItemParams = z.infer<typeof UpdateWatchlistItemSchema>;
|
|
47
|
+
|
|
48
|
+
export interface WatchlistRow {
|
|
49
|
+
id: string;
|
|
50
|
+
stockCode: string;
|
|
51
|
+
stockName: string;
|
|
52
|
+
exchange: string;
|
|
53
|
+
market?: string;
|
|
54
|
+
userId: string;
|
|
55
|
+
sortOrder: number;
|
|
56
|
+
isDeleted: number;
|
|
57
|
+
createdAt: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface CategoryRow {
|
|
61
|
+
id: string;
|
|
62
|
+
name: string;
|
|
63
|
+
type?: 'industry' | 'theme';
|
|
64
|
+
weight?: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type TagInput = string | string[] | { name: string; weight?: number } | { name: string; weight?: number }[];
|