waypoi 0.0.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/.github/instructions/ui.instructions.md +42 -0
- package/.github/workflows/ci.yml +35 -0
- package/.github/workflows/publish.yml +71 -0
- package/.github/workflows/release.yml +48 -0
- package/.playwright-mcp/console-2026-04-04T01-41-10-746Z.log +2 -0
- package/.playwright-mcp/console-2026-04-04T01-41-28-799Z.log +3 -0
- package/.playwright-mcp/console-2026-04-05T02-26-51-909Z.log +76 -0
- package/.playwright-mcp/page-2026-04-04T01-41-10-816Z.yml +1 -0
- package/.playwright-mcp/page-2026-04-04T01-41-29-141Z.yml +77 -0
- package/.playwright-mcp/page-2026-04-04T01-41-42-633Z.yml +190 -0
- package/.playwright-mcp/page-2026-04-04T01-42-03-929Z.yml +262 -0
- package/.playwright-mcp/page-2026-04-04T02-12-54-813Z.yml +6 -0
- package/.playwright-mcp/page-2026-04-04T02-14-58-600Z.yml +190 -0
- package/.playwright-mcp/page-2026-04-04T02-15-03-923Z.yml +190 -0
- package/.playwright-mcp/page-2026-04-04T02-15-07-426Z.yml +190 -0
- package/.playwright-mcp/page-2026-04-04T02-15-25-729Z.yml +262 -0
- package/.playwright-mcp/page-2026-04-04T02-16-22-984Z.yml +262 -0
- package/.playwright-mcp/page-2026-04-04T02-17-00-599Z.yml +190 -0
- package/.playwright-mcp/page-2026-04-04T02-17-50-874Z.yml +190 -0
- package/.playwright-mcp/page-2026-04-05T02-26-55-570Z.yml +6 -0
- package/AGENTS.md +48 -0
- package/CHANGELOG.md +131 -0
- package/README.md +552 -0
- package/assets/agent-mode.png +0 -0
- package/assets/categorize.png +0 -0
- package/assets/dashboard.png +0 -0
- package/assets/endpoint-proxy.png +0 -0
- package/assets/icon.png +0 -0
- package/assets/mcp-generate-image.png +0 -0
- package/assets/mcp-understand-image.png +0 -0
- package/assets/peek-token-flow.png +0 -0
- package/assets/playground.png +0 -0
- package/assets/sankey.png +0 -0
- package/cli/index.ts +2805 -0
- package/cli/legacyRewrite.ts +108 -0
- package/cli/modelRef.ts +24 -0
- package/dist/cli/index.js +2536 -0
- package/dist/cli/legacyRewrite.js +92 -0
- package/dist/cli/modelRef.js +20 -0
- package/dist/src/benchmark/artifacts.js +131 -0
- package/dist/src/benchmark/capabilityClassifier.js +81 -0
- package/dist/src/benchmark/capabilityStore.js +144 -0
- package/dist/src/benchmark/config.js +238 -0
- package/dist/src/benchmark/gates.js +118 -0
- package/dist/src/benchmark/jobs.js +252 -0
- package/dist/src/benchmark/runner.js +1847 -0
- package/dist/src/benchmark/schema.js +353 -0
- package/dist/src/benchmark/suites.js +314 -0
- package/dist/src/benchmark/tinyQaDataset.js +422 -0
- package/dist/src/benchmark/types.js +25 -0
- package/dist/src/config.js +47 -0
- package/dist/src/index.js +178 -0
- package/dist/src/mcp/client.js +215 -0
- package/dist/src/mcp/discovery.js +226 -0
- package/dist/src/mcp/policy.js +65 -0
- package/dist/src/mcp/registry.js +129 -0
- package/dist/src/mcp/service.js +460 -0
- package/dist/src/middleware/auth.js +179 -0
- package/dist/src/middleware/requestCapture.js +192 -0
- package/dist/src/middleware/requestStats.js +118 -0
- package/dist/src/pools/builder.js +132 -0
- package/dist/src/pools/repository.js +69 -0
- package/dist/src/pools/scheduler.js +360 -0
- package/dist/src/pools/types.js +2 -0
- package/dist/src/protocols/adapters/dashscope.js +267 -0
- package/dist/src/protocols/adapters/inferenceV2.js +346 -0
- package/dist/src/protocols/adapters/openai.js +27 -0
- package/dist/src/protocols/registry.js +99 -0
- package/dist/src/protocols/types.js +2 -0
- package/dist/src/providers/health.js +153 -0
- package/dist/src/providers/importer.js +289 -0
- package/dist/src/providers/modelRegistry.js +313 -0
- package/dist/src/providers/repository.js +361 -0
- package/dist/src/providers/types.js +2 -0
- package/dist/src/routes/admin.js +531 -0
- package/dist/src/routes/audio.js +295 -0
- package/dist/src/routes/chat.js +240 -0
- package/dist/src/routes/embeddings.js +157 -0
- package/dist/src/routes/images.js +288 -0
- package/dist/src/routes/mcp.js +256 -0
- package/dist/src/routes/mcpService.js +100 -0
- package/dist/src/routes/models.js +48 -0
- package/dist/src/routes/responses.js +711 -0
- package/dist/src/routes/sessions.js +450 -0
- package/dist/src/routes/stats.js +270 -0
- package/dist/src/routes/ui.js +97 -0
- package/dist/src/routes/videos.js +107 -0
- package/dist/src/routing/router.js +338 -0
- package/dist/src/services/imageGeneration.js +280 -0
- package/dist/src/services/imageUnderstanding.js +352 -0
- package/dist/src/services/videoGeneration.js +79 -0
- package/dist/src/storage/captureRepository.js +1591 -0
- package/dist/src/storage/files.js +157 -0
- package/dist/src/storage/imageCache.js +346 -0
- package/dist/src/storage/repositories.js +388 -0
- package/dist/src/storage/sessionRepository.js +370 -0
- package/dist/src/storage/statsRepository.js +204 -0
- package/dist/src/transport/httpClient.js +126 -0
- package/dist/src/types.js +2 -0
- package/dist/src/utils/messageMedia.js +285 -0
- package/dist/src/utils/modelCapabilities.js +108 -0
- package/dist/src/utils/modelDiscovery.js +170 -0
- package/dist/src/version.js +5 -0
- package/dist/src/workers/captureRetention.js +25 -0
- package/dist/src/workers/configWatcher.js +91 -0
- package/dist/src/workers/healthChecker.js +21 -0
- package/dist/src/workers/statsRotation.js +41 -0
- package/docs/LLM/output_schema.md +312 -0
- package/docs/benchmark.md +208 -0
- package/docs/mcp-guidelines.md +125 -0
- package/docs/mcp-service.md +178 -0
- package/docs/opencode.md +86 -0
- package/docs/providers.md +79 -0
- package/examples/benchmark.config.yaml +28 -0
- package/examples/providers/alibaba-dashscope.yaml +88 -0
- package/examples/providers/alibaba-llm.yaml +64 -0
- package/examples/providers/alibaba-registry.yaml +7 -0
- package/examples/providers/inference-v2-ray.yaml +29 -0
- package/examples/scenarios/assets/omni-call-sample.wav +0 -0
- package/examples/scenarios/custom.jsonl +5 -0
- package/examples/scenarios/custom.yaml +40 -0
- package/model-form-v2.png +0 -0
- package/package.json +66 -0
- package/provider-form-v2.png +0 -0
- package/provider-form.png +0 -0
- package/scripts/manual-test.sh +11 -0
- package/scripts/version-from-git.js +23 -0
- package/src/benchmark/artifacts.ts +149 -0
- package/src/benchmark/capabilityClassifier.ts +99 -0
- package/src/benchmark/capabilityStore.ts +174 -0
- package/src/benchmark/config.ts +337 -0
- package/src/benchmark/gates.ts +164 -0
- package/src/benchmark/jobs.ts +312 -0
- package/src/benchmark/runner.ts +2519 -0
- package/src/benchmark/schema.ts +443 -0
- package/src/benchmark/suites.ts +323 -0
- package/src/benchmark/tinyQaDataset.ts +428 -0
- package/src/benchmark/types.ts +442 -0
- package/src/config.ts +44 -0
- package/src/index.ts +195 -0
- package/src/mcp/client.ts +305 -0
- package/src/mcp/discovery.ts +266 -0
- package/src/mcp/policy.ts +105 -0
- package/src/mcp/registry.ts +164 -0
- package/src/mcp/service.ts +611 -0
- package/src/middleware/auth.ts +251 -0
- package/src/middleware/requestCapture.ts +245 -0
- package/src/middleware/requestStats.ts +163 -0
- package/src/pools/builder.ts +159 -0
- package/src/pools/repository.ts +71 -0
- package/src/pools/scheduler.ts +425 -0
- package/src/pools/types.ts +117 -0
- package/src/protocols/adapters/dashscope.ts +335 -0
- package/src/protocols/adapters/inferenceV2.ts +428 -0
- package/src/protocols/adapters/openai.ts +32 -0
- package/src/protocols/registry.ts +117 -0
- package/src/protocols/types.ts +81 -0
- package/src/providers/health.ts +207 -0
- package/src/providers/importer.ts +402 -0
- package/src/providers/modelRegistry.ts +415 -0
- package/src/providers/repository.ts +439 -0
- package/src/providers/types.ts +113 -0
- package/src/routes/admin.ts +666 -0
- package/src/routes/audio.ts +372 -0
- package/src/routes/chat.ts +301 -0
- package/src/routes/embeddings.ts +197 -0
- package/src/routes/images.ts +356 -0
- package/src/routes/mcp.ts +320 -0
- package/src/routes/mcpService.ts +114 -0
- package/src/routes/models.ts +50 -0
- package/src/routes/responses.ts +872 -0
- package/src/routes/sessions.ts +558 -0
- package/src/routes/stats.ts +312 -0
- package/src/routes/ui.ts +96 -0
- package/src/routes/videos.ts +132 -0
- package/src/routing/router.ts +501 -0
- package/src/services/imageGeneration.ts +396 -0
- package/src/services/imageUnderstanding.ts +449 -0
- package/src/services/videoGeneration.ts +127 -0
- package/src/storage/captureRepository.ts +1835 -0
- package/src/storage/files.ts +178 -0
- package/src/storage/imageCache.ts +405 -0
- package/src/storage/repositories.ts +494 -0
- package/src/storage/sessionRepository.ts +419 -0
- package/src/storage/statsRepository.ts +238 -0
- package/src/transport/httpClient.ts +145 -0
- package/src/types.ts +322 -0
- package/src/utils/messageMedia.ts +293 -0
- package/src/utils/modelCapabilities.ts +161 -0
- package/src/utils/modelDiscovery.ts +203 -0
- package/src/workers/captureRetention.ts +25 -0
- package/src/workers/configWatcher.ts +115 -0
- package/src/workers/healthChecker.ts +22 -0
- package/src/workers/statsRotation.ts +49 -0
- package/tests/benchmarkAdminRoutes.test.ts +82 -0
- package/tests/benchmarkBasics.test.ts +116 -0
- package/tests/captureAdminRoutes.test.ts +420 -0
- package/tests/captureRepository.test.ts +797 -0
- package/tests/cliLegacyRewrite.test.ts +45 -0
- package/tests/imageGeneration.service.test.ts +107 -0
- package/tests/imageUnderstanding.service.test.ts +123 -0
- package/tests/mcpPolicy.test.ts +105 -0
- package/tests/mcpService.test.ts +1245 -0
- package/tests/modelRef.test.ts +23 -0
- package/tests/modelsRoutes.test.ts +154 -0
- package/tests/sessionMediaCache.test.ts +167 -0
- package/tests/statsRoutes.test.ts +323 -0
- package/tsconfig.json +15 -0
- package/ui/index.html +16 -0
- package/ui/package-lock.json +8521 -0
- package/ui/package.json +52 -0
- package/ui/postcss.config.js +6 -0
- package/ui/public/assets/apple-touch-icon.png +0 -0
- package/ui/public/assets/favicon-16.png +0 -0
- package/ui/public/assets/favicon-32.png +0 -0
- package/ui/public/assets/icon-192.png +0 -0
- package/ui/public/assets/icon-512.png +0 -0
- package/ui/src/App.tsx +27 -0
- package/ui/src/api/client.ts +1503 -0
- package/ui/src/components/EndpointUsageGuide.tsx +361 -0
- package/ui/src/components/Layout.tsx +124 -0
- package/ui/src/components/MessageContent.tsx +365 -0
- package/ui/src/components/ToolCallMessage.tsx +179 -0
- package/ui/src/components/ToolPicker.tsx +442 -0
- package/ui/src/components/messageContentParser.test.ts +41 -0
- package/ui/src/components/messageContentParser.ts +73 -0
- package/ui/src/components/thinkingPreview.test.ts +27 -0
- package/ui/src/components/thinkingPreview.ts +15 -0
- package/ui/src/components/toMermaidSankey.test.ts +78 -0
- package/ui/src/components/toMermaidSankey.ts +56 -0
- package/ui/src/components/ui/button.tsx +58 -0
- package/ui/src/components/ui/input.tsx +21 -0
- package/ui/src/components/ui/textarea.tsx +21 -0
- package/ui/src/lib/utils.ts +6 -0
- package/ui/src/main.tsx +9 -0
- package/ui/src/pages/AgentPlayground.tsx +2010 -0
- package/ui/src/pages/Benchmark.tsx +988 -0
- package/ui/src/pages/Dashboard.tsx +581 -0
- package/ui/src/pages/Peek.tsx +962 -0
- package/ui/src/pages/Settings.tsx +2013 -0
- package/ui/src/pages/agentPlaygroundPayload.test.ts +109 -0
- package/ui/src/pages/agentPlaygroundPayload.ts +97 -0
- package/ui/src/pages/agentThinkingContent.test.ts +50 -0
- package/ui/src/pages/agentThinkingContent.ts +57 -0
- package/ui/src/pages/dashboardTokenUsage.test.ts +66 -0
- package/ui/src/pages/dashboardTokenUsage.ts +36 -0
- package/ui/src/pages/imageUpload.test.ts +39 -0
- package/ui/src/pages/imageUpload.ts +71 -0
- package/ui/src/pages/peekFilters.test.ts +29 -0
- package/ui/src/pages/peekFilters.ts +13 -0
- package/ui/src/pages/peekMedia.test.ts +58 -0
- package/ui/src/pages/peekMedia.ts +148 -0
- package/ui/src/pages/sessionAutoTitle.test.ts +128 -0
- package/ui/src/pages/sessionAutoTitle.ts +106 -0
- package/ui/src/stores/settings.ts +58 -0
- package/ui/src/styles/globals.css +223 -0
- package/ui/src/vite-env.d.ts +8 -0
- package/ui/tailwind.config.js +106 -0
- package/ui/tsconfig.json +32 -0
- package/ui/vite.config.ts +37 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { promises as fs } from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import crypto from "crypto";
|
|
5
|
+
import YAML from "yaml";
|
|
6
|
+
import { EndpointDoc, EndpointHealth, ProviderModelHealth, RequestLog } from "../types";
|
|
7
|
+
|
|
8
|
+
export interface ConfigFile {
|
|
9
|
+
endpoints: Array<Omit<EndpointDoc, "health">>;
|
|
10
|
+
// Auth configuration
|
|
11
|
+
authEnabled?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface HealthFile {
|
|
15
|
+
endpoints: Record<string, EndpointHealth>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ProviderHealthFile {
|
|
19
|
+
models: Record<string, ProviderModelHealth>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface StoragePaths {
|
|
23
|
+
baseDir: string;
|
|
24
|
+
configPath: string;
|
|
25
|
+
healthPath: string;
|
|
26
|
+
providerHealthPath: string;
|
|
27
|
+
requestLogPath: string;
|
|
28
|
+
providersPath: string;
|
|
29
|
+
poolsPath: string;
|
|
30
|
+
poolStatePath: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function resolveStoragePaths(): StoragePaths {
|
|
34
|
+
const baseDir = process.env.WAYPOI_DIR ?? path.join(os.homedir(), ".config", "waypoi");
|
|
35
|
+
const configPath = process.env.WAYPOI_CONFIG ?? path.join(baseDir, "config.yaml");
|
|
36
|
+
return {
|
|
37
|
+
baseDir,
|
|
38
|
+
configPath,
|
|
39
|
+
healthPath: path.join(baseDir, "health.json"),
|
|
40
|
+
providerHealthPath: path.join(baseDir, "providers_health.json"),
|
|
41
|
+
requestLogPath: path.join(baseDir, "request_logs.jsonl"),
|
|
42
|
+
providersPath: path.join(baseDir, "providers.json"),
|
|
43
|
+
poolsPath: path.join(baseDir, "pools.json"),
|
|
44
|
+
poolStatePath: path.join(baseDir, "pool_state.json"),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function ensureStorageDir(paths: StoragePaths): Promise<void> {
|
|
49
|
+
await fs.mkdir(paths.baseDir, { recursive: true });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function loadConfig(paths: StoragePaths): Promise<ConfigFile> {
|
|
53
|
+
await ensureStorageDir(paths);
|
|
54
|
+
try {
|
|
55
|
+
const raw = await fs.readFile(paths.configPath, "utf8");
|
|
56
|
+
const doc = YAML.parse(raw) as ConfigFile | null;
|
|
57
|
+
if (doc?.endpoints) {
|
|
58
|
+
return doc;
|
|
59
|
+
}
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { endpoints: [] };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function saveConfig(paths: StoragePaths, config: ConfigFile): Promise<void> {
|
|
69
|
+
await ensureStorageDir(paths);
|
|
70
|
+
const yaml = YAML.stringify(config);
|
|
71
|
+
await writeAtomic(paths.configPath, yaml);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function loadHealth(paths: StoragePaths): Promise<HealthFile> {
|
|
75
|
+
await ensureStorageDir(paths);
|
|
76
|
+
try {
|
|
77
|
+
const raw = await fs.readFile(paths.healthPath, "utf8");
|
|
78
|
+
const data = JSON.parse(raw) as HealthFile | null;
|
|
79
|
+
if (data?.endpoints) {
|
|
80
|
+
return data;
|
|
81
|
+
}
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { endpoints: {} };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function saveHealth(paths: StoragePaths, health: HealthFile): Promise<void> {
|
|
91
|
+
await ensureStorageDir(paths);
|
|
92
|
+
await writeAtomic(paths.healthPath, JSON.stringify(health, null, 2));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function loadProviderHealth(paths: StoragePaths): Promise<ProviderHealthFile> {
|
|
96
|
+
await ensureStorageDir(paths);
|
|
97
|
+
try {
|
|
98
|
+
const raw = await fs.readFile(paths.providerHealthPath, "utf8");
|
|
99
|
+
const data = JSON.parse(raw) as ProviderHealthFile | null;
|
|
100
|
+
if (data?.models) {
|
|
101
|
+
return data;
|
|
102
|
+
}
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return { models: {} };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function saveProviderHealth(paths: StoragePaths, health: ProviderHealthFile): Promise<void> {
|
|
112
|
+
await ensureStorageDir(paths);
|
|
113
|
+
await writeAtomic(paths.providerHealthPath, JSON.stringify(health, null, 2));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function readJsonFile<T>(
|
|
117
|
+
filePath: string,
|
|
118
|
+
fallback: T
|
|
119
|
+
): Promise<T> {
|
|
120
|
+
try {
|
|
121
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
122
|
+
return JSON.parse(raw) as T;
|
|
123
|
+
} catch (error) {
|
|
124
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
125
|
+
return fallback;
|
|
126
|
+
}
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function writeJsonFile(filePath: string, value: unknown): Promise<void> {
|
|
132
|
+
await ensureDir(path.dirname(filePath));
|
|
133
|
+
await writeAtomic(filePath, JSON.stringify(value, null, 2));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function newEndpointId(): string {
|
|
137
|
+
return crypto.randomUUID();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function defaultHealth(): EndpointHealth {
|
|
141
|
+
return {
|
|
142
|
+
status: "up",
|
|
143
|
+
consecutiveFailures: 0
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function appendRequestLog(paths: StoragePaths, log: RequestLog): Promise<void> {
|
|
148
|
+
await ensureStorageDir(paths);
|
|
149
|
+
const line = `${JSON.stringify(log)}\n`;
|
|
150
|
+
await fs.appendFile(paths.requestLogPath, line, "utf8");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function readRequestLogs(paths: StoragePaths): Promise<RequestLog[]> {
|
|
154
|
+
await ensureStorageDir(paths);
|
|
155
|
+
try {
|
|
156
|
+
const raw = await fs.readFile(paths.requestLogPath, "utf8");
|
|
157
|
+
return raw
|
|
158
|
+
.split("\n")
|
|
159
|
+
.filter((line) => line.trim().length > 0)
|
|
160
|
+
.map((line) => JSON.parse(line) as RequestLog);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
163
|
+
return [];
|
|
164
|
+
}
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function writeAtomic(filePath: string, content: string): Promise<void> {
|
|
170
|
+
const dir = path.dirname(filePath);
|
|
171
|
+
const tmp = path.join(dir, `.${path.basename(filePath)}.${crypto.randomUUID()}`);
|
|
172
|
+
await fs.writeFile(tmp, content, "utf8");
|
|
173
|
+
await fs.rename(tmp, filePath);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function ensureDir(dir: string): Promise<void> {
|
|
177
|
+
await fs.mkdir(dir, { recursive: true });
|
|
178
|
+
}
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import { promises as fs } from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import crypto from "crypto";
|
|
4
|
+
import { StoragePaths, ensureStorageDir } from "./files";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Media Cache
|
|
8
|
+
*
|
|
9
|
+
* Stores generated and uploaded media locally with LRU eviction.
|
|
10
|
+
* Backward-compatible with previous image-only APIs.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface MediaCacheEntry {
|
|
14
|
+
hash: string;
|
|
15
|
+
filename: string;
|
|
16
|
+
size: number;
|
|
17
|
+
mimeType: string;
|
|
18
|
+
createdAt: Date;
|
|
19
|
+
model?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface CacheIndex {
|
|
23
|
+
entries: MediaCacheEntry[];
|
|
24
|
+
totalSize: number;
|
|
25
|
+
evictionBlockedCount?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface MediaRefIndex {
|
|
29
|
+
refs: Record<string, string[]>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const DEFAULT_MAX_SIZE_BYTES = 1024 * 1024 * 1024; // 1GB
|
|
33
|
+
|
|
34
|
+
export function resolveMediaDir(paths: StoragePaths): string {
|
|
35
|
+
return path.join(paths.baseDir, "media");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function cacheIndexPath(paths: StoragePaths): string {
|
|
39
|
+
return path.join(resolveMediaDir(paths), "index.json");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function refsIndexPath(paths: StoragePaths): string {
|
|
43
|
+
return path.join(resolveMediaDir(paths), "media_refs.json");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function ensureMediaDir(paths: StoragePaths): Promise<void> {
|
|
47
|
+
await ensureStorageDir(paths);
|
|
48
|
+
await fs.mkdir(resolveMediaDir(paths), { recursive: true });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function ensureMediaCacheReady(paths: StoragePaths): Promise<void> {
|
|
52
|
+
await ensureMediaDir(paths);
|
|
53
|
+
const index = await loadCacheIndex(paths);
|
|
54
|
+
await saveCacheIndex(paths, index);
|
|
55
|
+
const refs = await loadRefsIndex(paths);
|
|
56
|
+
await saveRefsIndex(paths, refs);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function loadCacheIndex(paths: StoragePaths): Promise<CacheIndex> {
|
|
60
|
+
const indexPath = cacheIndexPath(paths);
|
|
61
|
+
try {
|
|
62
|
+
const raw = await fs.readFile(indexPath, "utf8");
|
|
63
|
+
const data = JSON.parse(raw) as CacheIndex;
|
|
64
|
+
data.entries = data.entries.map((entry) => ({
|
|
65
|
+
...entry,
|
|
66
|
+
createdAt: new Date(entry.createdAt),
|
|
67
|
+
}));
|
|
68
|
+
data.evictionBlockedCount = typeof data.evictionBlockedCount === "number" ? data.evictionBlockedCount : 0;
|
|
69
|
+
return data;
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
72
|
+
return { entries: [], totalSize: 0, evictionBlockedCount: 0 };
|
|
73
|
+
}
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function saveCacheIndex(paths: StoragePaths, index: CacheIndex): Promise<void> {
|
|
79
|
+
await fs.writeFile(cacheIndexPath(paths), JSON.stringify(index, null, 2), "utf8");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function loadRefsIndex(paths: StoragePaths): Promise<MediaRefIndex> {
|
|
83
|
+
const refsPath = refsIndexPath(paths);
|
|
84
|
+
try {
|
|
85
|
+
const raw = await fs.readFile(refsPath, "utf8");
|
|
86
|
+
const parsed = JSON.parse(raw) as MediaRefIndex;
|
|
87
|
+
const refs = parsed?.refs && typeof parsed.refs === "object" ? parsed.refs : {};
|
|
88
|
+
const cleaned: Record<string, string[]> = {};
|
|
89
|
+
for (const [hash, sessionIds] of Object.entries(refs)) {
|
|
90
|
+
if (!Array.isArray(sessionIds)) continue;
|
|
91
|
+
const deduped = Array.from(new Set(sessionIds.filter((id) => typeof id === "string" && id.length > 0)));
|
|
92
|
+
if (deduped.length > 0) {
|
|
93
|
+
cleaned[hash] = deduped;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { refs: cleaned };
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
99
|
+
return { refs: {} };
|
|
100
|
+
}
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function saveRefsIndex(paths: StoragePaths, index: MediaRefIndex): Promise<void> {
|
|
106
|
+
await fs.writeFile(refsIndexPath(paths), JSON.stringify(index, null, 2), "utf8");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function storeMedia(
|
|
110
|
+
paths: StoragePaths,
|
|
111
|
+
data: Buffer | string,
|
|
112
|
+
options?: { model?: string; maxSizeBytes?: number; mimeType?: string }
|
|
113
|
+
): Promise<{ filePath: string; hash: string; mimeType: string; evicted: string[] }> {
|
|
114
|
+
await ensureMediaDir(paths);
|
|
115
|
+
|
|
116
|
+
const normalized = normalizeMediaInput(data, options?.mimeType);
|
|
117
|
+
const buffer = normalized.buffer;
|
|
118
|
+
const mimeType = normalized.mimeType;
|
|
119
|
+
const extension = extensionFromMime(mimeType, buffer);
|
|
120
|
+
|
|
121
|
+
const hash = crypto.createHash("sha256").update(buffer).digest("hex").slice(0, 16);
|
|
122
|
+
const filename = `${hash}.${extension}`;
|
|
123
|
+
const filePath = path.join(resolveMediaDir(paths), filename);
|
|
124
|
+
|
|
125
|
+
const index = await loadCacheIndex(paths);
|
|
126
|
+
const refs = await loadRefsIndex(paths);
|
|
127
|
+
const existing = index.entries.find((entry) => entry.hash === hash);
|
|
128
|
+
if (existing) {
|
|
129
|
+
index.entries = index.entries.filter((entry) => entry.hash !== hash);
|
|
130
|
+
existing.createdAt = new Date();
|
|
131
|
+
index.entries.push(existing);
|
|
132
|
+
await saveCacheIndex(paths, index);
|
|
133
|
+
return { filePath, hash, mimeType: existing.mimeType, evicted: [] };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
await fs.writeFile(filePath, buffer);
|
|
137
|
+
|
|
138
|
+
const entry: MediaCacheEntry = {
|
|
139
|
+
hash,
|
|
140
|
+
filename,
|
|
141
|
+
size: buffer.length,
|
|
142
|
+
mimeType,
|
|
143
|
+
createdAt: new Date(),
|
|
144
|
+
model: options?.model,
|
|
145
|
+
};
|
|
146
|
+
index.entries.push(entry);
|
|
147
|
+
index.totalSize += buffer.length;
|
|
148
|
+
|
|
149
|
+
const maxSize = options?.maxSizeBytes ?? DEFAULT_MAX_SIZE_BYTES;
|
|
150
|
+
const evicted: string[] = [];
|
|
151
|
+
while (index.totalSize > maxSize && index.entries.length > 1) {
|
|
152
|
+
const evictionTargetIndex = index.entries.findIndex((entry) => !isHashReferenced(refs, entry.hash));
|
|
153
|
+
if (evictionTargetIndex < 0) {
|
|
154
|
+
index.evictionBlockedCount = (index.evictionBlockedCount ?? 0) + 1;
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const [oldest] = index.entries.splice(evictionTargetIndex, 1);
|
|
159
|
+
if (!oldest) {
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
await fs.unlink(path.join(resolveMediaDir(paths), oldest.filename));
|
|
164
|
+
evicted.push(oldest.hash);
|
|
165
|
+
} catch {
|
|
166
|
+
// ignore missing file
|
|
167
|
+
}
|
|
168
|
+
index.totalSize -= oldest.size;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
await saveCacheIndex(paths, index);
|
|
172
|
+
|
|
173
|
+
return { filePath, hash, mimeType, evicted };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function getMediaPath(paths: StoragePaths, hash: string): Promise<string | null> {
|
|
177
|
+
const index = await loadCacheIndex(paths);
|
|
178
|
+
const entry = index.entries.find((item) => item.hash === hash);
|
|
179
|
+
if (!entry) {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
return path.join(resolveMediaDir(paths), entry.filename);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function getMediaEntry(paths: StoragePaths, hash: string): Promise<MediaCacheEntry | null> {
|
|
186
|
+
const index = await loadCacheIndex(paths);
|
|
187
|
+
return index.entries.find((item) => item.hash === hash) ?? null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function getCacheStats(paths: StoragePaths): Promise<{
|
|
191
|
+
count: number;
|
|
192
|
+
totalSizeBytes: number;
|
|
193
|
+
referencedCount: number;
|
|
194
|
+
unreferencedCount: number;
|
|
195
|
+
evictionBlockedCount: number;
|
|
196
|
+
oldestEntry?: Date;
|
|
197
|
+
newestEntry?: Date;
|
|
198
|
+
}> {
|
|
199
|
+
const index = await loadCacheIndex(paths);
|
|
200
|
+
const refs = await loadRefsIndex(paths);
|
|
201
|
+
if (index.entries.length === 0) {
|
|
202
|
+
return {
|
|
203
|
+
count: 0,
|
|
204
|
+
totalSizeBytes: 0,
|
|
205
|
+
referencedCount: 0,
|
|
206
|
+
unreferencedCount: 0,
|
|
207
|
+
evictionBlockedCount: index.evictionBlockedCount ?? 0,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const sorted = [...index.entries].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
|
212
|
+
const referencedCount = index.entries.filter((entry) => isHashReferenced(refs, entry.hash)).length;
|
|
213
|
+
return {
|
|
214
|
+
count: index.entries.length,
|
|
215
|
+
totalSizeBytes: index.totalSize,
|
|
216
|
+
referencedCount,
|
|
217
|
+
unreferencedCount: index.entries.length - referencedCount,
|
|
218
|
+
evictionBlockedCount: index.evictionBlockedCount ?? 0,
|
|
219
|
+
oldestEntry: sorted[0].createdAt,
|
|
220
|
+
newestEntry: sorted[sorted.length - 1].createdAt,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export async function clearCache(paths: StoragePaths): Promise<number> {
|
|
225
|
+
const index = await loadCacheIndex(paths);
|
|
226
|
+
let deleted = 0;
|
|
227
|
+
|
|
228
|
+
for (const entry of index.entries) {
|
|
229
|
+
try {
|
|
230
|
+
await fs.unlink(path.join(resolveMediaDir(paths), entry.filename));
|
|
231
|
+
deleted += 1;
|
|
232
|
+
} catch {
|
|
233
|
+
// ignore missing file
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
await saveCacheIndex(paths, { entries: [], totalSize: 0, evictionBlockedCount: 0 });
|
|
238
|
+
await saveRefsIndex(paths, { refs: {} });
|
|
239
|
+
return deleted;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function syncSessionMediaReferences(
|
|
243
|
+
paths: StoragePaths,
|
|
244
|
+
sessionId: string,
|
|
245
|
+
hashes: string[]
|
|
246
|
+
): Promise<void> {
|
|
247
|
+
await ensureMediaDir(paths);
|
|
248
|
+
const refs = await loadRefsIndex(paths);
|
|
249
|
+
const nextHashes = Array.from(new Set(hashes.filter((hash) => /^[a-f0-9]{16}$/i.test(hash))));
|
|
250
|
+
|
|
251
|
+
// Remove previous references for this session from all hashes.
|
|
252
|
+
for (const [hash, sessionIds] of Object.entries(refs.refs)) {
|
|
253
|
+
const filtered = sessionIds.filter((id) => id !== sessionId);
|
|
254
|
+
if (filtered.length > 0) {
|
|
255
|
+
refs.refs[hash] = filtered;
|
|
256
|
+
} else {
|
|
257
|
+
delete refs.refs[hash];
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
for (const hash of nextHashes) {
|
|
262
|
+
const existing = refs.refs[hash] ?? [];
|
|
263
|
+
refs.refs[hash] = Array.from(new Set([...existing, sessionId]));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
await saveRefsIndex(paths, refs);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export async function unmarkSessionMediaReferences(
|
|
270
|
+
paths: StoragePaths,
|
|
271
|
+
sessionId: string
|
|
272
|
+
): Promise<void> {
|
|
273
|
+
await ensureMediaDir(paths);
|
|
274
|
+
const refs = await loadRefsIndex(paths);
|
|
275
|
+
for (const [hash, sessionIds] of Object.entries(refs.refs)) {
|
|
276
|
+
const filtered = sessionIds.filter((id) => id !== sessionId);
|
|
277
|
+
if (filtered.length > 0) {
|
|
278
|
+
refs.refs[hash] = filtered;
|
|
279
|
+
} else {
|
|
280
|
+
delete refs.refs[hash];
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
await saveRefsIndex(paths, refs);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export async function getMediaRefCount(paths: StoragePaths, hash: string): Promise<number> {
|
|
287
|
+
const refs = await loadRefsIndex(paths);
|
|
288
|
+
return refs.refs[hash]?.length ?? 0;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Remove all media entries that are no longer referenced by any session.
|
|
293
|
+
* Call this after deleting a session to free up disk space.
|
|
294
|
+
* Returns the number of files deleted.
|
|
295
|
+
*/
|
|
296
|
+
export async function cleanOrphanedMedia(paths: StoragePaths): Promise<number> {
|
|
297
|
+
const index = await loadCacheIndex(paths);
|
|
298
|
+
const refs = await loadRefsIndex(paths);
|
|
299
|
+
let deleted = 0;
|
|
300
|
+
const surviving: typeof index.entries = [];
|
|
301
|
+
let survivingSize = 0;
|
|
302
|
+
|
|
303
|
+
for (const entry of index.entries) {
|
|
304
|
+
if (isHashReferenced(refs, entry.hash)) {
|
|
305
|
+
surviving.push(entry);
|
|
306
|
+
survivingSize += entry.size;
|
|
307
|
+
} else {
|
|
308
|
+
try {
|
|
309
|
+
await fs.unlink(path.join(resolveMediaDir(paths), entry.filename));
|
|
310
|
+
deleted += 1;
|
|
311
|
+
} catch {
|
|
312
|
+
// File already gone — still remove from index
|
|
313
|
+
deleted += 1;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (deleted > 0) {
|
|
319
|
+
await saveCacheIndex(paths, { ...index, entries: surviving, totalSize: survivingSize });
|
|
320
|
+
}
|
|
321
|
+
return deleted;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function isHashReferenced(refs: MediaRefIndex, hash: string): boolean {
|
|
325
|
+
const sessionIds = refs.refs[hash];
|
|
326
|
+
return Array.isArray(sessionIds) && sessionIds.length > 0;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function normalizeMediaInput(
|
|
330
|
+
data: Buffer | string,
|
|
331
|
+
hintedMime?: string
|
|
332
|
+
): { buffer: Buffer; mimeType: string } {
|
|
333
|
+
if (Buffer.isBuffer(data)) {
|
|
334
|
+
const mimeType = hintedMime ?? detectMimeType(data);
|
|
335
|
+
return { buffer: data, mimeType };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const trimmed = data.trim();
|
|
339
|
+
const dataUrlMatch = trimmed.match(/^data:([^;]+);base64,(.+)$/i);
|
|
340
|
+
if (dataUrlMatch) {
|
|
341
|
+
const mimeType = dataUrlMatch[1].toLowerCase();
|
|
342
|
+
const base64Payload = dataUrlMatch[2].replace(/\s+/g, "");
|
|
343
|
+
return { buffer: Buffer.from(base64Payload, "base64"), mimeType };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const buffer = Buffer.from(trimmed.replace(/\s+/g, ""), "base64");
|
|
347
|
+
const mimeType = hintedMime ?? detectMimeType(buffer);
|
|
348
|
+
return { buffer, mimeType };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function extensionFromMime(mimeType: string, buffer: Buffer): string {
|
|
352
|
+
const map: Record<string, string> = {
|
|
353
|
+
"image/png": "png",
|
|
354
|
+
"image/jpeg": "jpg",
|
|
355
|
+
"image/jpg": "jpg",
|
|
356
|
+
"image/gif": "gif",
|
|
357
|
+
"image/webp": "webp",
|
|
358
|
+
"audio/wav": "wav",
|
|
359
|
+
"audio/x-wav": "wav",
|
|
360
|
+
"audio/mpeg": "mp3",
|
|
361
|
+
"audio/mp3": "mp3",
|
|
362
|
+
"audio/ogg": "ogg",
|
|
363
|
+
"audio/webm": "webm",
|
|
364
|
+
"audio/mp4": "m4a",
|
|
365
|
+
};
|
|
366
|
+
if (map[mimeType]) {
|
|
367
|
+
return map[mimeType];
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// fallback by magic bytes
|
|
371
|
+
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) return "png";
|
|
372
|
+
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return "jpg";
|
|
373
|
+
if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) return "gif";
|
|
374
|
+
if (buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46) {
|
|
375
|
+
if (buffer.length > 11) {
|
|
376
|
+
const chunk = buffer.subarray(8, 12).toString("ascii");
|
|
377
|
+
if (chunk === "WAVE") return "wav";
|
|
378
|
+
if (chunk === "WEBP") return "webp";
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return "bin";
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function detectMimeType(buffer: Buffer): string {
|
|
385
|
+
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) {
|
|
386
|
+
return "image/png";
|
|
387
|
+
}
|
|
388
|
+
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
|
389
|
+
return "image/jpeg";
|
|
390
|
+
}
|
|
391
|
+
if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) {
|
|
392
|
+
return "image/gif";
|
|
393
|
+
}
|
|
394
|
+
if (buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46) {
|
|
395
|
+
if (buffer.length > 11) {
|
|
396
|
+
const chunk = buffer.subarray(8, 12).toString("ascii");
|
|
397
|
+
if (chunk === "WAVE") return "audio/wav";
|
|
398
|
+
if (chunk === "WEBP") return "image/webp";
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
if (buffer.length > 3 && buffer[0] === 0x49 && buffer[1] === 0x44 && buffer[2] === 0x33) {
|
|
402
|
+
return "audio/mpeg";
|
|
403
|
+
}
|
|
404
|
+
return "application/octet-stream";
|
|
405
|
+
}
|