feat: optimize text generation speed across all platforms and fix DeepThink control
This commit is contained in:
1 parent
523b791471
commit
2aec5c36ea
7 files changed
+795
-37
No files matched your search
@@ -130,7 +130,23 @@ function App() {
|
||||
temperature: 0.7,
|
||||
systemPrompt: "You are a helpful local AI assistant.",
|
||||
threads: Math.max(4, Math.min(16, (navigator.hardwareConcurrency || 4) - 2)),
|
||||
enableThinking: true
|
||||
enableThinking: true,
|
||||
// New performance settings
|
||||
gpuLayers: -1,
|
||||
maxTokens: 1024,
|
||||
topP: 0.95,
|
||||
topK: 40,
|
||||
minP: 0.05,
|
||||
repeatPenalty: 1.1,
|
||||
seed: null,
|
||||
performanceProfile: "balanced",
|
||||
flashAttn: true,
|
||||
cacheTypeK: "q8_0",
|
||||
cacheTypeV: "q8_0",
|
||||
mlock: false,
|
||||
mmap: true,
|
||||
cachePrompt: true,
|
||||
defragThold: 0.1,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -685,6 +685,287 @@ function Settings({
|
||||
<strong>No GPU detected.</strong> Text generation will use CPU only. Install GPU drivers or restart if you have a dedicated graphics card.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Performance Profile Preset */}
|
||||
<div className="m3-text-field" style={{ marginTop: "16px" }}>
|
||||
<label className="m3-text-field-label">Performance Profile</label>
|
||||
<select
|
||||
value={textSettings.performanceProfile || "balanced"}
|
||||
onChange={(e) => {
|
||||
const profile = e.target.value;
|
||||
updateTextSetting("performanceProfile", profile);
|
||||
// Auto-apply recommended settings for the profile
|
||||
if (profile === "potato") {
|
||||
updateTextSetting("contextSize", 2048);
|
||||
updateTextSetting("cacheTypeK", "q4_0");
|
||||
updateTextSetting("cacheTypeV", "q4_0");
|
||||
updateTextSetting("mlock", true);
|
||||
updateTextSetting("gpuLayers", 0);
|
||||
} else if (profile === "balanced") {
|
||||
updateTextSetting("contextSize", 4096);
|
||||
updateTextSetting("cacheTypeK", "q8_0");
|
||||
updateTextSetting("cacheTypeV", "q8_0");
|
||||
updateTextSetting("mlock", false);
|
||||
updateTextSetting("gpuLayers", -1);
|
||||
} else if (profile === "high") {
|
||||
updateTextSetting("contextSize", 8192);
|
||||
updateTextSetting("cacheTypeK", "q8_0");
|
||||
updateTextSetting("cacheTypeV", "q8_0");
|
||||
updateTextSetting("mlock", false);
|
||||
updateTextSetting("gpuLayers", -1);
|
||||
}
|
||||
}}
|
||||
className="m3-input"
|
||||
style={{ marginTop: "6px", height: "40px", cursor: "pointer" }}
|
||||
>
|
||||
<option value="potato">🥔 Potato PC (Low-end CPU, no GPU)</option>
|
||||
<option value="balanced">⚖️ Balanced (Mid-range GPU/CPU)</option>
|
||||
<option value="high">🚀 High-End (Fast GPU, 16GB+ VRAM)</option>
|
||||
<option value="custom">🔧 Custom (Manual settings)</option>
|
||||
</select>
|
||||
<span style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)", marginTop: "4px" }}>
|
||||
One-click optimization for your hardware tier. Select "Custom" to fine-tune individual settings.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Advanced Settings Toggle */}
|
||||
<details style={{ marginTop: "16px" }}>
|
||||
<summary style={{
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 600,
|
||||
color: "var(--md-sys-color-primary)",
|
||||
cursor: "pointer",
|
||||
userSelect: "none"
|
||||
}}>
|
||||
Advanced Settings
|
||||
</summary>
|
||||
<div style={{ marginTop: "12px", paddingLeft: "8px" }}>
|
||||
|
||||
{/* CPU Threads */}
|
||||
<div className="m3-slider-group" style={{ marginTop: "12px" }}>
|
||||
<div className="m3-slider-header">
|
||||
<span className="m3-slider-label">CPU Threads</span>
|
||||
<span className="m3-slider-value">{textSettings.threads || specs?.cpu_cores_physical || 4}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="m3-slider"
|
||||
value={textSettings.threads || specs?.cpu_cores_physical || 4}
|
||||
onChange={(e) => updateTextSetting("threads", parseInt(e.target.value))}
|
||||
min="1"
|
||||
max="64"
|
||||
step="1"
|
||||
/>
|
||||
<span style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)", lineHeight: "1.3" }}>
|
||||
Lower = less oversaturation on GPU mode. Auto-detected: {specs?.cpu_cores_physical || 4} physical cores.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Max Tokens */}
|
||||
<div className="m3-slider-group" style={{ marginTop: "12px" }}>
|
||||
<div className="m3-slider-header">
|
||||
<span className="m3-slider-label">Max Tokens</span>
|
||||
<span className="m3-slider-value">{textSettings.maxTokens || 1024}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="m3-slider"
|
||||
value={textSettings.maxTokens || 1024}
|
||||
onChange={(e) => updateTextSetting("maxTokens", parseInt(e.target.value))}
|
||||
min="128"
|
||||
max="4096"
|
||||
step="128"
|
||||
/>
|
||||
<span style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)", lineHeight: "1.3" }}>
|
||||
Maximum tokens to generate per response.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Top P */}
|
||||
<div className="m3-slider-group" style={{ marginTop: "12px" }}>
|
||||
<div className="m3-slider-header">
|
||||
<span className="m3-slider-label">Top P</span>
|
||||
<span className="m3-slider-value">{textSettings.topP || 0.95}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="m3-slider"
|
||||
value={textSettings.topP || 0.95}
|
||||
onChange={(e) => updateTextSetting("topP", parseFloat(e.target.value))}
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Top K */}
|
||||
<div className="m3-slider-group" style={{ marginTop: "12px" }}>
|
||||
<div className="m3-slider-header">
|
||||
<span className="m3-slider-label">Top K</span>
|
||||
<span className="m3-slider-value">{textSettings.topK || 40}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="m3-slider"
|
||||
value={textSettings.topK || 40}
|
||||
onChange={(e) => updateTextSetting("topK", parseInt(e.target.value))}
|
||||
min="1"
|
||||
max="100"
|
||||
step="1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Min P */}
|
||||
<div className="m3-slider-group" style={{ marginTop: "12px" }}>
|
||||
<div className="m3-slider-header">
|
||||
<span className="m3-slider-label">Min P</span>
|
||||
<span className="m3-slider-value">{textSettings.minP || 0.05}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="m3-slider"
|
||||
value={textSettings.minP || 0.05}
|
||||
onChange={(e) => updateTextSetting("minP", parseFloat(e.target.value))}
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Repeat Penalty */}
|
||||
<div className="m3-slider-group" style={{ marginTop: "12px" }}>
|
||||
<div className="m3-slider-header">
|
||||
<span className="m3-slider-label">Repeat Penalty</span>
|
||||
<span className="m3-slider-value">{textSettings.repeatPenalty || 1.1}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="m3-slider"
|
||||
value={textSettings.repeatPenalty || 1.1}
|
||||
onChange={(e) => updateTextSetting("repeatPenalty", parseFloat(e.target.value))}
|
||||
min="0.5"
|
||||
max="2.0"
|
||||
step="0.05"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* KV Cache Type K */}
|
||||
<div className="m3-text-field" style={{ marginTop: "12px" }}>
|
||||
<label className="m3-text-field-label">KV Cache Type K</label>
|
||||
<select
|
||||
value={textSettings.cacheTypeK || "q8_0"}
|
||||
onChange={(e) => updateTextSetting("cacheTypeK", e.target.value)}
|
||||
className="m3-input"
|
||||
style={{ marginTop: "6px", height: "40px", cursor: "pointer" }}
|
||||
>
|
||||
<option value="f16">f16 (Highest quality, most memory)</option>
|
||||
<option value="q8_0">q8_0 (Balanced, 2x memory reduction)</option>
|
||||
<option value="q4_0">q4_0 (Aggressive, 4x memory reduction)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* KV Cache Type V */}
|
||||
<div className="m3-text-field" style={{ marginTop: "12px" }}>
|
||||
<label className="m3-text-field-label">KV Cache Type V</label>
|
||||
<select
|
||||
value={textSettings.cacheTypeV || "q8_0"}
|
||||
onChange={(e) => updateTextSetting("cacheTypeV", e.target.value)}
|
||||
className="m3-input"
|
||||
style={{ marginTop: "6px", height: "40px", cursor: "pointer" }}
|
||||
>
|
||||
<option value="f16">f16 (Highest quality, most memory)</option>
|
||||
<option value="q8_0">q8_0 (Balanced, 2x memory reduction)</option>
|
||||
<option value="q4_0">q4_0 (Aggressive, 4x memory reduction)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Flash Attention Toggle */}
|
||||
<div style={{ marginTop: "12px" }}>
|
||||
<label style={{ display: "flex", alignItems: "flex-start", gap: "10px", cursor: "pointer", fontSize: "0.85rem" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={textSettings.flashAttn !== false}
|
||||
onChange={(e) => updateTextSetting("flashAttn", e.target.checked)}
|
||||
style={{ width: "16px", height: "16px", marginTop: "3px", accentColor: "var(--md-sys-color-primary)", cursor: "pointer" }}
|
||||
/>
|
||||
<div>
|
||||
<strong>Flash Attention</strong>
|
||||
<div style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)", marginTop: "2px" }}>
|
||||
Memory-efficient attention for faster generation and lower VRAM usage.
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Memory Lock (mlock) Toggle */}
|
||||
<div style={{ marginTop: "12px" }}>
|
||||
<label style={{ display: "flex", alignItems: "flex-start", gap: "10px", cursor: "pointer", fontSize: "0.85rem" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={textSettings.mlock === true}
|
||||
onChange={(e) => updateTextSetting("mlock", e.target.checked)}
|
||||
style={{ width: "16px", height: "16px", marginTop: "3px", accentColor: "var(--md-sys-color-primary)", cursor: "pointer" }}
|
||||
/>
|
||||
<div>
|
||||
<strong>Memory Lock (mlock)</strong>
|
||||
<div style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)", marginTop: "2px" }}>
|
||||
Prevent OS from paging model to disk. Recommended for CPU mode.
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Memory Map (mmap) Toggle */}
|
||||
<div style={{ marginTop: "12px" }}>
|
||||
<label style={{ display: "flex", alignItems: "flex-start", gap: "10px", cursor: "pointer", fontSize: "0.85rem" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={textSettings.mmap !== false}
|
||||
onChange={(e) => updateTextSetting("mmap", e.target.checked)}
|
||||
style={{ width: "16px", height: "16px", marginTop: "3px", accentColor: "var(--md-sys-color-primary)", cursor: "pointer" }}
|
||||
/>
|
||||
<div>
|
||||
<strong>Memory Map (mmap)</strong>
|
||||
<div style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)", marginTop: "2px" }}>
|
||||
Faster model loading via memory-mapped files.
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Seed */}
|
||||
<div className="m3-text-field" style={{ marginTop: "12px" }}>
|
||||
<label className="m3-text-field-label">Seed (optional)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={textSettings.seed || ""}
|
||||
onChange={(e) => updateTextSetting("seed", e.target.value ? parseInt(e.target.value) : null)}
|
||||
placeholder="Random"
|
||||
className="m3-input"
|
||||
style={{ marginTop: "6px", height: "40px" }}
|
||||
/>
|
||||
<span style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)", marginTop: "4px" }}>
|
||||
Set for reproducible outputs. Leave empty for random.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{/* NPU Detection Badge */}
|
||||
{specs?.npu?.detected && (
|
||||
<div style={{
|
||||
marginTop: "16px",
|
||||
padding: "10px 14px",
|
||||
background: "rgba(99, 102, 241, 0.08)",
|
||||
border: "1px dashed var(--md-sys-color-primary)",
|
||||
borderRadius: "8px",
|
||||
fontSize: "0.75rem",
|
||||
color: "var(--md-sys-color-on-surface)",
|
||||
lineHeight: "1.45"
|
||||
}}>
|
||||
<strong>Intel NPU detected.</strong> NPU text generation via OpenVINO/IPEX-LLM is on the roadmap for future updates.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
stopLlm,
|
||||
} from "../services/api";
|
||||
|
||||
const processMessageContent = (rawText, apiReasoning = "") => {
|
||||
const processMessageContent = (rawText, apiReasoning = "", enableThinking = true) => {
|
||||
let content = rawText;
|
||||
let reasoning = apiReasoning || "";
|
||||
|
||||
@@ -47,21 +47,32 @@ const processMessageContent = (rawText, apiReasoning = "") => {
|
||||
if (endIdx !== -1) {
|
||||
const actualEndIdxInRaw = startIdx + matchedStartTag.length + endIdx;
|
||||
const extractedReasoning = rawText.substring(startIdx + matchedStartTag.length, actualEndIdxInRaw).trim();
|
||||
reasoning = (reasoning + "\n" + extractedReasoning).trim();
|
||||
|
||||
// Only extract reasoning if thinking is enabled
|
||||
if (enableThinking) {
|
||||
reasoning = (reasoning + "\n" + extractedReasoning).trim();
|
||||
}
|
||||
|
||||
const afterEndText = rawText.substring(actualEndIdxInRaw + matchedEndTag.length);
|
||||
content = (rawText.substring(0, startIdx) + afterEndText).trim();
|
||||
|
||||
return processMessageContent(content, reasoning);
|
||||
return processMessageContent(content, reasoning, enableThinking);
|
||||
} else {
|
||||
const extractedReasoning = rawText.substring(startIdx + matchedStartTag.length).trim();
|
||||
reasoning = (reasoning + "\n" + extractedReasoning).trim();
|
||||
// Only extract reasoning if thinking is enabled
|
||||
if (enableThinking) {
|
||||
reasoning = (reasoning + "\n" + extractedReasoning).trim();
|
||||
}
|
||||
content = rawText.substring(0, startIdx).trim();
|
||||
}
|
||||
}
|
||||
|
||||
return { content, reasoning };
|
||||
};
|
||||
}
|
||||
|
||||
return { content, reasoning };
|
||||
};
|
||||
|
||||
function ThinkingBlock({ reasoning, isComplete, thinkingDuration }) {
|
||||
const [isExpanded, setIsExpanded] = useState(!isComplete);
|
||||
@@ -143,6 +154,11 @@ function TextChat({
|
||||
const chatMessagesRef = useRef(null);
|
||||
const prevMessagesLengthRef = useRef(0);
|
||||
const abortControllerRef = useRef(null);
|
||||
// rAF batching: accumulate token updates and flush once per frame
|
||||
const tokenBufferRef = useRef(null);
|
||||
const rafRef = useRef(null);
|
||||
// Debounced stats: only update stats pill every 250ms
|
||||
const statsDebounceRef = useRef(null);
|
||||
|
||||
const [attachments, setAttachments] = useState([]);
|
||||
const fileInputRef = useRef(null);
|
||||
@@ -477,7 +493,12 @@ function TextChat({
|
||||
|
||||
const response = await streamChatWithLlm(requestMessages, {
|
||||
temperature: textSettings?.temperature || 0.7,
|
||||
maxTokens: 768,
|
||||
maxTokens: textSettings?.maxTokens || 1024,
|
||||
topP: textSettings?.topP,
|
||||
topK: textSettings?.topK,
|
||||
minP: textSettings?.minP,
|
||||
repeatPenalty: textSettings?.repeatPenalty,
|
||||
seed: textSettings?.seed,
|
||||
signal: controller.signal,
|
||||
}, (_token, fullText, _reasoningToken, fullReasoning) => {
|
||||
const now = performance.now();
|
||||
@@ -489,7 +510,7 @@ function TextChat({
|
||||
? Math.max(0.05, (now - firstTokenAt) / 1000)
|
||||
: Math.max(0.05, (now - requestStartedAt) / 1000);
|
||||
|
||||
const processed = processMessageContent(fullText, fullReasoning);
|
||||
const processed = processMessageContent(fullText, fullReasoning, textSettings?.enableThinking !== false);
|
||||
assistantText = processed.content;
|
||||
assistantReasoning = processed.reasoning;
|
||||
|
||||
@@ -505,24 +526,46 @@ function TextChat({
|
||||
? thinkingDuration
|
||||
: (thinkingStartedAt ? (now - thinkingStartedAt) / 1000 : 0);
|
||||
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev];
|
||||
if (updated.length > 0 && updated[updated.length - 1].role === "assistant") {
|
||||
updated[updated.length - 1] = {
|
||||
...updated[updated.length - 1],
|
||||
content: processed.content,
|
||||
reasoning: processed.reasoning,
|
||||
thinkingDuration: currentThinkingDuration,
|
||||
generationStats: {
|
||||
status: "streaming",
|
||||
tokens: streamedTokens,
|
||||
tokensPerSecond: streamedTokens / generationSeconds,
|
||||
seconds: (now - requestStartedAt) / 1000,
|
||||
},
|
||||
};
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
// Debounced stats update: only update stats every 250ms for smoother UI
|
||||
// while text still updates per-frame via rAF batching
|
||||
const currentStats = {
|
||||
status: "streaming",
|
||||
tokens: streamedTokens,
|
||||
tokensPerSecond: streamedTokens / generationSeconds,
|
||||
seconds: (now - requestStartedAt) / 1000,
|
||||
};
|
||||
|
||||
// rAF batching: accumulate updates and flush once per frame (~16ms)
|
||||
// This reduces React re-renders from N per token to ~60/sec
|
||||
tokenBufferRef.current = {
|
||||
content: processed.content,
|
||||
reasoning: processed.reasoning,
|
||||
thinkingDuration: currentThinkingDuration,
|
||||
stats: currentStats,
|
||||
};
|
||||
|
||||
if (!rafRef.current) {
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
const buffer = tokenBufferRef.current;
|
||||
if (buffer) {
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev];
|
||||
if (updated.length > 0 && updated[updated.length - 1].role === "assistant") {
|
||||
updated[updated.length - 1] = {
|
||||
...updated[updated.length - 1],
|
||||
content: buffer.content,
|
||||
reasoning: buffer.reasoning,
|
||||
thinkingDuration: buffer.thinkingDuration,
|
||||
generationStats: buffer.stats,
|
||||
};
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
tokenBufferRef.current = null;
|
||||
}
|
||||
rafRef.current = null;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const completedAt = performance.now();
|
||||
@@ -546,7 +589,7 @@ function TextChat({
|
||||
seconds: exactSeconds,
|
||||
};
|
||||
|
||||
const processed = processMessageContent(assistantText, response.reasoningContent || assistantReasoning);
|
||||
const processed = processMessageContent(assistantText, response.reasoningContent || assistantReasoning, textSettings?.enableThinking !== false);
|
||||
const finalMessages = [...nextMessages, {
|
||||
role: "assistant",
|
||||
content: processed.content,
|
||||
@@ -758,7 +801,8 @@ function TextChat({
|
||||
{messages.map((message, index) => {
|
||||
const processed = processMessageContent(
|
||||
Array.isArray(message.content) ? "" : (message.content || ""),
|
||||
message.reasoning || ""
|
||||
message.reasoning || "",
|
||||
textSettings?.enableThinking !== false
|
||||
);
|
||||
const displayContent = Array.isArray(message.content) ? message.content : processed.content;
|
||||
const displayReasoning = processed.reasoning;
|
||||
|
||||
@@ -365,6 +365,15 @@ export async function chatWithLlm(messages, options = {}) {
|
||||
messages,
|
||||
temperature: options.temperature,
|
||||
max_tokens: options.maxTokens,
|
||||
// New sampling parameters
|
||||
top_p: options.topP,
|
||||
top_k: options.topK,
|
||||
min_p: options.minP,
|
||||
repeat_penalty: options.repeatPenalty,
|
||||
frequency_penalty: options.frequencyPenalty,
|
||||
presence_penalty: options.presencePenalty,
|
||||
seed: options.seed,
|
||||
stop: options.stop,
|
||||
}),
|
||||
});
|
||||
const data = await readJsonResponse(res, "The local text model returned an invalid response.");
|
||||
@@ -385,6 +394,15 @@ export async function streamChatWithLlm(messages, options = {}, onToken = () =>
|
||||
temperature: options.temperature,
|
||||
max_tokens: options.maxTokens,
|
||||
stream: true,
|
||||
// New sampling parameters
|
||||
top_p: options.topP,
|
||||
top_k: options.topK,
|
||||
min_p: options.minP,
|
||||
repeat_penalty: options.repeatPenalty,
|
||||
frequency_penalty: options.frequencyPenalty,
|
||||
presence_penalty: options.presencePenalty,
|
||||
seed: options.seed,
|
||||
stop: options.stop,
|
||||
}),
|
||||
};
|
||||
if (options.signal) {
|
||||
@@ -477,6 +495,17 @@ export async function deleteLlmModel(filename) {
|
||||
return await readJsonResponse(res, "The local server returned an invalid text model delete response.");
|
||||
}
|
||||
|
||||
export async function getLlmRecommendations(useCase = "chat", limit = 10) {
|
||||
try {
|
||||
const res = await fetch(`/api/llm/recommend?useCase=${encodeURIComponent(useCase)}&limit=${limit}`);
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.ok) return null;
|
||||
return data.recommendations;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listGeneratedOutputs() {
|
||||
try {
|
||||
const res = await fetch("/api/outputs");
|
||||
|
||||
Reference in new issue
Block a user