dgxay-code-cli 0.0.0-stage → 1.1.0
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 +674 -0
- package/README-zh.md +242 -0
- package/README.md +223 -2
- package/bin/dgxay.js +42 -0
- package/package.json +47 -4
- package/src/cli/ansi.js +539 -0
- package/src/cli/approval.js +186 -0
- package/src/cli/args.js +162 -0
- package/src/cli/command-table.js +52 -0
- package/src/cli/editor.js +828 -0
- package/src/cli/i18n.js +276 -0
- package/src/cli/index.js +228 -0
- package/src/cli/print.js +148 -0
- package/src/cli/render.js +395 -0
- package/src/cli/repl-state.js +85 -0
- package/src/cli/repl.js +1231 -0
- package/src/cli/slash.js +75 -0
- package/src/cli/ui.js +52 -0
- package/src/commands/_shared.js +20 -0
- package/src/commands/chat.js +212 -0
- package/src/commands/config.js +363 -0
- package/src/commands/context.js +43 -0
- package/src/commands/doctor.js +192 -0
- package/src/commands/export.js +102 -0
- package/src/commands/mcp.js +257 -0
- package/src/commands/model.js +223 -0
- package/src/commands/oauth.js +246 -0
- package/src/commands/permission.js +108 -0
- package/src/commands/provider.js +332 -0
- package/src/commands/run.js +172 -0
- package/src/commands/skill.js +94 -0
- package/src/commands/workspace.js +100 -0
- package/src/core/agent.js +420 -0
- package/src/core/llm.js +709 -0
- package/src/core/mcp.js +271 -0
- package/src/core/oauth.js +737 -0
- package/src/core/prompt.js +269 -0
- package/src/core/sessions.js +208 -0
- package/src/core/tools.js +391 -0
- package/src/services/config.js +182 -0
- package/src/services/input.js +91 -0
- package/src/services/session-bus.js +104 -0
- package/src/services/settings.js +340 -0
- package/src/services/skills.js +125 -0
- package/src/services/terminal.js +124 -0
- package/src/services/version.js +25 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 系统提示与安全审查(与桌面版 main.js 中的逻辑一一对应)。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { streamChat } = require('./llm');
|
|
8
|
+
const oauth = require('./oauth');
|
|
9
|
+
|
|
10
|
+
const REVIEW_TIMEOUT = 25000;
|
|
11
|
+
|
|
12
|
+
const REVIEW_SYSTEM = [
|
|
13
|
+
'You are the local-operation safety reviewer for DgxayCode. Before the AI assistant is allowed to act on the user\'s machine, you decide whether an operation could harm the computer or leak data.',
|
|
14
|
+
'Output JSON only, no code fences: {"decision":"allow","reason":"..."} or {"decision":"review","reason":"..."}',
|
|
15
|
+
'allow: reading files directly relevant to the task, writing ordinary text files inside the workspace, listing directories, read-only queries, fetching public documentation.',
|
|
16
|
+
'review: deleting or overwriting important files, touching system or user directories outside the workspace, installing/uninstalling/elevating/services/bulk scripts, reading .ssh/.env/keys/browser data, uploading local data, ambiguous commands or targets, anything irreversible.',
|
|
17
|
+
'When unsure, choose review. Keep the reason under 60 characters.'
|
|
18
|
+
].join('\n');
|
|
19
|
+
|
|
20
|
+
function systemPromptFor({ mode, permission, workspace, customPrompt, skill }) {
|
|
21
|
+
const parts = [];
|
|
22
|
+
parts.push(
|
|
23
|
+
'You are the AI assistant built into the DgxayCode command-line tool. Always answer in English unless the user writes in another language. Be clear and concise, and use Markdown where it helps.'
|
|
24
|
+
);
|
|
25
|
+
if (skill?.content) {
|
|
26
|
+
parts.push(`[Skill enabled for this conversation: ${skill.name}]\n${skill.content}`);
|
|
27
|
+
}
|
|
28
|
+
if (customPrompt && String(customPrompt).trim()) {
|
|
29
|
+
parts.push(`User instructions:\n${String(customPrompt).trim()}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (mode !== 'work') {
|
|
33
|
+
parts.push('You are in "chat mode": no filesystem or shell tools are available. Answer in prose.');
|
|
34
|
+
if (permission !== 'none') {
|
|
35
|
+
parts.push(
|
|
36
|
+
'If the user explicitly asks to install an MCP server you may call install_mcp_server to save the launch configuration, then tell them to run dgxay mcp start.'
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
return parts.join('\n\n');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
parts.push(`You are in "work mode". Workspace directory: ${workspace || '(not selected)'}.`);
|
|
43
|
+
if (permission === 'none') {
|
|
44
|
+
parts.push(
|
|
45
|
+
'Permission level: none. You may only read files inside the workspace. Writing, creating, deleting, network access and shell commands are all forbidden. ' +
|
|
46
|
+
'If the user asks for any of those, explain that they need to switch to normal, auto-review or full permission.'
|
|
47
|
+
);
|
|
48
|
+
} else if (permission === 'normal') {
|
|
49
|
+
parts.push(
|
|
50
|
+
'Permission level: normal. You have every capability: read/write any file, run shell commands, access the network. ' +
|
|
51
|
+
'Every tool call requires explicit user approval in the terminal first; if the user declines, do not retry — answer with what you already know.'
|
|
52
|
+
);
|
|
53
|
+
} else if (permission === 'auto') {
|
|
54
|
+
parts.push(
|
|
55
|
+
'Permission level: auto review. You have every capability: read/write any file, run shell commands, access the network. ' +
|
|
56
|
+
'Each operation is first judged by the reviewer model; safe ones run automatically and suspicious ones are handed to the user. If declined, do not retry.'
|
|
57
|
+
);
|
|
58
|
+
} else {
|
|
59
|
+
parts.push(
|
|
60
|
+
'Permission level: full access. You may run shell commands, touch any file and access the network without review. Confirm destructive actions with the user first.'
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
parts.push(
|
|
65
|
+
'MCP: if the user explicitly asks to install an MCP server, call install_mcp_server to save it, then tell them to run dgxay mcp start (or use /mcp). Never fabricate the result.'
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
if (workspace) {
|
|
69
|
+
parts.push(
|
|
70
|
+
[
|
|
71
|
+
'Work-mode rules:',
|
|
72
|
+
'1. Whenever you need to inspect or change files, call a tool — never assume file contents. Read before you write.',
|
|
73
|
+
'2. Save every generated file inside the workspace, preferring relative paths.',
|
|
74
|
+
'3. When the task is done, summarize in a short list which files you created or modified.',
|
|
75
|
+
'',
|
|
76
|
+
'[Execution discipline]',
|
|
77
|
+
'• Break the goal into actionable steps and start calling tools immediately. Do not output only a plan or a promise ("I will..." / "Next I will...").',
|
|
78
|
+
'• Do not stop until the task is genuinely finished, and do not wait for the user to say "continue". After each tool result, proceed to the next step automatically.',
|
|
79
|
+
'• When the task needs several reads, writes or commands, run them all before wrapping up; do not stop after one step.',
|
|
80
|
+
'• On tool errors, analyse the cause and retry differently. Only stop when information is missing, permission was denied, or the task truly cannot continue — and state the exact blocker.',
|
|
81
|
+
'• Verify before finishing: re-read key files, check command output, or run the relevant tests.',
|
|
82
|
+
'• Then report what was done and how it was verified. Never end with "let me know if you want me to continue".'
|
|
83
|
+
].join('\n')
|
|
84
|
+
);
|
|
85
|
+
} else {
|
|
86
|
+
parts.push('No workspace is selected: do not call file tools. Ask the user to pick a directory with /cd.');
|
|
87
|
+
}
|
|
88
|
+
return parts.join('\n\n');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function resolveProviderAuth(provider) {
|
|
92
|
+
if (provider?.authMode === 'oauth' && provider.oauthProvider) {
|
|
93
|
+
const token = await oauth.getAccessToken(provider.oauthProvider);
|
|
94
|
+
return { apiKey: token, authType: 'oauth' };
|
|
95
|
+
}
|
|
96
|
+
return { apiKey: provider?.apiKey || '', authType: 'apiKey' };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function summarizeArgs(args) {
|
|
100
|
+
if (args == null) return '';
|
|
101
|
+
if (typeof args !== 'object') return String(args).slice(0, 1800);
|
|
102
|
+
const copy = { ...args };
|
|
103
|
+
if (typeof copy.content === 'string' && copy.content.length > 1200) {
|
|
104
|
+
copy.content = `${copy.content.slice(0, 1200)}… (${copy.content.length} chars total)`;
|
|
105
|
+
}
|
|
106
|
+
let text = JSON.stringify(copy);
|
|
107
|
+
if (text.length > 2000) text = `${text.slice(0, 2000)}…`;
|
|
108
|
+
return text;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 审查模型解析:优先当前供应商,未配置时回退到 providers[0](默认供应商) */
|
|
112
|
+
function resolveReviewer(config, providerId) {
|
|
113
|
+
const usable = (provider) =>
|
|
114
|
+
Boolean(
|
|
115
|
+
provider?.reviewModelId &&
|
|
116
|
+
Array.isArray(provider.models) &&
|
|
117
|
+
provider.models.some((model) => model.id === provider.reviewModelId)
|
|
118
|
+
);
|
|
119
|
+
const own = (config.providers || []).find((provider) => provider.id === providerId);
|
|
120
|
+
if (usable(own)) return { provider: own, modelId: own.reviewModelId, source: 'own' };
|
|
121
|
+
const fallback = (config.providers || [])[0];
|
|
122
|
+
if (usable(fallback)) return { provider: fallback, modelId: fallback.reviewModelId, source: 'default' };
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function hasReviewModel(config) {
|
|
127
|
+
return Boolean(resolveReviewer(config, config?.activeProviderId));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function reviewOperation({ config, providerId, kind, name, args, command, workspace }) {
|
|
131
|
+
const reviewer = resolveReviewer(config, providerId);
|
|
132
|
+
if (!reviewer) {
|
|
133
|
+
return { decision: 'review', reason: 'No review model configured — asking you instead', reviewer: null, modelId: null };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const controller = new AbortController();
|
|
137
|
+
const timer = setTimeout(() => controller.abort(), REVIEW_TIMEOUT);
|
|
138
|
+
try {
|
|
139
|
+
const reviewerAuth = await resolveProviderAuth(reviewer.provider);
|
|
140
|
+
const result = await streamChat({
|
|
141
|
+
protocol: reviewer.provider.protocol || 'openai',
|
|
142
|
+
baseUrl: reviewer.provider.baseUrl,
|
|
143
|
+
apiKey: reviewerAuth.apiKey,
|
|
144
|
+
authType: reviewerAuth.authType,
|
|
145
|
+
model: reviewer.modelId,
|
|
146
|
+
messages: [
|
|
147
|
+
{ role: 'system', content: REVIEW_SYSTEM },
|
|
148
|
+
{
|
|
149
|
+
role: 'user',
|
|
150
|
+
content: [
|
|
151
|
+
`Operation type: ${kind === 'terminal' ? 'shell command' : 'tool call'}`,
|
|
152
|
+
`Operation: ${name}`,
|
|
153
|
+
command ? `Command: ${command}` : '',
|
|
154
|
+
`Workspace: ${workspace || '(none)'}`,
|
|
155
|
+
args ? `Arguments: ${summarizeArgs(args)}` : '',
|
|
156
|
+
'Is this operation safe?'
|
|
157
|
+
]
|
|
158
|
+
.filter(Boolean)
|
|
159
|
+
.join('\n')
|
|
160
|
+
}
|
|
161
|
+
],
|
|
162
|
+
stream: false,
|
|
163
|
+
temperature: 0,
|
|
164
|
+
maxTokens: 320,
|
|
165
|
+
signal: controller.signal
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const text = String(result.content || '').trim();
|
|
169
|
+
let decision = 'review';
|
|
170
|
+
let reason = text.slice(0, 160) || 'The reviewer gave no verdict';
|
|
171
|
+
const left = text.indexOf('{');
|
|
172
|
+
const right = text.lastIndexOf('}');
|
|
173
|
+
if (left !== -1 && right > left) {
|
|
174
|
+
try {
|
|
175
|
+
const parsed = JSON.parse(text.slice(left, right + 1));
|
|
176
|
+
if (String(parsed.decision || '').toLowerCase() === 'allow') decision = 'allow';
|
|
177
|
+
if (parsed.reason) reason = String(parsed.reason).slice(0, 200);
|
|
178
|
+
} catch {
|
|
179
|
+
/* keep review */
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
decision,
|
|
184
|
+
reason,
|
|
185
|
+
reviewer: reviewer.provider.name,
|
|
186
|
+
modelId: reviewer.modelId,
|
|
187
|
+
source: reviewer.source
|
|
188
|
+
};
|
|
189
|
+
} catch (err) {
|
|
190
|
+
// 超时、网络失败或返回无法解析时一律按“可疑”处理,转交用户确认
|
|
191
|
+
return {
|
|
192
|
+
decision: 'review',
|
|
193
|
+
reason: `Auto-review failed: ${err.message}`,
|
|
194
|
+
reviewer: reviewer.provider.name,
|
|
195
|
+
modelId: reviewer.modelId,
|
|
196
|
+
source: reviewer.source
|
|
197
|
+
};
|
|
198
|
+
} finally {
|
|
199
|
+
clearTimeout(timer);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function looksUnfinishedWork(result) {
|
|
204
|
+
const text = String(result?.content || '').trim();
|
|
205
|
+
if (result?.finishReason === 'length') return true;
|
|
206
|
+
if (!text) return true;
|
|
207
|
+
const tail = text.slice(-520);
|
|
208
|
+
return /(我(将|会|马上|这就|现在)(继续|开始|去|来|处理|执行|写入|创建|生成|读取|检查|整理)|接下来(我)?(会|将|继续|需要)|下一步(我)?(会|将)|稍后(会|将|再)|let me (now )?(proceed|continue|start|check)|i(?:'|’)ll (now )?(proceed|continue|start|check|write)|next,? i (?:will|'ll))/i.test(
|
|
209
|
+
tail
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** 上下文压缩(/compress):用一条摘要替换全部历史,有损且不可撤销 */
|
|
214
|
+
async function compressMessages({ config, providerId, modelId, messages }) {
|
|
215
|
+
const provider = (config.providers || []).find((item) => item.id === providerId) || (config.providers || [])[0];
|
|
216
|
+
const targetModel = modelId || provider?.models?.[0]?.id;
|
|
217
|
+
if (!provider || !targetModel) throw new Error('No model available to compress the context');
|
|
218
|
+
|
|
219
|
+
const history = (Array.isArray(messages) ? messages : []).filter(
|
|
220
|
+
(m) => m && ['user', 'assistant'].includes(m.role) && typeof m.content === 'string' && m.content.trim()
|
|
221
|
+
);
|
|
222
|
+
if (!history.length) return { ok: true, summary: '', usage: null };
|
|
223
|
+
|
|
224
|
+
const transcript = history
|
|
225
|
+
.map((message) => `${message.role === 'user' ? 'User' : 'Assistant'}: ${message.content}`)
|
|
226
|
+
.join('\n\n')
|
|
227
|
+
.slice(-150000);
|
|
228
|
+
|
|
229
|
+
const providerAuth = await resolveProviderAuth(provider);
|
|
230
|
+
const result = await streamChat({
|
|
231
|
+
protocol: provider.protocol || 'openai',
|
|
232
|
+
baseUrl: provider.baseUrl,
|
|
233
|
+
apiKey: providerAuth.apiKey,
|
|
234
|
+
authType: providerAuth.authType,
|
|
235
|
+
model: targetModel,
|
|
236
|
+
messages: [
|
|
237
|
+
{
|
|
238
|
+
role: 'system',
|
|
239
|
+
content: 'You compress conversations into a summary that lets work continue. Output only the summary body: no greeting, no code fences.'
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
role: 'user',
|
|
243
|
+
content:
|
|
244
|
+
'Compress the conversation below. Keep: user goals and preferences, key facts and numbers, decisions made, file paths and code locations, open items, and important tool results. Drop greetings, repetition and noise. Write in English, at most 800 words.\n\n' +
|
|
245
|
+
transcript
|
|
246
|
+
}
|
|
247
|
+
],
|
|
248
|
+
stream: false,
|
|
249
|
+
temperature: 0.2,
|
|
250
|
+
maxTokens: Math.min(2048, Number(config.chat?.maxTokens) || 2048)
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const summary = String(result.content || '').trim();
|
|
254
|
+
if (!summary) throw new Error('The model returned no summary, try again later');
|
|
255
|
+
return { ok: true, summary, usage: result.usage || null };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
module.exports = {
|
|
259
|
+
REVIEW_SYSTEM,
|
|
260
|
+
REVIEW_TIMEOUT,
|
|
261
|
+
systemPromptFor,
|
|
262
|
+
resolveProviderAuth,
|
|
263
|
+
summarizeArgs,
|
|
264
|
+
resolveReviewer,
|
|
265
|
+
hasReviewModel,
|
|
266
|
+
reviewOperation,
|
|
267
|
+
looksUnfinishedWork,
|
|
268
|
+
compressMessages
|
|
269
|
+
};
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 会话存储:~/.dcb/chats.json
|
|
5
|
+
*
|
|
6
|
+
* 存储结构兼容桌面版,便于两端共用同一个数据目录:
|
|
7
|
+
* { id, title, mode, skill, createdAt, updatedAt, messages: [{role, content, ts, tools, files, ...}] }
|
|
8
|
+
*
|
|
9
|
+
* 最多保留 200 段对话(超出丢弃最旧的);写入使用原子替换。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const os = require('os');
|
|
13
|
+
const { loadChats, saveChats } = require('../services/config');
|
|
14
|
+
|
|
15
|
+
const MAX_CHATS = 200;
|
|
16
|
+
|
|
17
|
+
function newId(prefix = 'chat') {
|
|
18
|
+
return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function titleFrom(text) {
|
|
22
|
+
const cleaned = String(text || '')
|
|
23
|
+
.replace(/\s+/g, ' ')
|
|
24
|
+
.trim();
|
|
25
|
+
if (!cleaned) return 'New conversation';
|
|
26
|
+
return cleaned.length > 26 ? `${cleaned.slice(0, 26)}…` : cleaned;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizeChat(chat) {
|
|
30
|
+
if (!chat || typeof chat !== 'object') return null;
|
|
31
|
+
return {
|
|
32
|
+
id: String(chat.id || newId()),
|
|
33
|
+
title: String(chat.title || 'New conversation'),
|
|
34
|
+
mode: chat.mode === 'work' ? 'work' : 'chat',
|
|
35
|
+
skill: typeof chat.skill === 'string' ? chat.skill : '',
|
|
36
|
+
createdAt: Number(chat.createdAt) || Date.now(),
|
|
37
|
+
updatedAt: Number(chat.updatedAt) || Date.now(),
|
|
38
|
+
messages: Array.isArray(chat.messages) ? chat.messages.filter((m) => m && m.role) : []
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function sortChats(chats) {
|
|
43
|
+
return chats.slice().sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 读取全部会话(按更新时间倒序) */
|
|
47
|
+
function listChats() {
|
|
48
|
+
return sortChats(loadChats().map(normalizeChat).filter(Boolean));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getChat(id) {
|
|
52
|
+
if (!id) return null;
|
|
53
|
+
return listChats().find((chat) => chat.id === id) || null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 按序号(1 = 最新)或 id 前缀查找 */
|
|
57
|
+
function resolveChat(ref, chats = listChats()) {
|
|
58
|
+
if (!ref) return null;
|
|
59
|
+
const text = String(ref).trim();
|
|
60
|
+
const exact = chats.find((chat) => chat.id === text);
|
|
61
|
+
if (exact) return exact;
|
|
62
|
+
const index = Number(text);
|
|
63
|
+
if (Number.isInteger(index) && index >= 1 && index <= chats.length) return chats[index - 1];
|
|
64
|
+
const prefixMatches = chats.filter((chat) => chat.id.startsWith(text));
|
|
65
|
+
if (prefixMatches.length === 1) return prefixMatches[0];
|
|
66
|
+
if (prefixMatches.length > 1) {
|
|
67
|
+
const error = new Error(`Ambiguous id "${text}" matches ${prefixMatches.length} conversations; use a longer id`);
|
|
68
|
+
error.code = 'AMBIGUOUS';
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 保存单段会话(插入或更新),返回保存后的会话 */
|
|
75
|
+
function saveChat(chat) {
|
|
76
|
+
const normalized = normalizeChat(chat);
|
|
77
|
+
if (!normalized) throw new Error('Invalid conversation');
|
|
78
|
+
if (!normalized.messages.length) return normalized;
|
|
79
|
+
const chats = listChats();
|
|
80
|
+
const index = chats.findIndex((item) => item.id === normalized.id);
|
|
81
|
+
normalized.updatedAt = Date.now();
|
|
82
|
+
if (index === -1) chats.unshift(normalized);
|
|
83
|
+
else chats[index] = normalized;
|
|
84
|
+
const trimmed = sortChats(chats).slice(0, MAX_CHATS);
|
|
85
|
+
saveChats(trimmed);
|
|
86
|
+
return normalized;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function deleteChat(id) {
|
|
90
|
+
const chats = listChats();
|
|
91
|
+
const next = chats.filter((chat) => chat.id !== id);
|
|
92
|
+
if (next.length === chats.length) return false;
|
|
93
|
+
saveChats(next.length ? next : []);
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function clearChats() {
|
|
98
|
+
return saveChats([]);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function createChat(overrides = {}) {
|
|
102
|
+
return {
|
|
103
|
+
id: newId('chat'),
|
|
104
|
+
title: 'New conversation',
|
|
105
|
+
mode: overrides.mode === 'work' ? 'work' : 'chat',
|
|
106
|
+
skill: typeof overrides.skill === 'string' ? overrides.skill : '',
|
|
107
|
+
createdAt: Date.now(),
|
|
108
|
+
updatedAt: Date.now(),
|
|
109
|
+
messages: []
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/* ------------------------------ 历史转换 ------------------------------ */
|
|
114
|
+
|
|
115
|
+
/** 发送给模型的消息:只保留 user / assistant 且有内容的部分(与桌面版 buildHistory 等价) */
|
|
116
|
+
function buildHistory(chat) {
|
|
117
|
+
return (chat?.messages || [])
|
|
118
|
+
.map((msg) => {
|
|
119
|
+
if (!msg || !msg.role) return null;
|
|
120
|
+
if (msg.compressed) return { role: 'user', content: `[Context summary]\n${String(msg.content || '').replace(/^> Context compressed[^\n]*\n+/, '')}` };
|
|
121
|
+
if (msg.role === 'terminal') {
|
|
122
|
+
const output = `${msg.output || ''}${msg.stderr ? `\n${msg.stderr}` : ''}`.slice(0, 4000);
|
|
123
|
+
return {
|
|
124
|
+
role: 'user',
|
|
125
|
+
content: `[终端命令] ${msg.command}\n[输出]\n${output}\n[退出码] ${
|
|
126
|
+
msg.exitCode ?? (msg.status === 'running' ? 'running' : 'unknown')
|
|
127
|
+
}`
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
if (msg.role !== 'user' && msg.role !== 'assistant') return null;
|
|
131
|
+
if (!msg.content || msg.error) return null;
|
|
132
|
+
return { role: msg.role, content: msg.content };
|
|
133
|
+
})
|
|
134
|
+
.filter(Boolean);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 每次请求只发送最近 N 条消息(与桌面版一致的 24 条) */
|
|
138
|
+
function trimHistory(history, limit = 24) {
|
|
139
|
+
const list = Array.isArray(history) ? history : [];
|
|
140
|
+
return limit > 0 ? list.slice(-limit) : list;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* ------------------------------ token 估算 ------------------------------ */
|
|
144
|
+
|
|
145
|
+
function estimateTokens(text) {
|
|
146
|
+
if (!text) return 0;
|
|
147
|
+
let total = 0;
|
|
148
|
+
for (const char of String(text)) {
|
|
149
|
+
if (/[\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff]/.test(char)) total += 1;
|
|
150
|
+
else if (/\s/.test(char)) total += 0.25;
|
|
151
|
+
else total += 0.28;
|
|
152
|
+
}
|
|
153
|
+
return Math.ceil(total);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function contextLimit(model) {
|
|
157
|
+
const configured = Number(model?.contextWindow);
|
|
158
|
+
if (configured > 0) return configured;
|
|
159
|
+
if (model?.protocol === 'anthropic' || model?.protocol === 'responses') return 200000;
|
|
160
|
+
return 128000;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function estimateChatTokens(chat) {
|
|
164
|
+
let total = 700;
|
|
165
|
+
if (!chat) return total;
|
|
166
|
+
for (const msg of chat.messages || []) {
|
|
167
|
+
if (msg.role === 'terminal') {
|
|
168
|
+
total += estimateTokens(msg.command) + estimateTokens(msg.output) + estimateTokens(msg.stderr) + 12;
|
|
169
|
+
} else {
|
|
170
|
+
total += estimateTokens(msg.content) + 6;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (chat.skill) total += 1200;
|
|
174
|
+
return Math.round(total);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function formatTokens(value) {
|
|
178
|
+
const number = Math.max(0, Math.round(Number(value) || 0));
|
|
179
|
+
if (number >= 10000) return `${Math.round(number / 1000)}k`;
|
|
180
|
+
if (number >= 1000) return `${(number / 1000).toFixed(1)}k`;
|
|
181
|
+
return String(number);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function homeShort(path) {
|
|
185
|
+
const home = os.homedir();
|
|
186
|
+
if (!path) return '';
|
|
187
|
+
return String(path).startsWith(home) ? `~${String(path).slice(home.length)}` : String(path);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
module.exports = {
|
|
191
|
+
MAX_CHATS,
|
|
192
|
+
newId,
|
|
193
|
+
titleFrom,
|
|
194
|
+
listChats,
|
|
195
|
+
getChat,
|
|
196
|
+
resolveChat,
|
|
197
|
+
saveChat,
|
|
198
|
+
deleteChat,
|
|
199
|
+
clearChats,
|
|
200
|
+
createChat,
|
|
201
|
+
buildHistory,
|
|
202
|
+
trimHistory,
|
|
203
|
+
estimateTokens,
|
|
204
|
+
estimateChatTokens,
|
|
205
|
+
contextLimit,
|
|
206
|
+
formatTokens,
|
|
207
|
+
homeShort
|
|
208
|
+
};
|