cc-hub 0.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/README.md +92 -0
- package/README.zh-CN.md +92 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +6 -0
- package/dist/components/ui/modal.d.ts +10 -0
- package/dist/components/ui/modal.js +11 -0
- package/dist/components/ui/status-bar.d.ts +15 -0
- package/dist/components/ui/status-bar.js +6 -0
- package/dist/components/ui/tab-bar.d.ts +14 -0
- package/dist/components/ui/tab-bar.js +10 -0
- package/dist/components/ui/table.d.ts +36 -0
- package/dist/components/ui/table.js +79 -0
- package/dist/config/presets.d.ts +2 -0
- package/dist/config/presets.js +57 -0
- package/dist/store/claude-config.d.ts +5 -0
- package/dist/store/claude-config.js +79 -0
- package/dist/store/config-store.d.ts +15 -0
- package/dist/store/config-store.js +138 -0
- package/dist/store/model-store.d.ts +8 -0
- package/dist/store/model-store.js +76 -0
- package/dist/test-escape-compiled.d.ts +1 -0
- package/dist/test-escape-compiled.js +17 -0
- package/dist/tui/add-model.d.ts +7 -0
- package/dist/tui/add-model.js +102 -0
- package/dist/tui/add-provider.d.ts +13 -0
- package/dist/tui/add-provider.js +185 -0
- package/dist/tui/app.d.ts +1 -0
- package/dist/tui/app.js +163 -0
- package/dist/tui/confirm.d.ts +7 -0
- package/dist/tui/confirm.js +5 -0
- package/dist/tui/dashboard.d.ts +10 -0
- package/dist/tui/dashboard.js +45 -0
- package/dist/tui/edit-model.d.ts +8 -0
- package/dist/tui/edit-model.js +54 -0
- package/dist/tui/edit-provider.d.ts +12 -0
- package/dist/tui/edit-provider.js +140 -0
- package/dist/tui/scenario-config.d.ts +8 -0
- package/dist/tui/scenario-config.js +87 -0
- package/dist/types.d.ts +19 -0
- package/dist/types.js +2 -0
- package/package.json +45 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// src/store/config-store.ts
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { homedir } from "os";
|
|
5
|
+
import JSON5 from "json5";
|
|
6
|
+
const CONFIG_DIR = join(homedir(), ".cc-hub");
|
|
7
|
+
const CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
8
|
+
const DEFAULT_STORE = {
|
|
9
|
+
providers: [],
|
|
10
|
+
activeProviderId: null,
|
|
11
|
+
activeModelId: null,
|
|
12
|
+
scenarioModels: {},
|
|
13
|
+
};
|
|
14
|
+
function ensureConfigDir() {
|
|
15
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
16
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function atomicWrite(filePath, content) {
|
|
20
|
+
const tmpPath = filePath + ".tmp";
|
|
21
|
+
writeFileSync(tmpPath, content, "utf8");
|
|
22
|
+
renameSync(tmpPath, filePath);
|
|
23
|
+
}
|
|
24
|
+
const EXAMPLE_CONFIG = `{
|
|
25
|
+
// cc-hub configuration
|
|
26
|
+
// Edit this file to add or modify providers, then restart.
|
|
27
|
+
|
|
28
|
+
"providers": [
|
|
29
|
+
// {
|
|
30
|
+
// "id": "dashscope",
|
|
31
|
+
// "name": "DashScope",
|
|
32
|
+
// "baseUrl": "https://coding.dashscope.aliyuncs.com/apps/anthropic",
|
|
33
|
+
// "apiKey": "sk-your-api-key",
|
|
34
|
+
// "models": [
|
|
35
|
+
// "qwen3.6-plus",
|
|
36
|
+
// "qwen-max"
|
|
37
|
+
// ]
|
|
38
|
+
// }
|
|
39
|
+
],
|
|
40
|
+
|
|
41
|
+
// Optional: map Claude Code aliases (sonnet/opus/haiku) to your models.
|
|
42
|
+
// These write ANTHROPIC_DEFAULT_SONNET_MODEL etc. into Claude's settings.json.
|
|
43
|
+
"scenarioModels": {}
|
|
44
|
+
}
|
|
45
|
+
`;
|
|
46
|
+
let _configError = null;
|
|
47
|
+
export function getConfigError() {
|
|
48
|
+
return _configError;
|
|
49
|
+
}
|
|
50
|
+
export function clearConfigError() {
|
|
51
|
+
_configError = null;
|
|
52
|
+
}
|
|
53
|
+
export function loadConfig() {
|
|
54
|
+
ensureConfigDir();
|
|
55
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
56
|
+
atomicWrite(CONFIG_PATH, EXAMPLE_CONFIG);
|
|
57
|
+
const parsed = JSON5.parse(EXAMPLE_CONFIG);
|
|
58
|
+
return {
|
|
59
|
+
providers: parsed.providers ?? [],
|
|
60
|
+
activeProviderId: null,
|
|
61
|
+
activeModelId: null,
|
|
62
|
+
scenarioModels: parsed.scenarioModels ?? {},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const raw = readFileSync(CONFIG_PATH, "utf8");
|
|
66
|
+
if (!raw.trim() || raw.trim() === "{}") {
|
|
67
|
+
atomicWrite(CONFIG_PATH, EXAMPLE_CONFIG);
|
|
68
|
+
const parsed = JSON5.parse(EXAMPLE_CONFIG);
|
|
69
|
+
return {
|
|
70
|
+
providers: parsed.providers ?? [],
|
|
71
|
+
activeProviderId: null,
|
|
72
|
+
activeModelId: null,
|
|
73
|
+
scenarioModels: parsed.scenarioModels ?? {},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const parsed = JSON5.parse(raw);
|
|
78
|
+
if (!Array.isArray(parsed.providers) || parsed.providers.length === 0) {
|
|
79
|
+
atomicWrite(CONFIG_PATH, EXAMPLE_CONFIG);
|
|
80
|
+
return { ...DEFAULT_STORE };
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
providers: parsed.providers,
|
|
84
|
+
activeProviderId: parsed.activeProviderId ?? null,
|
|
85
|
+
activeModelId: parsed.activeModelId ?? null,
|
|
86
|
+
scenarioModels: parsed.scenarioModels ?? {},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
const msg = err instanceof Error ? err.message : "Unknown parse error";
|
|
91
|
+
_configError = `Config file is invalid. ${msg}`;
|
|
92
|
+
return { ...DEFAULT_STORE };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
export function saveConfig(store) {
|
|
96
|
+
ensureConfigDir();
|
|
97
|
+
atomicWrite(CONFIG_PATH, JSON.stringify(store, null, 2));
|
|
98
|
+
}
|
|
99
|
+
// --- Helper: resolve a model by id across all providers ---
|
|
100
|
+
export function findModel(store, modelId) {
|
|
101
|
+
for (const p of store.providers) {
|
|
102
|
+
if (p.models.includes(modelId))
|
|
103
|
+
return { provider: p, modelId };
|
|
104
|
+
}
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
export function getAllModels(store) {
|
|
108
|
+
return store.providers.flatMap((p) => p.models);
|
|
109
|
+
}
|
|
110
|
+
// --- Active model ---
|
|
111
|
+
export function setActiveModel(store, modelId) {
|
|
112
|
+
const found = findModel(store, modelId);
|
|
113
|
+
if (!found)
|
|
114
|
+
return store;
|
|
115
|
+
return {
|
|
116
|
+
...store,
|
|
117
|
+
activeProviderId: found.provider.id,
|
|
118
|
+
activeModelId: modelId,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
// --- Scenario models ---
|
|
122
|
+
export function updateScenarioModels(store, updates) {
|
|
123
|
+
return {
|
|
124
|
+
...store,
|
|
125
|
+
scenarioModels: { ...store.scenarioModels, ...updates },
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
// --- Delete model from provider ---
|
|
129
|
+
export function removeModelFromProvider(store, providerId, modelId) {
|
|
130
|
+
const wasActive = store.activeModelId === modelId;
|
|
131
|
+
return {
|
|
132
|
+
...store,
|
|
133
|
+
providers: store.providers.map((p) => p.id === providerId
|
|
134
|
+
? { ...p, models: p.models.filter((m) => m !== modelId) }
|
|
135
|
+
: p),
|
|
136
|
+
activeModelId: wasActive ? null : store.activeModelId,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { ModelConfig, ModelStore } from "../types.js";
|
|
2
|
+
export declare function loadStore(): ModelStore;
|
|
3
|
+
export declare function saveStore(store: ModelStore): void;
|
|
4
|
+
export declare function addModel(store: ModelStore, model: ModelConfig): ModelStore;
|
|
5
|
+
export declare function updateModel(store: ModelStore, id: string, updates: Partial<ModelConfig>): ModelStore;
|
|
6
|
+
export declare function removeModel(store: ModelStore, id: string): ModelStore;
|
|
7
|
+
export declare function setActiveModel(store: ModelStore, id: string): ModelStore;
|
|
8
|
+
export declare function getModel(store: ModelStore, id: string): ModelConfig | undefined;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// src/store/model-store.ts
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { homedir } from "os";
|
|
5
|
+
const CONFIG_DIR = join(homedir(), ".cc-model-manager");
|
|
6
|
+
const STORE_PATH = join(CONFIG_DIR, "models.json");
|
|
7
|
+
const DEFAULT_STORE = {
|
|
8
|
+
models: [],
|
|
9
|
+
activeModelId: null,
|
|
10
|
+
};
|
|
11
|
+
function ensureConfigDir() {
|
|
12
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
13
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function atomicWrite(filePath, content) {
|
|
17
|
+
const tmpPath = filePath + ".tmp";
|
|
18
|
+
writeFileSync(tmpPath, content, "utf8");
|
|
19
|
+
renameSync(tmpPath, filePath);
|
|
20
|
+
}
|
|
21
|
+
export function loadStore() {
|
|
22
|
+
ensureConfigDir();
|
|
23
|
+
if (!existsSync(STORE_PATH)) {
|
|
24
|
+
atomicWrite(STORE_PATH, JSON.stringify(DEFAULT_STORE, null, 2));
|
|
25
|
+
return { ...DEFAULT_STORE };
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
const raw = readFileSync(STORE_PATH, "utf8");
|
|
29
|
+
return JSON.parse(raw);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// Corrupted file — backup and recreate
|
|
33
|
+
const backupPath = STORE_PATH + ".bak";
|
|
34
|
+
try {
|
|
35
|
+
renameSync(STORE_PATH, backupPath);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// ignore
|
|
39
|
+
}
|
|
40
|
+
atomicWrite(STORE_PATH, JSON.stringify(DEFAULT_STORE, null, 2));
|
|
41
|
+
return { ...DEFAULT_STORE };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export function saveStore(store) {
|
|
45
|
+
ensureConfigDir();
|
|
46
|
+
atomicWrite(STORE_PATH, JSON.stringify(store, null, 2));
|
|
47
|
+
}
|
|
48
|
+
export function addModel(store, model) {
|
|
49
|
+
return {
|
|
50
|
+
...store,
|
|
51
|
+
models: [...store.models, model],
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
export function updateModel(store, id, updates) {
|
|
55
|
+
return {
|
|
56
|
+
...store,
|
|
57
|
+
models: store.models.map((m) => (m.id === id ? { ...m, ...updates } : m)),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export function removeModel(store, id) {
|
|
61
|
+
const newActive = store.activeModelId === id ? null : store.activeModelId;
|
|
62
|
+
return {
|
|
63
|
+
...store,
|
|
64
|
+
models: store.models.filter((m) => m.id !== id),
|
|
65
|
+
activeModelId: newActive,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
export function setActiveModel(store, id) {
|
|
69
|
+
const exists = store.models.some((m) => m.id === id);
|
|
70
|
+
if (!exists)
|
|
71
|
+
return store;
|
|
72
|
+
return { ...store, activeModelId: id };
|
|
73
|
+
}
|
|
74
|
+
export function getModel(store, id) {
|
|
75
|
+
return store.models.find((m) => m.id === id);
|
|
76
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { useInput, Box, Text, render } from "ink";
|
|
4
|
+
import TextInput from "ink-text-input";
|
|
5
|
+
function Test() {
|
|
6
|
+
const [appEscape, setAppEscape] = React.useState(0);
|
|
7
|
+
const [textInput, setTextInput] = React.useState("");
|
|
8
|
+
useInput((input, key) => {
|
|
9
|
+
if (key.escape) {
|
|
10
|
+
setAppEscape((prev) => prev + 1);
|
|
11
|
+
}
|
|
12
|
+
if (input === "q")
|
|
13
|
+
process.exit(0);
|
|
14
|
+
});
|
|
15
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Text, { children: ["App-level escape count: ", appEscape] }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "green", children: "> " }), _jsx(TextInput, { value: textInput, onChange: setTextInput })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "dim", children: "Press Escape, then q to quit" }) })] }));
|
|
16
|
+
}
|
|
17
|
+
render(_jsx(Test, {}));
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ModelConfig } from "../types.js";
|
|
2
|
+
interface AddModelProps {
|
|
3
|
+
onSubmit: (model: ModelConfig) => void;
|
|
4
|
+
onCancel: () => void;
|
|
5
|
+
}
|
|
6
|
+
export declare function AddModel({ onSubmit, onCancel }: AddModelProps): import("react/jsx-runtime").JSX.Element | null;
|
|
7
|
+
export {};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// src/tui/add-model.tsx
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { Box, Text, useInput } from "ink";
|
|
5
|
+
import TextInput from "ink-text-input";
|
|
6
|
+
import { PRESETS } from "../config/presets.js";
|
|
7
|
+
export function AddModel({ onSubmit, onCancel }) {
|
|
8
|
+
const [state, setState] = React.useState({
|
|
9
|
+
step: "select-preset",
|
|
10
|
+
preset: null,
|
|
11
|
+
apiKey: "",
|
|
12
|
+
customBaseUrl: "",
|
|
13
|
+
customModelId: "",
|
|
14
|
+
customName: "",
|
|
15
|
+
});
|
|
16
|
+
// Preset selection
|
|
17
|
+
if (state.step === "select-preset") {
|
|
18
|
+
return (_jsx(PresetSelect, { onSelect: (preset) => setState((s) => ({ ...s, preset, step: "enter-key" })), onCancel: onCancel }));
|
|
19
|
+
}
|
|
20
|
+
// Determine which field to ask for next
|
|
21
|
+
const isCustom = !state.preset;
|
|
22
|
+
if (state.step === "enter-key") {
|
|
23
|
+
return (_jsx(FieldInput, { label: "API Key", value: state.apiKey, onChange: (apiKey) => setState((s) => ({ ...s, apiKey })), onSubmit: () => {
|
|
24
|
+
if (isCustom) {
|
|
25
|
+
setState((s) => ({ ...s, step: "enter-baseurl" }));
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
setState((s) => ({ ...s, step: "enter-name" }));
|
|
29
|
+
}
|
|
30
|
+
}, onCancel: onCancel }));
|
|
31
|
+
}
|
|
32
|
+
if (state.step === "enter-baseurl") {
|
|
33
|
+
return (_jsx(FieldInput, { label: "Base URL", value: state.customBaseUrl, onChange: (customBaseUrl) => setState((s) => ({ ...s, customBaseUrl })), onSubmit: () => setState((s) => ({ ...s, step: "enter-modelid" })), onCancel: onCancel }));
|
|
34
|
+
}
|
|
35
|
+
if (state.step === "enter-modelid") {
|
|
36
|
+
return (_jsx(FieldInput, { label: "Model ID (optional)", value: state.customModelId, onChange: (customModelId) => setState((s) => ({ ...s, customModelId })), onSubmit: () => setState((s) => ({ ...s, step: "enter-name" })), onCancel: onCancel }));
|
|
37
|
+
}
|
|
38
|
+
// Final step — submit
|
|
39
|
+
if (state.step === "enter-name") {
|
|
40
|
+
return (_jsx(FieldInput, { label: isCustom ? "Name (required)" : "Display name (optional)", value: state.customName, onChange: (customName) => setState((s) => ({ ...s, customName })), onSubmit: () => {
|
|
41
|
+
const preset = state.preset;
|
|
42
|
+
if (isCustom && !state.customBaseUrl.trim())
|
|
43
|
+
return;
|
|
44
|
+
if (isCustom && !state.customName.trim())
|
|
45
|
+
return;
|
|
46
|
+
const model = {
|
|
47
|
+
id: isCustom ? toKebabCase(state.customName) : preset.id,
|
|
48
|
+
name: state.customName.trim() ||
|
|
49
|
+
(preset ? preset.name : state.customBaseUrl),
|
|
50
|
+
baseUrl: isCustom ? state.customBaseUrl.trim() : preset.baseUrl,
|
|
51
|
+
apiKey: state.apiKey,
|
|
52
|
+
modelId: isCustom
|
|
53
|
+
? state.customModelId.trim() || undefined
|
|
54
|
+
: preset?.modelId,
|
|
55
|
+
};
|
|
56
|
+
onSubmit(model);
|
|
57
|
+
}, onCancel: onCancel }));
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
// --- Sub-components ---
|
|
62
|
+
function PresetSelect({ onSelect, onCancel, }) {
|
|
63
|
+
const [index, setIndex] = React.useState(0);
|
|
64
|
+
const options = [
|
|
65
|
+
...PRESETS,
|
|
66
|
+
{
|
|
67
|
+
id: "custom",
|
|
68
|
+
name: "Custom (manual setup)",
|
|
69
|
+
baseUrl: "",
|
|
70
|
+
modelId: undefined,
|
|
71
|
+
description: undefined,
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
useInput((input, key) => {
|
|
75
|
+
if (key.escape || input === "q") {
|
|
76
|
+
onCancel();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (key.upArrow)
|
|
80
|
+
setIndex((i) => Math.max(0, i - 1));
|
|
81
|
+
if (key.downArrow)
|
|
82
|
+
setIndex((i) => Math.min(options.length - 1, i + 1));
|
|
83
|
+
if (key.return)
|
|
84
|
+
onSelect(options[index]);
|
|
85
|
+
});
|
|
86
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Add Model" }), _jsx(Text, { children: "─".repeat(40) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "dim", children: "Select a provider preset:" }) }), options.map((opt, i) => (_jsxs(Box, { children: [_jsx(Text, { children: i === index ? "> " : " " }), _jsx(Text, { color: i === index ? "green" : undefined, children: opt.name })] }, opt.id))), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "dim", children: [_jsx(Text, { color: "green", bold: true, children: "Enter" }), ": Select", " ", _jsx(Text, { color: "green", bold: true, children: "q" }), ": Cancel"] }) })] }));
|
|
87
|
+
}
|
|
88
|
+
function FieldInput({ label, value, onChange, onSubmit, onCancel, }) {
|
|
89
|
+
// Handle escape BEFORE TextInput's useInput can process it
|
|
90
|
+
useInput((_, key) => {
|
|
91
|
+
if (key.escape) {
|
|
92
|
+
onCancel();
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, children: label }), _jsx(Text, { children: "─".repeat(40) }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "green", children: "> " }), _jsx(TextInput, { value: value, onChange: onChange, onSubmit: onSubmit, focus: false })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "dim", children: "Press Enter to continue, Esc to cancel" }) })] }));
|
|
96
|
+
}
|
|
97
|
+
function toKebabCase(str) {
|
|
98
|
+
return str
|
|
99
|
+
.toLowerCase()
|
|
100
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
101
|
+
.replace(/^-|-$/g, "");
|
|
102
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Model } from "../types.js";
|
|
2
|
+
interface AddProviderProps {
|
|
3
|
+
onSubmit: (provider: {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
baseUrl: string;
|
|
7
|
+
apiKey: string;
|
|
8
|
+
models: Model[];
|
|
9
|
+
}) => void;
|
|
10
|
+
onCancel: () => void;
|
|
11
|
+
}
|
|
12
|
+
export declare function AddProvider({ onSubmit, onCancel }: AddProviderProps): import("react/jsx-runtime").JSX.Element | null;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// src/tui/add-provider.tsx
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { Box, Text, useInput } from "ink";
|
|
5
|
+
import TextInput from "ink-text-input";
|
|
6
|
+
import { PRESETS } from "../config/presets.js";
|
|
7
|
+
export function AddProvider({ onSubmit, onCancel }) {
|
|
8
|
+
const [state, setState] = React.useState({
|
|
9
|
+
step: "select-preset",
|
|
10
|
+
preset: null,
|
|
11
|
+
isCustom: false,
|
|
12
|
+
customName: "",
|
|
13
|
+
customBaseUrl: "",
|
|
14
|
+
apiKey: "",
|
|
15
|
+
selectedModelIds: new Set(),
|
|
16
|
+
newModelName: "",
|
|
17
|
+
newModelId: "",
|
|
18
|
+
newModelModelId: "",
|
|
19
|
+
});
|
|
20
|
+
// Step 1: Select preset
|
|
21
|
+
if (state.step === "select-preset") {
|
|
22
|
+
return (_jsx(PresetSelect, { onSelect: (preset) => setState((s) => ({
|
|
23
|
+
...s,
|
|
24
|
+
preset,
|
|
25
|
+
isCustom: false,
|
|
26
|
+
step: "enter-key",
|
|
27
|
+
selectedModelIds: new Set(preset.models.map((m) => m.id)),
|
|
28
|
+
})), onCustom: () => setState((s) => ({
|
|
29
|
+
...s,
|
|
30
|
+
isCustom: true,
|
|
31
|
+
step: "enter-name",
|
|
32
|
+
})), onCancel: onCancel }));
|
|
33
|
+
}
|
|
34
|
+
// Custom: enter name
|
|
35
|
+
if (state.step === "enter-name") {
|
|
36
|
+
return (_jsx(FieldInput, { label: "Provider name", value: state.customName, onChange: (customName) => setState((s) => ({ ...s, customName })), onSubmit: () => setState((s) => ({ ...s, step: "enter-baseurl" })), onCancel: onCancel }));
|
|
37
|
+
}
|
|
38
|
+
// Custom: enter base URL
|
|
39
|
+
if (state.step === "enter-baseurl") {
|
|
40
|
+
return (_jsx(FieldInput, { label: "Base URL", value: state.customBaseUrl, onChange: (customBaseUrl) => setState((s) => ({ ...s, customBaseUrl })), onSubmit: () => setState((s) => ({ ...s, step: "enter-key" })), onCancel: onCancel }));
|
|
41
|
+
}
|
|
42
|
+
// Enter API Key (preset or custom)
|
|
43
|
+
if (state.step === "enter-key") {
|
|
44
|
+
return (_jsx(FieldInput, { label: "API Key", value: state.apiKey, onChange: (apiKey) => setState((s) => ({ ...s, apiKey })), onSubmit: () => {
|
|
45
|
+
if (state.isCustom) {
|
|
46
|
+
setState((s) => ({ ...s, step: "custom-add-model" }));
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
setState((s) => ({ ...s, step: "select-models" }));
|
|
50
|
+
}
|
|
51
|
+
}, onCancel: onCancel }));
|
|
52
|
+
}
|
|
53
|
+
// Preset: select which models to include
|
|
54
|
+
if (state.step === "select-models" && state.preset) {
|
|
55
|
+
return (_jsx(ModelSelect, { preset: state.preset, selectedIds: state.selectedModelIds, onToggle: (id) => {
|
|
56
|
+
setState((s) => {
|
|
57
|
+
const next = new Set(s.selectedModelIds);
|
|
58
|
+
if (next.has(id))
|
|
59
|
+
next.delete(id);
|
|
60
|
+
else
|
|
61
|
+
next.add(id);
|
|
62
|
+
return { ...s, selectedModelIds: next };
|
|
63
|
+
});
|
|
64
|
+
}, onSubmit: () => {
|
|
65
|
+
if (state.selectedModelIds.size === 0 || !state.preset)
|
|
66
|
+
return;
|
|
67
|
+
const models = state.preset.models.filter((m) => state.selectedModelIds.has(m.id));
|
|
68
|
+
onSubmit({
|
|
69
|
+
id: state.preset.id,
|
|
70
|
+
name: state.preset.name,
|
|
71
|
+
baseUrl: state.preset.baseUrl,
|
|
72
|
+
apiKey: state.apiKey,
|
|
73
|
+
models,
|
|
74
|
+
});
|
|
75
|
+
}, onCancel: onCancel }));
|
|
76
|
+
}
|
|
77
|
+
// Custom: add models one by one
|
|
78
|
+
if (state.step === "custom-add-model") {
|
|
79
|
+
return (_jsx(CustomModelInput, { name: state.newModelName, modelId: state.newModelModelId, localId: state.newModelId, onChangeName: (newModelName) => setState((s) => ({ ...s, newModelName })), onChangeModelId: (newModelModelId) => setState((s) => ({ ...s, newModelModelId })), onChangeLocalId: (newModelId) => setState((s) => ({ ...s, newModelId })), onAdd: () => {
|
|
80
|
+
// For custom providers, we submit directly after entering one model
|
|
81
|
+
// Simplified: single model per custom provider
|
|
82
|
+
if (!state.newModelName.trim() || !state.customName.trim())
|
|
83
|
+
return;
|
|
84
|
+
const id = state.newModelId.trim() || toKebabCase(state.newModelName);
|
|
85
|
+
onSubmit({
|
|
86
|
+
id: toKebabCase(state.customName),
|
|
87
|
+
name: state.customName.trim(),
|
|
88
|
+
baseUrl: state.customBaseUrl.trim(),
|
|
89
|
+
apiKey: state.apiKey,
|
|
90
|
+
models: [
|
|
91
|
+
{
|
|
92
|
+
id,
|
|
93
|
+
name: state.newModelName.trim(),
|
|
94
|
+
modelId: state.newModelModelId.trim() || id,
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
});
|
|
98
|
+
}, onCancel: onCancel }));
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
// --- Sub-components ---
|
|
103
|
+
function PresetSelect({ onSelect, onCustom, onCancel, }) {
|
|
104
|
+
const [index, setIndex] = React.useState(0);
|
|
105
|
+
const options = [...PRESETS];
|
|
106
|
+
const customIndex = options.length;
|
|
107
|
+
useInput((input, key) => {
|
|
108
|
+
if (key.escape || input === "q") {
|
|
109
|
+
onCancel();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (key.upArrow)
|
|
113
|
+
setIndex((i) => Math.max(0, i - 1));
|
|
114
|
+
if (key.downArrow)
|
|
115
|
+
setIndex((i) => Math.min(options.length, i + 1));
|
|
116
|
+
if (key.return) {
|
|
117
|
+
if (index === customIndex) {
|
|
118
|
+
onCustom();
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
onSelect(options[index]);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Add Provider" }), _jsx(Text, { children: "─".repeat(40) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "dim", children: "Select a provider preset:" }) }), options.map((opt, i) => (_jsxs(Box, { children: [_jsx(Text, { children: i === index ? "> " : " " }), _jsxs(Text, { color: i === index ? "green" : undefined, children: [opt.name, " ", _jsxs(Text, { color: "dim", children: ["(", opt.models.length, " model", opt.models.length > 1 ? "s" : "", ")"] })] })] }, opt.id))), _jsxs(Box, { children: [_jsx(Text, { children: customIndex === index ? "> " : " " }), _jsx(Text, { color: customIndex === index ? "green" : undefined, children: "Custom (manual setup)" })] }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "dim", children: [_jsx(Text, { color: "green", bold: true, children: "Enter" }), ": Select", " ", _jsx(Text, { color: "green", bold: true, children: "q" }), ": Cancel"] }) })] }));
|
|
126
|
+
}
|
|
127
|
+
function FieldInput({ label, value, onChange, onSubmit, onCancel, }) {
|
|
128
|
+
useInput((_, key) => {
|
|
129
|
+
if (key.escape)
|
|
130
|
+
onCancel();
|
|
131
|
+
});
|
|
132
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, children: label }), _jsx(Text, { children: "─".repeat(40) }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "green", children: "> " }), _jsx(TextInput, { value: value, onChange: onChange, onSubmit: onSubmit, focus: false })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "dim", children: "Press Enter to continue, Esc to cancel" }) })] }));
|
|
133
|
+
}
|
|
134
|
+
function ModelSelect({ preset, selectedIds, onToggle, onSubmit, onCancel, }) {
|
|
135
|
+
const [index, setIndex] = React.useState(0);
|
|
136
|
+
useInput((input, key) => {
|
|
137
|
+
if (key.escape)
|
|
138
|
+
onCancel();
|
|
139
|
+
if (key.upArrow)
|
|
140
|
+
setIndex((i) => Math.max(0, i - 1));
|
|
141
|
+
if (key.downArrow)
|
|
142
|
+
setIndex((i) => Math.min(preset.models.length - 1, i + 1));
|
|
143
|
+
if (key.return)
|
|
144
|
+
onToggle(preset.models[index].id);
|
|
145
|
+
if (input === " ")
|
|
146
|
+
onToggle(preset.models[index].id);
|
|
147
|
+
});
|
|
148
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Text, { bold: true, children: ["Select Models \u2014 ", preset.name] }), _jsx(Text, { children: "─".repeat(40) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "dim", children: "Select models to include (toggle with Enter/Space):" }) }), preset.models.map((m, i) => (_jsxs(Box, { children: [_jsx(Text, { children: i === index ? "> " : " " }), _jsx(Text, { color: selectedIds.has(m.id) ? "green" : "dim", children: selectedIds.has(m.id) ? "[x]" : "[ ]" }), _jsxs(Text, { children: [" ", m.name] }), _jsxs(Text, { color: "dim", children: [" (", m.modelId, ")"] })] }, m.id))), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "dim", children: [_jsx(Text, { color: "green", bold: true, children: "Enter" }), ": Toggle", " ", _jsx(Text, { color: "green", bold: true, children: "Space" }), ": Toggle", " ", _jsx(Text, { color: "green", bold: true, children: "Esc" }), ": Cancel"] }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "green", bold: true, children: "\u2192 Press Enter on a selected model to confirm" }) })] }));
|
|
149
|
+
}
|
|
150
|
+
function CustomModelInput({ name, modelId, localId, onChangeName, onChangeModelId, onChangeLocalId, onAdd, onCancel, }) {
|
|
151
|
+
const [field, setField] = React.useState("name");
|
|
152
|
+
useInput((_, key) => {
|
|
153
|
+
if (key.escape)
|
|
154
|
+
onCancel();
|
|
155
|
+
});
|
|
156
|
+
const fields = [
|
|
157
|
+
{ key: "name", label: "Model name", required: true },
|
|
158
|
+
{ key: "modelId", label: "Model ID (remote)", required: false },
|
|
159
|
+
{ key: "id", label: "Local ID (optional)", required: false },
|
|
160
|
+
];
|
|
161
|
+
const valueMap = { name, modelId, id: localId };
|
|
162
|
+
const currentValue = valueMap[field];
|
|
163
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Add Model \u2014 Custom Provider" }), _jsx(Text, { children: "─".repeat(40) }), fields.map((f) => (_jsxs(Box, { children: [_jsx(Text, { children: f.key === field ? "> " : " " }), _jsx(Text, { color: "dim", children: f.label.padEnd(22) }), _jsx(Text, { color: f.key === field ? "green" : "dim", children: valueMap[f.key] || _jsx(Text, { color: "dim", children: "(empty)" }) })] }, f.key))), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(TextInput, { value: currentValue, onChange: (v) => {
|
|
164
|
+
if (field === "name")
|
|
165
|
+
onChangeName(v);
|
|
166
|
+
else if (field === "modelId")
|
|
167
|
+
onChangeModelId(v);
|
|
168
|
+
else if (field === "id")
|
|
169
|
+
onChangeLocalId(v);
|
|
170
|
+
}, onSubmit: () => {
|
|
171
|
+
const idx = fields.findIndex((f) => f.key === field);
|
|
172
|
+
if (idx < fields.length - 1) {
|
|
173
|
+
setField(fields[idx + 1].key);
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
onAdd();
|
|
177
|
+
}
|
|
178
|
+
}, focus: false }), _jsx(Text, { color: "dim", children: "Press Enter for next field, Esc to cancel" })] })] }));
|
|
179
|
+
}
|
|
180
|
+
function toKebabCase(str) {
|
|
181
|
+
return str
|
|
182
|
+
.toLowerCase()
|
|
183
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
184
|
+
.replace(/^-|-$/g, "");
|
|
185
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function App(): import("react/jsx-runtime").JSX.Element;
|