cctra 0.3.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 +21 -0
- package/README.md +135 -0
- package/bin/cctra +2 -0
- package/bin/cctra-daemon.exe +0 -0
- package/bin/cctra.js +2 -0
- package/examples/plugins/oauth-internal.js +46 -0
- package/examples/plugins/openai-compatible.js +27 -0
- package/package.json +53 -0
- package/src/canonical/types.ts +132 -0
- package/src/commands/add.ts +159 -0
- package/src/commands/daemon.ts +102 -0
- package/src/commands/ls.ts +49 -0
- package/src/commands/model.ts +95 -0
- package/src/commands/plugin.ts +167 -0
- package/src/commands/rename.ts +33 -0
- package/src/commands/rm.ts +37 -0
- package/src/commands/serve.ts +29 -0
- package/src/commands/shared.ts +14 -0
- package/src/commands/show.ts +46 -0
- package/src/commands/tier.ts +91 -0
- package/src/convert/common/content-blocks.ts +29 -0
- package/src/convert/common/extras.ts +38 -0
- package/src/convert/common/reasoning.ts +19 -0
- package/src/convert/common/system-prompt.ts +15 -0
- package/src/convert/common/tool-calls.ts +29 -0
- package/src/convert/common/usage.ts +19 -0
- package/src/convert/inbound/anthropic-to-canonical.ts +106 -0
- package/src/convert/inbound/chat-to-canonical.ts +132 -0
- package/src/convert/inbound/responses-to-canonical.ts +92 -0
- package/src/convert/outbound/canonical-to-anthropic.ts +62 -0
- package/src/convert/outbound/canonical-to-chat.ts +101 -0
- package/src/convert/outbound/canonical-to-responses.ts +105 -0
- package/src/convert/streaming/inbound/anthropic-stream.ts +14 -0
- package/src/convert/streaming/inbound/chat-stream.ts +219 -0
- package/src/convert/streaming/inbound/pick.ts +21 -0
- package/src/convert/streaming/inbound/responses-stream.ts +276 -0
- package/src/convert/streaming/outbound/format-anthropic.ts +19 -0
- package/src/convert/streaming/outbound/format-chat.ts +133 -0
- package/src/convert/streaming/outbound/format-responses.ts +184 -0
- package/src/convert/upstream/canonical-to-anthropic.ts +111 -0
- package/src/convert/upstream/canonical-to-chat.ts +115 -0
- package/src/convert/upstream/canonical-to-responses.ts +123 -0
- package/src/core/config.ts +156 -0
- package/src/core/model-fetch.ts +124 -0
- package/src/core/resolve.ts +73 -0
- package/src/core/routing.ts +31 -0
- package/src/core/source.ts +28 -0
- package/src/daemon/install.ts +47 -0
- package/src/daemon/platform/linux.ts +65 -0
- package/src/daemon/platform/macos.ts +71 -0
- package/src/daemon/platform/windows.ts +70 -0
- package/src/daemon/start.ts +22 -0
- package/src/daemon/status.ts +19 -0
- package/src/daemon/stop.ts +58 -0
- package/src/index.ts +34 -0
- package/src/plugin/contract.ts +51 -0
- package/src/plugin/host.ts +27 -0
- package/src/plugin/loader.ts +55 -0
- package/src/plugin/sandbox.ts +3 -0
- package/src/providers/presets.ts +167 -0
- package/src/server/anthropic-parser.ts +44 -0
- package/src/server/cancelable-fetch.ts +21 -0
- package/src/server/chat-parser.ts +81 -0
- package/src/server/error-status.ts +18 -0
- package/src/server/error.ts +16 -0
- package/src/server/handlers/chat-completions.ts +94 -0
- package/src/server/handlers/messages.ts +89 -0
- package/src/server/handlers/models.ts +35 -0
- package/src/server/handlers/responses.ts +89 -0
- package/src/server/keepalive.ts +63 -0
- package/src/server/responses-parser.ts +62 -0
- package/src/server/serve.ts +79 -0
- package/src/server/sse.ts +61 -0
- package/src/server/upstream.ts +251 -0
- package/src/tier/builtin.ts +9 -0
- package/src/tier/resolve.ts +33 -0
- package/src/tier/store.ts +3 -0
- package/src/types.ts +94 -0
- package/src/ui/format.ts +44 -0
- package/src/ui/prompts.ts +34 -0
- package/src/utils/fuzzy.ts +48 -0
- package/src/utils/logger.ts +32 -0
- package/src/utils/paths.ts +48 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// 上游转发 orchestrator:把 Canonical 请求转成上游协议,注入 auth,fetch,解析响应
|
|
3
|
+
// 同时支持 stream 和非 stream
|
|
4
|
+
// ============================================================================
|
|
5
|
+
import type { Source, Subscription, ApiFormat } from "../types";
|
|
6
|
+
import type { CanonicalRequest, CanonicalResponse, CanonicalChunk } from "../canonical/types";
|
|
7
|
+
import { canonicalToChatUpstream } from "../convert/upstream/canonical-to-chat";
|
|
8
|
+
import { canonicalToAnthropicUpstream } from "../convert/upstream/canonical-to-anthropic";
|
|
9
|
+
import { canonicalToResponses } from "../convert/upstream/canonical-to-responses";
|
|
10
|
+
import { parseChatUpstreamResponse } from "./chat-parser";
|
|
11
|
+
import { parseAnthropicUpstreamResponse } from "./anthropic-parser";
|
|
12
|
+
import { parseResponsesResponse } from "./responses-parser";
|
|
13
|
+
import { pickInboundStreamParser, type InboundStreamParser } from "../convert/streaming/inbound/pick";
|
|
14
|
+
import { ChatStreamFormatter } from "../convert/streaming/outbound/format-chat";
|
|
15
|
+
import { ResponsesStreamFormatter } from "../convert/streaming/outbound/format-responses";
|
|
16
|
+
import { AnthropicStreamFormatter } from "../convert/streaming/outbound/format-anthropic";
|
|
17
|
+
import { cancelableFetch } from "./cancelable-fetch";
|
|
18
|
+
import { logger } from "../utils/logger";
|
|
19
|
+
import type { CanonicalResponseError } from "../canonical/types";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 流式路径专用 typed error:携带上游 HTTP status 给 handler 决定 HTTP 响应。
|
|
23
|
+
* HTTP 4xx/5xx 到达 `callUpstreamStream` 时直接 throw,handler 在 SSE 流内发 error event。
|
|
24
|
+
*/
|
|
25
|
+
export class UpstreamError extends Error {
|
|
26
|
+
constructor(
|
|
27
|
+
public readonly status: number | undefined,
|
|
28
|
+
public readonly body: string,
|
|
29
|
+
message: string,
|
|
30
|
+
) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = "UpstreamError";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface UpstreamCallOptions {
|
|
37
|
+
route: { source: Source; upstreamModelId: string; apiFormat: ApiFormat };
|
|
38
|
+
canonical: CanonicalRequest;
|
|
39
|
+
clientFormat: "openai-chat" | "openai-responses" | "anthropic-messages";
|
|
40
|
+
/** 客户端 req.signal;为 undefined 时只用上游 5min 硬超时 */
|
|
41
|
+
clientSignal?: AbortSignal;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 非流式上游调用 */
|
|
45
|
+
export async function callUpstream(opts: UpstreamCallOptions): Promise<CanonicalResponse> {
|
|
46
|
+
const ready = await resolveUpstream(opts.route);
|
|
47
|
+
if (!ready) {
|
|
48
|
+
return makeError(opts.route.upstreamModelId, "plugin_returned_no_config", { type: "plugin_error" });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const upstreamBody = pickUpstreamSerializer(ready.apiFormat)(opts.canonical);
|
|
52
|
+
|
|
53
|
+
const url = joinUrl(ready.baseUrl, ready.path);
|
|
54
|
+
logger.info(`[upstream] ${opts.route.apiFormat} → POST ${url} (model=${opts.canonical.model})`);
|
|
55
|
+
|
|
56
|
+
let res: Response;
|
|
57
|
+
try {
|
|
58
|
+
res = await cancelableFetch(url, {
|
|
59
|
+
method: "POST",
|
|
60
|
+
headers: {
|
|
61
|
+
"Content-Type": "application/json",
|
|
62
|
+
...ready.authHeader,
|
|
63
|
+
},
|
|
64
|
+
body: JSON.stringify(upstreamBody),
|
|
65
|
+
}, opts.clientSignal);
|
|
66
|
+
} catch (e) {
|
|
67
|
+
return makeError(
|
|
68
|
+
opts.route.upstreamModelId,
|
|
69
|
+
`network_error: ${(e as Error).message}`,
|
|
70
|
+
{ type: "network_error" },
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!res.ok) {
|
|
75
|
+
const text = await res.text();
|
|
76
|
+
// 优先抽 body 的 error.message(OpenAI/Anthropic/Responses 标准 shape),
|
|
77
|
+
// 抽不到时回退到 raw text 截 500 字符
|
|
78
|
+
let message = text.slice(0, 500);
|
|
79
|
+
try {
|
|
80
|
+
const parsed = JSON.parse(text) as { error?: { message?: unknown } };
|
|
81
|
+
if (typeof parsed.error?.message === "string") {
|
|
82
|
+
message = parsed.error.message.slice(0, 500);
|
|
83
|
+
}
|
|
84
|
+
} catch { /* 非 JSON body,保持 raw text */ }
|
|
85
|
+
return makeError(
|
|
86
|
+
opts.route.upstreamModelId,
|
|
87
|
+
message,
|
|
88
|
+
{ status: res.status, type: "upstream_error" },
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const raw = await res.json();
|
|
93
|
+
return pickUpstreamParser(ready.apiFormat)(raw, opts.route.upstreamModelId);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 流式上游调用 */
|
|
97
|
+
export interface UpstreamStream {
|
|
98
|
+
upstreamStream: ReadableStream<Uint8Array>;
|
|
99
|
+
/** 按 ready.apiFormat(真实上游协议)选 inbound 解析器 */
|
|
100
|
+
parser: InboundStreamParser;
|
|
101
|
+
/** 按 clientFormat 选输出格式化器;返回 0+ SSE 行(含 \n\n) */
|
|
102
|
+
format: (chunk: CanonicalChunk) => string[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function callUpstreamStream(opts: UpstreamCallOptions): Promise<UpstreamStream> {
|
|
106
|
+
const ready = await resolveUpstream(opts.route);
|
|
107
|
+
if (!ready) throw new Error("plugin_returned_no_config");
|
|
108
|
+
|
|
109
|
+
const upstreamBody = pickUpstreamSerializer(ready.apiFormat)(opts.canonical);
|
|
110
|
+
|
|
111
|
+
const url = joinUrl(ready.baseUrl, ready.path);
|
|
112
|
+
logger.info(`[upstream:stream] ${opts.route.apiFormat} → POST ${url} (model=${opts.canonical.model})`);
|
|
113
|
+
|
|
114
|
+
const res = await cancelableFetch(url, {
|
|
115
|
+
method: "POST",
|
|
116
|
+
headers: {
|
|
117
|
+
"Content-Type": "application/json",
|
|
118
|
+
...ready.authHeader,
|
|
119
|
+
},
|
|
120
|
+
body: JSON.stringify(upstreamBody),
|
|
121
|
+
}, opts.clientSignal);
|
|
122
|
+
|
|
123
|
+
if (!res.ok || !res.body) {
|
|
124
|
+
const text = res.body ? await res.text() : `upstream_${res.status}`;
|
|
125
|
+
throw new UpstreamError(res.status, text.slice(0, 500), text.slice(0, 500));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 关键:parser 用 ready.apiFormat(plugin 真实返回),不是 route.apiFormat(plugin 占位)
|
|
129
|
+
const parser = pickInboundStreamParser(ready.apiFormat);
|
|
130
|
+
|
|
131
|
+
const formatter = opts.clientFormat === "openai-chat" ? new ChatStreamFormatter()
|
|
132
|
+
: opts.clientFormat === "openai-responses" ? new ResponsesStreamFormatter()
|
|
133
|
+
: new AnthropicStreamFormatter();
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
upstreamStream: res.body,
|
|
137
|
+
parser,
|
|
138
|
+
format: (chunk) => formatter.format(chunk),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ============================================================================
|
|
143
|
+
// 解析上游 ready config(subscription 直接用;plugin 调用 getConfig)
|
|
144
|
+
// ============================================================================
|
|
145
|
+
|
|
146
|
+
interface ReadyConfig {
|
|
147
|
+
baseUrl: string;
|
|
148
|
+
path: string;
|
|
149
|
+
apiFormat: ApiFormat;
|
|
150
|
+
authHeader: Record<string, string>;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function resolveUpstream(route: UpstreamCallOptions["route"]): Promise<ReadyConfig | null> {
|
|
154
|
+
const source = route.source;
|
|
155
|
+
if (source.kind === "subscription") {
|
|
156
|
+
const sub = source as Subscription;
|
|
157
|
+
const path = pickUpstreamPath(sub);
|
|
158
|
+
return {
|
|
159
|
+
baseUrl: sub.endpoint,
|
|
160
|
+
path,
|
|
161
|
+
apiFormat: sub.apiFormat,
|
|
162
|
+
authHeader: { Authorization: `Bearer ${sub.token}`, ...sub.headers },
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
// plugin
|
|
166
|
+
if (source.kind !== "plugin") return null;
|
|
167
|
+
const pluginCfg = source as unknown as import("../types").PluginConfig;
|
|
168
|
+
const { loadPlugin } = await import("../plugin/loader");
|
|
169
|
+
const { makePluginContext } = await import("../plugin/host");
|
|
170
|
+
const { loadConfigFile } = await import("../core/config");
|
|
171
|
+
const config = loadConfigFile();
|
|
172
|
+
const plugin = await loadPlugin(pluginCfg, config);
|
|
173
|
+
if (!plugin?.getConfig) return null;
|
|
174
|
+
try {
|
|
175
|
+
const ctx = makePluginContext(pluginCfg.name, pluginCfg.config);
|
|
176
|
+
const result = await plugin.getConfig(ctx);
|
|
177
|
+
const ready = Array.isArray(result) ? result[0] : result;
|
|
178
|
+
if (!ready) return null;
|
|
179
|
+
return {
|
|
180
|
+
baseUrl: ready.baseUrl,
|
|
181
|
+
path: ready.path,
|
|
182
|
+
apiFormat: ready.apiFormat,
|
|
183
|
+
authHeader: ready.authHeader,
|
|
184
|
+
};
|
|
185
|
+
} catch (e) {
|
|
186
|
+
logger.error(`[plugin:${source.name}] getConfig failed: ${(e as Error).message}`);
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function makeError(
|
|
192
|
+
model: string,
|
|
193
|
+
msg: string,
|
|
194
|
+
opts?: { status?: number; type?: CanonicalResponseError["type"] },
|
|
195
|
+
): CanonicalResponse {
|
|
196
|
+
const base = {
|
|
197
|
+
id: `error-${Date.now()}`,
|
|
198
|
+
model,
|
|
199
|
+
content: [{ type: "text", text: msg }] as CanonicalResponse["content"],
|
|
200
|
+
stopReason: "error" as const,
|
|
201
|
+
usage: { inputTokens: 0, outputTokens: 0 },
|
|
202
|
+
};
|
|
203
|
+
if (opts?.status === undefined && !opts?.type) return base;
|
|
204
|
+
return {
|
|
205
|
+
...base,
|
|
206
|
+
error: {
|
|
207
|
+
message: msg,
|
|
208
|
+
...(opts.status !== undefined && { status: opts.status }),
|
|
209
|
+
...(opts.type && { type: opts.type }),
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function joinUrl(base: string, path: string): string {
|
|
215
|
+
const b = base.replace(/\/+$/, "");
|
|
216
|
+
const p = path.startsWith("/") ? path : `/${path}`;
|
|
217
|
+
if (b.endsWith("/v1") && p.startsWith("/v1/")) return `${b}${p.slice(3)}`;
|
|
218
|
+
if (b.endsWith("/v1beta") && p.startsWith("/v1beta/")) return `${b}${p.slice(7)}`;
|
|
219
|
+
return `${b}${p}`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ============================================================================
|
|
223
|
+
// 工厂函数:按 apiFormat 选上游 serializer / parser / path
|
|
224
|
+
// ============================================================================
|
|
225
|
+
|
|
226
|
+
type UpstreamSerializer = (req: CanonicalRequest) => unknown;
|
|
227
|
+
type UpstreamParser = (raw: unknown, model: string) => CanonicalResponse;
|
|
228
|
+
|
|
229
|
+
function pickUpstreamSerializer(format: ApiFormat): UpstreamSerializer {
|
|
230
|
+
switch (format) {
|
|
231
|
+
case "openai-chat": return canonicalToChatUpstream;
|
|
232
|
+
case "openai-responses": return canonicalToResponses;
|
|
233
|
+
case "anthropic-messages": return canonicalToAnthropicUpstream;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function pickUpstreamParser(format: ApiFormat): UpstreamParser {
|
|
238
|
+
switch (format) {
|
|
239
|
+
case "openai-chat": return parseChatUpstreamResponse;
|
|
240
|
+
case "openai-responses": return parseResponsesResponse;
|
|
241
|
+
case "anthropic-messages": return parseAnthropicUpstreamResponse;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function pickUpstreamPath(sub: Subscription): string {
|
|
246
|
+
switch (sub.apiFormat) {
|
|
247
|
+
case "openai-chat": return sub.chatCompletionsPath ?? "/v1/chat/completions";
|
|
248
|
+
case "openai-responses": return sub.responsesPath ?? "/v1/responses";
|
|
249
|
+
case "anthropic-messages": return sub.messagesPath ?? "/v1/messages";
|
|
250
|
+
}
|
|
251
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// 预留:4 个预定义 tier 的元数据(description / 用途说明)
|
|
2
|
+
import type { Tier } from "../types";
|
|
3
|
+
|
|
4
|
+
export const BUILTIN_TIERS: Record<string, Tier> = {
|
|
5
|
+
cctra: { name: "cctra", target: "", description: "默认(中等质量、便宜)" },
|
|
6
|
+
"cctra-pro": { name: "cctra-pro", target: "", description: "深度思考(慢但强)" },
|
|
7
|
+
"cctra-flash": { name: "cctra-flash", target: "", description: "高速(小快灵)" },
|
|
8
|
+
"cctra-vision": { name: "cctra-vision", target: "", description: "多模态" },
|
|
9
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Config, Source } from "../types";
|
|
2
|
+
import { getSource } from "../core/source";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 把 tier 名字解析成 (Source, upstreamModelId)
|
|
6
|
+
* tier 的 target 是 "sub/model" 或 "plugin/model" 格式
|
|
7
|
+
* 找不到映射或映射到的 source/model 不存在时返回 null
|
|
8
|
+
*/
|
|
9
|
+
export function resolveTier(
|
|
10
|
+
name: string,
|
|
11
|
+
config: Config,
|
|
12
|
+
): { source: Source; modelId: string } | null {
|
|
13
|
+
const tier = config.tiers[name];
|
|
14
|
+
if (!tier || !tier.target) return null;
|
|
15
|
+
|
|
16
|
+
// 递归解析(支持 tier → tier)
|
|
17
|
+
if (config.tiers[tier.target]) {
|
|
18
|
+
return resolveTier(tier.target, config);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// 解析 "sub/model" 格式
|
|
22
|
+
if (!tier.target.includes("/")) return null;
|
|
23
|
+
const [sourceName, modelPart] = tier.target.split("/", 2);
|
|
24
|
+
if (!sourceName || !modelPart) return null;
|
|
25
|
+
const source = getSource(config, sourceName);
|
|
26
|
+
if (!source) return null;
|
|
27
|
+
const model = source.models.find((m) => m.id === modelPart || m.alias === modelPart);
|
|
28
|
+
if (!model) return null;
|
|
29
|
+
return { source, modelId: model.id };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 列出所有 4 个预定义 tier 名字(用于 CLI 提示等) */
|
|
33
|
+
export const BUILTIN_TIER_NAMES = ["cctra", "cctra-pro", "cctra-flash", "cctra-vision"] as const;
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Source 抽象:所有提供模型的服务(静态订阅 + 动态插件)的统一接口
|
|
3
|
+
// ============================================================================
|
|
4
|
+
|
|
5
|
+
import type { ApiFormat } from "./canonical/types";
|
|
6
|
+
|
|
7
|
+
export type SourceKind = "subscription" | "plugin";
|
|
8
|
+
|
|
9
|
+
export interface Source {
|
|
10
|
+
kind: SourceKind;
|
|
11
|
+
name: string;
|
|
12
|
+
displayName?: string;
|
|
13
|
+
models: Model[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ============================================================================
|
|
17
|
+
// Model:模型元数据
|
|
18
|
+
// ============================================================================
|
|
19
|
+
|
|
20
|
+
export interface Model {
|
|
21
|
+
id: string;
|
|
22
|
+
alias?: string;
|
|
23
|
+
contextWindow?: number;
|
|
24
|
+
supportsTools?: boolean;
|
|
25
|
+
supportsVision?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// Subscription:静态订阅(endpoint + token + 协议类型)
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
export type { ApiFormat } from "./canonical/types";
|
|
33
|
+
|
|
34
|
+
export interface Subscription extends Source {
|
|
35
|
+
kind: "subscription";
|
|
36
|
+
vendor?: string; // 来源 vendor 名(仅显示用,不影响路由)
|
|
37
|
+
endpoint: string;
|
|
38
|
+
token: string;
|
|
39
|
+
apiFormat: ApiFormat;
|
|
40
|
+
chatCompletionsPath?: string; // 默认 "/v1/chat/completions"(仅 openai-chat)
|
|
41
|
+
messagesPath?: string; // 默认 "/v1/messages"(仅 anthropic)
|
|
42
|
+
responsesPath?: string; // 默认 "/v1/responses"(仅 openai-responses)
|
|
43
|
+
modelsPath?: string; // 默认 "/v1/models"
|
|
44
|
+
headers?: Record<string, string>;
|
|
45
|
+
createdAt: number;
|
|
46
|
+
updatedAt: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ============================================================================
|
|
50
|
+
// Plugin:本地路径插件配置
|
|
51
|
+
// ============================================================================
|
|
52
|
+
|
|
53
|
+
export interface PluginConfig extends Source {
|
|
54
|
+
kind: "plugin";
|
|
55
|
+
path: string;
|
|
56
|
+
config: Record<string, unknown>;
|
|
57
|
+
enabled: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ============================================================================
|
|
61
|
+
// Tier:层级模型别名(用户固定写 cctra-pro,cctra 动态路由到具体模型)
|
|
62
|
+
// ============================================================================
|
|
63
|
+
|
|
64
|
+
export interface Tier {
|
|
65
|
+
name: string;
|
|
66
|
+
target: string; // "subscription/model" 或 "plugin/model"
|
|
67
|
+
description?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const BUILTIN_TIERS = ["cctra", "cctra-pro", "cctra-flash", "cctra-vision"] as const;
|
|
71
|
+
export type BuiltinTier = typeof BUILTIN_TIERS[number];
|
|
72
|
+
|
|
73
|
+
// ============================================================================
|
|
74
|
+
// 总配置(~/.cctra/config.toml 的 schema)
|
|
75
|
+
// ============================================================================
|
|
76
|
+
|
|
77
|
+
export interface Config {
|
|
78
|
+
port: number;
|
|
79
|
+
subscriptions: Record<string, Subscription>;
|
|
80
|
+
plugins: Record<string, PluginConfig>;
|
|
81
|
+
tiers: Record<string, Tier>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const DEFAULT_CONFIG: Config = {
|
|
85
|
+
port: 3133,
|
|
86
|
+
subscriptions: {},
|
|
87
|
+
plugins: {},
|
|
88
|
+
tiers: {
|
|
89
|
+
cctra: { name: "cctra", target: "", description: "默认(中等质量、便宜)" },
|
|
90
|
+
"cctra-pro": { name: "cctra-pro", target: "", description: "深度思考(慢但强)" },
|
|
91
|
+
"cctra-flash": { name: "cctra-flash", target: "", description: "高速(小快灵)" },
|
|
92
|
+
"cctra-vision": { name: "cctra-vision", target: "", description: "多模态" },
|
|
93
|
+
},
|
|
94
|
+
};
|
package/src/ui/format.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
|
|
3
|
+
// Windows Terminal 对 Unicode 字符宽度渲染不一致,需要额外空格
|
|
4
|
+
const GAP = process.platform === "win32" ? " " : " ";
|
|
5
|
+
|
|
6
|
+
export function success(msg: string): void {
|
|
7
|
+
console.log(`${pc.green("✔")}${GAP}${msg}`);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function error(msg: string): void {
|
|
11
|
+
console.error(`${pc.red("✖")}${GAP}${msg}`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function info(msg: string): void {
|
|
15
|
+
console.log(`${pc.cyan("ℹ")}${GAP}${msg}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function warn(msg: string): void {
|
|
19
|
+
console.log(`${pc.yellow("⚠")}${GAP}${msg}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function dim(s: string): string {
|
|
23
|
+
return pc.dim(s);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function bold(s: string): string {
|
|
27
|
+
return pc.bold(s);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function green(s: string): string {
|
|
31
|
+
return pc.green(s);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function red(s: string): string {
|
|
35
|
+
return pc.red(s);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function yellow(s: string): string {
|
|
39
|
+
return pc.yellow(s);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function cyan(s: string): string {
|
|
43
|
+
return pc.cyan(s);
|
|
44
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 检查是否取消操作
|
|
5
|
+
*/
|
|
6
|
+
export function checkCancel<T>(value: T | symbol): T {
|
|
7
|
+
if (p.isCancel(value)) {
|
|
8
|
+
p.cancel("Operation cancelled.");
|
|
9
|
+
process.exit(0);
|
|
10
|
+
}
|
|
11
|
+
return value as T;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 掩码 token(前 4 + 后 4 可见,中间显示实际位数的 *)
|
|
16
|
+
*/
|
|
17
|
+
export function maskToken(token: string): string {
|
|
18
|
+
if (!token) return "";
|
|
19
|
+
if (token.length <= 8) return "*".repeat(token.length);
|
|
20
|
+
const midLen = token.length - 8;
|
|
21
|
+
return `${token.slice(0, 4)}${"•".repeat(midLen)}${token.slice(-4)}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 通用确认 prompt
|
|
26
|
+
*/
|
|
27
|
+
export async function confirm(message: string, initial = false): Promise<boolean> {
|
|
28
|
+
return checkCancel(
|
|
29
|
+
await p.confirm({
|
|
30
|
+
message,
|
|
31
|
+
initialValue: initial,
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 模糊匹配:大小写不敏感的子串匹配 + token 打分
|
|
3
|
+
* 返回匹配得分,0 表示不匹配
|
|
4
|
+
*/
|
|
5
|
+
export function fuzzyScore(query: string, target: string): number {
|
|
6
|
+
const q = query.toLowerCase().trim();
|
|
7
|
+
const t = target.toLowerCase().trim();
|
|
8
|
+
|
|
9
|
+
if (!q) return 1;
|
|
10
|
+
if (!t) return 0;
|
|
11
|
+
|
|
12
|
+
// 完全匹配得分最高
|
|
13
|
+
if (t === q) return 100;
|
|
14
|
+
|
|
15
|
+
// 前缀匹配
|
|
16
|
+
if (t.startsWith(q)) return 90;
|
|
17
|
+
|
|
18
|
+
// 子串匹配
|
|
19
|
+
if (t.includes(q)) return 70;
|
|
20
|
+
|
|
21
|
+
// token 级匹配:query 按空格分词,每个词都要在 target 中出现
|
|
22
|
+
const tokens = q.split(/\s+/);
|
|
23
|
+
if (tokens.length > 1 && tokens.every((token) => t.includes(token))) {
|
|
24
|
+
return 50 + tokens.length * 5;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// 单 token 子串匹配
|
|
28
|
+
if (tokens.some((token) => token.length >= 2 && t.includes(token))) {
|
|
29
|
+
return 30;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 在列表中模糊搜索,返回按得分排序的结果
|
|
37
|
+
*/
|
|
38
|
+
export function fuzzySearch<T>(
|
|
39
|
+
query: string,
|
|
40
|
+
items: T[],
|
|
41
|
+
getText: (item: T) => string,
|
|
42
|
+
): T[] {
|
|
43
|
+
return items
|
|
44
|
+
.map((item) => ({ item, score: fuzzyScore(query, getText(item)) }))
|
|
45
|
+
.filter((r) => r.score > 0)
|
|
46
|
+
.sort((a, b) => b.score - a.score)
|
|
47
|
+
.map((r) => r.item);
|
|
48
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { daemonLogPath } from "./paths";
|
|
4
|
+
|
|
5
|
+
/** 简单 logger:写到 ~/.cctra/daemon.log,带时间戳 */
|
|
6
|
+
export const logger = {
|
|
7
|
+
info(msg: string): void {
|
|
8
|
+
log("INFO", msg);
|
|
9
|
+
},
|
|
10
|
+
warn(msg: string): void {
|
|
11
|
+
log("WARN", msg);
|
|
12
|
+
},
|
|
13
|
+
error(msg: string): void {
|
|
14
|
+
log("ERROR", msg);
|
|
15
|
+
},
|
|
16
|
+
debug(msg: string): void {
|
|
17
|
+
if (process.env.CCTRA_DEBUG) log("DEBUG", msg);
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function log(level: string, msg: string): void {
|
|
22
|
+
const line = `[${new Date().toISOString()}] [${level}] ${msg}\n`;
|
|
23
|
+
const path = daemonLogPath();
|
|
24
|
+
try {
|
|
25
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
26
|
+
appendFileSync(path, line, "utf-8");
|
|
27
|
+
} catch {
|
|
28
|
+
// 写日志失败不能影响主流程
|
|
29
|
+
}
|
|
30
|
+
// 同时输出到 stderr(daemon 模式下 stdout 被吞,stderr 总是可见)
|
|
31
|
+
process.stderr.write(line);
|
|
32
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { mkdirSync } from "node:fs";
|
|
4
|
+
|
|
5
|
+
/** ~/.cctra 目录路径 */
|
|
6
|
+
export function cctraDir(): string {
|
|
7
|
+
return join(homedir(), ".cctra");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** ~/.cctra/config.toml 路径 */
|
|
11
|
+
export function configTomlPath(): string {
|
|
12
|
+
return join(cctraDir(), "config.toml");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** ~/.cctra/daemon.log 路径 */
|
|
16
|
+
export function daemonLogPath(): string {
|
|
17
|
+
return join(cctraDir(), "daemon.log");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** ~/.cctra/models-cache.json 路径 */
|
|
21
|
+
export function modelsCachePath(): string {
|
|
22
|
+
return join(cctraDir(), "models-cache.json");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** ~/.cctra/plugins/<name>/ 目录 */
|
|
26
|
+
export function pluginDir(name: string): string {
|
|
27
|
+
return join(cctraDir(), "plugins", name);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** ~/.cctra/plugins/<name>/config.json 路径 */
|
|
31
|
+
export function pluginConfigPath(name: string): string {
|
|
32
|
+
return join(pluginDir(name), "config.json");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Windows: %USERPROFILE%/.cctra/bin/cctra-daemon.exe */
|
|
36
|
+
export function windowsLauncherPath(): string {
|
|
37
|
+
return join(cctraDir(), "bin", "cctra-daemon.exe");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 确保目录存在 */
|
|
41
|
+
export function ensureDir(dir: string): void {
|
|
42
|
+
mkdirSync(dir, { recursive: true });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 确保 cctra 目录存在 */
|
|
46
|
+
export function ensureCctraDir(): void {
|
|
47
|
+
ensureDir(cctraDir());
|
|
48
|
+
}
|