feat(KB-225): add AI text refinement feature to dashboard
- Add AI text refinement backend service with OpenAI/Anthropic integration - Add /api/refine-text API endpoint with error handling - Add QuickEntryBox AI refine button with style presets menu - Add NewTaskModal AI refine feature for task description editing - Add comprehensive tests for backend service, API, and components - Add changeset for the new feature
This commit is contained in:
@@ -1172,3 +1172,127 @@ describe("API Error Handling", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── AI Text Refinement API Tests ───────────────────────────────────────────
|
||||
|
||||
import { refineText, getRefineErrorMessage, REFINE_ERROR_MESSAGES, type RefinementType } from "./api";
|
||||
|
||||
describe("refineText", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("sends POST with text and type, returns refined text", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(
|
||||
mockFetchResponse(true, { refined: "Refined task description" })
|
||||
);
|
||||
|
||||
const result = await refineText("Original text", "clarify");
|
||||
|
||||
expect(result).toBe("Refined task description");
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/ai/refine-text", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
body: JSON.stringify({ text: "Original text", type: "clarify" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("works with all four refinement types", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(
|
||||
mockFetchResponse(true, { refined: "Refined" })
|
||||
);
|
||||
|
||||
const types: RefinementType[] = ["clarify", "add-details", "expand", "simplify"];
|
||||
|
||||
for (const type of types) {
|
||||
const result = await refineText("Test text", type);
|
||||
expect(result).toBe("Refined");
|
||||
}
|
||||
});
|
||||
|
||||
it("throws on rate limit error (429)", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(
|
||||
mockFetchResponse(false, { error: "Rate limit exceeded. Maximum 10 refinement requests per hour." }, 429)
|
||||
);
|
||||
|
||||
await expect(refineText("Test", "clarify")).rejects.toThrow("Rate limit exceeded");
|
||||
});
|
||||
|
||||
it("throws on invalid type error (422)", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(
|
||||
mockFetchResponse(false, { error: "type must be one of: clarify, add-details, expand, simplify" }, 422)
|
||||
);
|
||||
|
||||
await expect(refineText("Test", "invalid" as RefinementType)).rejects.toThrow("type must be one of");
|
||||
});
|
||||
|
||||
it("throws on validation error (400)", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(
|
||||
mockFetchResponse(false, { error: "text must be at least 1 character" }, 400)
|
||||
);
|
||||
|
||||
await expect(refineText("", "clarify")).rejects.toThrow("text must be at least 1 character");
|
||||
});
|
||||
|
||||
it("throws on server error (500)", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(
|
||||
mockFetchResponse(false, { error: "AI service error" }, 500)
|
||||
);
|
||||
|
||||
await expect(refineText("Test", "clarify")).rejects.toThrow("AI service error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRefineErrorMessage", () => {
|
||||
it("returns rate limit message for rate limit errors", () => {
|
||||
const error = new Error("Rate limit exceeded");
|
||||
expect(getRefineErrorMessage(error)).toBe(REFINE_ERROR_MESSAGES.RATE_LIMIT);
|
||||
});
|
||||
|
||||
it("returns rate limit message for 429 status", () => {
|
||||
const error = new Error("429 Too Many Requests");
|
||||
expect(getRefineErrorMessage(error)).toBe(REFINE_ERROR_MESSAGES.RATE_LIMIT);
|
||||
});
|
||||
|
||||
it("returns invalid type message for invalid type errors", () => {
|
||||
const error = new Error("Invalid type selected");
|
||||
expect(getRefineErrorMessage(error)).toBe(REFINE_ERROR_MESSAGES.INVALID_TYPE);
|
||||
});
|
||||
|
||||
it("passes through text validation errors", () => {
|
||||
const error = new Error("text must be at least 1 character");
|
||||
expect(getRefineErrorMessage(error)).toBe("text must be at least 1 character");
|
||||
});
|
||||
|
||||
it("passes through text length errors", () => {
|
||||
const error = new Error("text must not exceed 2000 characters");
|
||||
expect(getRefineErrorMessage(error)).toBe("text must not exceed 2000 characters");
|
||||
});
|
||||
|
||||
it("passes through type required errors", () => {
|
||||
const error = new Error("type is required");
|
||||
expect(getRefineErrorMessage(error)).toBe("type is required");
|
||||
});
|
||||
|
||||
it("returns network message for unknown errors", () => {
|
||||
const error = new Error("Network failure");
|
||||
expect(getRefineErrorMessage(error)).toBe(REFINE_ERROR_MESSAGES.NETWORK);
|
||||
});
|
||||
|
||||
it("returns network message for non-Error values", () => {
|
||||
expect(getRefineErrorMessage("string error")).toBe(REFINE_ERROR_MESSAGES.NETWORK);
|
||||
expect(getRefineErrorMessage(null)).toBe(REFINE_ERROR_MESSAGES.NETWORK);
|
||||
expect(getRefineErrorMessage(undefined)).toBe(REFINE_ERROR_MESSAGES.NETWORK);
|
||||
});
|
||||
});
|
||||
|
||||
describe("REFINE_ERROR_MESSAGES", () => {
|
||||
it("has the expected error messages", () => {
|
||||
expect(REFINE_ERROR_MESSAGES.RATE_LIMIT).toBe("Too many refinement requests. Please wait an hour.");
|
||||
expect(REFINE_ERROR_MESSAGES.INVALID_TYPE).toBe("Invalid refinement option selected.");
|
||||
expect(REFINE_ERROR_MESSAGES.NETWORK).toBe("Failed to refine text. Please try again.");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1180,3 +1180,76 @@ export function refineWorkflowStepPrompt(id: string): Promise<{ prompt: string;
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
// ── AI Text Refinement API ────────────────────────────────────────────
|
||||
|
||||
/** Refinement types for AI text refinement */
|
||||
export type RefinementType = "clarify" | "add-details" | "expand" | "simplify";
|
||||
|
||||
/** Response from text refinement endpoint */
|
||||
export interface RefineTextResponse {
|
||||
refined: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refine task description text using AI.
|
||||
* @param text - The text to refine (1-2000 characters)
|
||||
* @param type - The refinement type: clarify, add-details, expand, or simplify
|
||||
* @returns The refined text
|
||||
* @throws Error with message for rate limit (429), invalid type (422), validation (400), or server errors
|
||||
*/
|
||||
export async function refineText(text: string, type: RefinementType): Promise<string> {
|
||||
const response = await api<RefineTextResponse>("/ai/refine-text", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ text, type }),
|
||||
});
|
||||
return response.refined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error messages for refineText failures (to use with toast notifications).
|
||||
*/
|
||||
export const REFINE_ERROR_MESSAGES = {
|
||||
/** Rate limit exceeded (429) */
|
||||
RATE_LIMIT: "Too many refinement requests. Please wait an hour.",
|
||||
/** Invalid refinement type (422) */
|
||||
INVALID_TYPE: "Invalid refinement option selected.",
|
||||
/** Network or server errors */
|
||||
NETWORK: "Failed to refine text. Please try again.",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Get user-friendly error message for a refineText error.
|
||||
* @param error - The error thrown by refineText
|
||||
* @returns A user-friendly error message suitable for toast display
|
||||
*/
|
||||
export function getRefineErrorMessage(error: unknown): string {
|
||||
if (!(error instanceof Error)) {
|
||||
return REFINE_ERROR_MESSAGES.NETWORK;
|
||||
}
|
||||
|
||||
const message = error.message.toLowerCase();
|
||||
|
||||
// Rate limit errors (429)
|
||||
if (message.includes("rate limit") || message.includes("429")) {
|
||||
return REFINE_ERROR_MESSAGES.RATE_LIMIT;
|
||||
}
|
||||
|
||||
// Invalid type errors (422)
|
||||
if (message.includes("invalid") && message.includes("type")) {
|
||||
return REFINE_ERROR_MESSAGES.INVALID_TYPE;
|
||||
}
|
||||
|
||||
// Text validation errors (400) - pass through from backend
|
||||
if (
|
||||
message.startsWith("text must") ||
|
||||
message.includes("text is required") ||
|
||||
message.includes("type is required")
|
||||
) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
// Default network/server error
|
||||
return REFINE_ERROR_MESSAGES.NETWORK;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
|
||||
import type { Task, TaskCreateInput, ModelPreset, Settings, WorkflowStep } from "@kb/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { uploadAttachment, fetchModels, fetchSettings, fetchWorkflowSteps } from "../api";
|
||||
import { uploadAttachment, fetchModels, fetchSettings, fetchWorkflowSteps, refineText, getRefineErrorMessage, type RefinementType } from "../api";
|
||||
import type { ModelInfo } from "../api";
|
||||
import { filterModels } from "../utils/modelFilter";
|
||||
import { applyPresetToSelection, getRecommendedPresetForSize } from "../utils/modelPresets";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import { Sparkles } from "lucide-react";
|
||||
|
||||
const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||
|
||||
@@ -320,6 +321,11 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
||||
const [workflowSteps, setWorkflowSteps] = useState<WorkflowStep[]>([]);
|
||||
const [selectedWorkflowSteps, setSelectedWorkflowSteps] = useState<string[]>([]);
|
||||
|
||||
// AI Refinement state
|
||||
const [isRefineMenuOpen, setIsRefineMenuOpen] = useState(false);
|
||||
const [isRefining, setIsRefining] = useState(false);
|
||||
const refineMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const depDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const descTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -392,6 +398,18 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [showDepDropdown]);
|
||||
|
||||
// Close refine menu when clicking outside
|
||||
useEffect(() => {
|
||||
if (!isRefineMenuOpen) return;
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (refineMenuRef.current && !refineMenuRef.current.contains(e.target as Node)) {
|
||||
setIsRefineMenuOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [isRefineMenuOpen]);
|
||||
|
||||
// Handle paste for images
|
||||
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items;
|
||||
@@ -462,6 +480,8 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
||||
setPresetMode("default");
|
||||
setEnablePlanningMode(false);
|
||||
setSelectedWorkflowSteps([]);
|
||||
setIsRefineMenuOpen(false);
|
||||
setIsRefining(false);
|
||||
setHasDirtyState(false);
|
||||
onClose();
|
||||
}, [hasDirtyState, onClose, pendingImages]);
|
||||
@@ -568,6 +588,30 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
||||
el.style.height = el.scrollHeight + "px";
|
||||
}, []);
|
||||
|
||||
// AI Refinement handler
|
||||
const handleRefine = useCallback(async (type: RefinementType) => {
|
||||
const trimmed = description.trim();
|
||||
if (!trimmed || isRefining) return;
|
||||
|
||||
setIsRefining(true);
|
||||
try {
|
||||
const refined = await refineText(trimmed, type);
|
||||
setDescription(refined);
|
||||
setIsRefineMenuOpen(false);
|
||||
addToast("Description refined with AI", "success");
|
||||
// Auto-resize textarea after content update
|
||||
if (descTextareaRef.current) {
|
||||
descTextareaRef.current.style.height = "auto";
|
||||
descTextareaRef.current.style.height = descTextareaRef.current.scrollHeight + "px";
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMessage = getRefineErrorMessage(err);
|
||||
addToast(errorMessage, "error");
|
||||
} finally {
|
||||
setIsRefining(false);
|
||||
}
|
||||
}, [description, isRefining, addToast]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const availableDeps = tasks
|
||||
@@ -608,15 +652,69 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
||||
{/* Description field */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="new-task-description">Description</label>
|
||||
<textarea
|
||||
ref={descTextareaRef}
|
||||
id="new-task-description"
|
||||
value={description}
|
||||
onChange={handleDescriptionChange}
|
||||
placeholder="What needs to be done?"
|
||||
rows={3}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<div className="description-with-refine" ref={refineMenuRef}>
|
||||
<textarea
|
||||
ref={descTextareaRef}
|
||||
id="new-task-description"
|
||||
value={description}
|
||||
onChange={handleDescriptionChange}
|
||||
placeholder="What needs to be done?"
|
||||
rows={3}
|
||||
disabled={isSubmitting || isRefining}
|
||||
/>
|
||||
{description.trim() && !isSubmitting && (
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm refine-button ${isRefining ? "refine-button--loading" : ""}`}
|
||||
onClick={() => setIsRefineMenuOpen((prev) => !prev)}
|
||||
disabled={isRefining}
|
||||
data-testid="refine-button"
|
||||
title="Refine description with AI"
|
||||
>
|
||||
<Sparkles size={12} style={{ verticalAlign: "middle" }} />
|
||||
{isRefining ? "Refining..." : "Refine"}
|
||||
</button>
|
||||
)}
|
||||
{isRefineMenuOpen && (
|
||||
<div
|
||||
className="refine-menu refine-menu--modal"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<div
|
||||
className="refine-menu-item"
|
||||
onClick={() => handleRefine("clarify")}
|
||||
data-testid="refine-clarify"
|
||||
>
|
||||
<div className="refine-menu-item-title">Clarify</div>
|
||||
<div className="refine-menu-item-desc">Make the description clearer and more specific</div>
|
||||
</div>
|
||||
<div
|
||||
className="refine-menu-item"
|
||||
onClick={() => handleRefine("add-details")}
|
||||
data-testid="refine-add-details"
|
||||
>
|
||||
<div className="refine-menu-item-title">Add details</div>
|
||||
<div className="refine-menu-item-desc">Add implementation details and context</div>
|
||||
</div>
|
||||
<div
|
||||
className="refine-menu-item"
|
||||
onClick={() => handleRefine("expand")}
|
||||
data-testid="refine-expand"
|
||||
>
|
||||
<div className="refine-menu-item-title">Expand</div>
|
||||
<div className="refine-menu-item-desc">Expand into a more comprehensive description</div>
|
||||
</div>
|
||||
<div
|
||||
className="refine-menu-item"
|
||||
onClick={() => handleRefine("simplify")}
|
||||
data-testid="refine-simplify"
|
||||
>
|
||||
<div className="refine-menu-item-title">Simplify</div>
|
||||
<div className="refine-menu-item-desc">Simplify and make more concise</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dependencies */}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import type { Task, TaskCreateInput } from "@kb/core";
|
||||
import type { ModelInfo } from "../api";
|
||||
import { fetchModels } from "../api";
|
||||
import { Link, Brain, Lightbulb, ListTree } from "lucide-react";
|
||||
import type { ModelInfo, RefinementType } from "../api";
|
||||
import { fetchModels, refineText, getRefineErrorMessage } from "../api";
|
||||
import { Link, Brain, Lightbulb, ListTree, Sparkles } from "lucide-react";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
|
||||
const STORAGE_KEY = "kb-quick-entry-text";
|
||||
@@ -69,6 +69,11 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
const [modelsError, setModelsError] = useState<string | null>(null);
|
||||
const [loadedModels, setLoadedModels] = useState<ModelInfo[]>(availableModels ?? []);
|
||||
|
||||
// AI Refinement state
|
||||
const [isRefineMenuOpen, setIsRefineMenuOpen] = useState(false);
|
||||
const [isRefining, setIsRefining] = useState(false);
|
||||
const refineMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// If onCreate is not provided, the component is disabled
|
||||
const isDisabled = !onCreate;
|
||||
|
||||
@@ -173,6 +178,20 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
if (!showDeps) setDepSearch("");
|
||||
}, [showDeps]);
|
||||
|
||||
// Close refine menu when clicking outside
|
||||
useEffect(() => {
|
||||
if (!isRefineMenuOpen) return;
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (refineMenuRef.current && !refineMenuRef.current.contains(e.target as Node)) {
|
||||
setIsRefineMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [isRefineMenuOpen]);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setDescription("");
|
||||
setDependencies([]);
|
||||
@@ -182,6 +201,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
setValidatorModelId(undefined);
|
||||
setShowDeps(false);
|
||||
setShowModels(false);
|
||||
setIsRefineMenuOpen(false);
|
||||
setIsRefining(false);
|
||||
setIsExpanded(false);
|
||||
justResetRef.current = true;
|
||||
if (textareaRef.current) {
|
||||
@@ -246,9 +267,10 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
// Close dropdowns first if open
|
||||
if (showDeps || showModels) {
|
||||
if (showDeps || showModels || isRefineMenuOpen) {
|
||||
setShowDeps(false);
|
||||
setShowModels(false);
|
||||
setIsRefineMenuOpen(false);
|
||||
return;
|
||||
}
|
||||
// Clear non-empty input on Escape and clear localStorage
|
||||
@@ -273,7 +295,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
textareaRef.current?.blur();
|
||||
}
|
||||
},
|
||||
[handleSubmit, description, isExpanded, showDeps, showModels, resetForm],
|
||||
[handleSubmit, description, isExpanded, showDeps, showModels, isRefineMenuOpen, resetForm],
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
@@ -294,7 +316,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
// Collapse after a short delay to allow click events on dropdowns
|
||||
// Collapse regardless of content - only check if dropdowns are open
|
||||
blurTimeoutRef.current = setTimeout(() => {
|
||||
if (!showDeps && !showModels) {
|
||||
if (!showDeps && !showModels && !isRefineMenuOpen) {
|
||||
setIsExpanded(false);
|
||||
// Reset height when collapsing
|
||||
if (textareaRef.current) {
|
||||
@@ -303,7 +325,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
}
|
||||
blurTimeoutRef.current = null;
|
||||
}, 200);
|
||||
}, [showDeps, showModels]);
|
||||
}, [showDeps, showModels, isRefineMenuOpen]);
|
||||
|
||||
const toggleDep = useCallback((id: string) => {
|
||||
setDependencies((prev) =>
|
||||
@@ -372,6 +394,29 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
resetForm();
|
||||
}, [description, onSubtaskBreakdown, addToast, resetForm]);
|
||||
|
||||
const handleRefine = useCallback(async (type: RefinementType) => {
|
||||
const trimmed = description.trim();
|
||||
if (!trimmed || isRefining) return;
|
||||
|
||||
setIsRefining(true);
|
||||
try {
|
||||
const refined = await refineText(trimmed, type);
|
||||
setDescription(refined);
|
||||
setIsRefineMenuOpen(false);
|
||||
addToast("Description refined with AI", "success");
|
||||
// Auto-resize textarea after content update
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 200)}px`;
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMessage = getRefineErrorMessage(err);
|
||||
addToast(errorMessage, "error");
|
||||
} finally {
|
||||
setIsRefining(false);
|
||||
}
|
||||
}, [description, isRefining, addToast]);
|
||||
|
||||
const truncate = (s: string, len: number) =>
|
||||
s.length > len ? s.slice(0, len) + "…" : s;
|
||||
|
||||
@@ -574,6 +619,58 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
<ListTree size={12} style={{ verticalAlign: "middle" }} />
|
||||
Subtask
|
||||
</button>
|
||||
<div className="refine-trigger-wrap" ref={refineMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm refine-button ${isRefining ? "refine-button--loading" : ""}`}
|
||||
onClick={() => setIsRefineMenuOpen((prev) => !prev)}
|
||||
disabled={!description.trim() || isRefining}
|
||||
data-testid="refine-button"
|
||||
title="Refine description with AI"
|
||||
>
|
||||
<Sparkles size={12} style={{ verticalAlign: "middle" }} />
|
||||
{isRefining ? "Refining..." : "Refine"}
|
||||
</button>
|
||||
{isRefineMenuOpen && (
|
||||
<div
|
||||
className="refine-menu"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<div
|
||||
className="refine-menu-item"
|
||||
onClick={() => handleRefine("clarify")}
|
||||
data-testid="refine-clarify"
|
||||
>
|
||||
<div className="refine-menu-item-title">Clarify</div>
|
||||
<div className="refine-menu-item-desc">Make the description clearer and more specific</div>
|
||||
</div>
|
||||
<div
|
||||
className="refine-menu-item"
|
||||
onClick={() => handleRefine("add-details")}
|
||||
data-testid="refine-add-details"
|
||||
>
|
||||
<div className="refine-menu-item-title">Add details</div>
|
||||
<div className="refine-menu-item-desc">Add implementation details and context</div>
|
||||
</div>
|
||||
<div
|
||||
className="refine-menu-item"
|
||||
onClick={() => handleRefine("expand")}
|
||||
data-testid="refine-expand"
|
||||
>
|
||||
<div className="refine-menu-item-title">Expand</div>
|
||||
<div className="refine-menu-item-desc">Expand into a more comprehensive description</div>
|
||||
</div>
|
||||
<div
|
||||
className="refine-menu-item"
|
||||
onClick={() => handleRefine("simplify")}
|
||||
data-testid="refine-simplify"
|
||||
>
|
||||
<div className="refine-menu-item-title">Simplify</div>
|
||||
<div className="refine-menu-item-desc">Simplify and make more concise</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,8 @@ vi.mock("../../api", () => ({
|
||||
defaultPresetBySize: {},
|
||||
}),
|
||||
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
|
||||
refineText: vi.fn(),
|
||||
getRefineErrorMessage: vi.fn((err) => err?.message || "Failed to refine text. Please try again."),
|
||||
}));
|
||||
|
||||
function makeTask(id: string): Task {
|
||||
@@ -537,4 +539,133 @@ describe("NewTaskModal", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AI Refine feature", () => {
|
||||
it("shows refine button when description has content", async () => {
|
||||
renderNewTaskModal();
|
||||
const textarea = screen.getByLabelText(/Description/i);
|
||||
|
||||
// Initially, refine button is not visible
|
||||
expect(screen.queryByTestId("refine-button")).toBeNull();
|
||||
|
||||
// Type something
|
||||
fireEvent.change(textarea, { target: { value: "Task to refine" } });
|
||||
|
||||
// Now the refine button should be visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("refine-button")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("hides refine button when description is empty", async () => {
|
||||
renderNewTaskModal();
|
||||
const textarea = screen.getByLabelText(/Description/i);
|
||||
|
||||
// Type something first
|
||||
fireEvent.change(textarea, { target: { value: "Some text" } });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("refine-button")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Clear the input
|
||||
fireEvent.change(textarea, { target: { value: "" } });
|
||||
|
||||
// Button should be hidden
|
||||
expect(screen.queryByTestId("refine-button")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens refine menu on button click", async () => {
|
||||
renderNewTaskModal();
|
||||
const textarea = screen.getByLabelText(/Description/i);
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task to refine" } });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("refine-button")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("refine-button"));
|
||||
|
||||
// Menu should be visible with all options
|
||||
expect(screen.getByTestId("refine-clarify")).toBeTruthy();
|
||||
expect(screen.getByTestId("refine-add-details")).toBeTruthy();
|
||||
expect(screen.getByTestId("refine-expand")).toBeTruthy();
|
||||
expect(screen.getByTestId("refine-simplify")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("successful refinement updates description and shows toast", async () => {
|
||||
const { refineText } = await import("../../api");
|
||||
vi.mocked(refineText).mockResolvedValueOnce("Refined description");
|
||||
|
||||
const { props } = renderNewTaskModal();
|
||||
const textarea = screen.getByLabelText(/Description/i);
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Original text" } });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("refine-button")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("refine-button"));
|
||||
fireEvent.click(screen.getByTestId("refine-clarify"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(refineText).toHaveBeenCalledWith("Original text", "clarify");
|
||||
});
|
||||
|
||||
// Textarea should be updated
|
||||
await waitFor(() => {
|
||||
expect(textarea).toHaveValue("Refined description");
|
||||
});
|
||||
|
||||
// Success toast should be shown
|
||||
await waitFor(() => {
|
||||
expect(props.addToast).toHaveBeenCalledWith("Description refined with AI", "success");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error toast on refinement failure and preserves original text", async () => {
|
||||
const { refineText, getRefineErrorMessage } = await import("../../api");
|
||||
vi.mocked(refineText).mockRejectedValueOnce(new Error("Rate limit exceeded"));
|
||||
vi.mocked(getRefineErrorMessage).mockReturnValue("Too many refinement requests. Please wait an hour.");
|
||||
|
||||
const { props } = renderNewTaskModal();
|
||||
const textarea = screen.getByLabelText(/Description/i);
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Original text" } });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("refine-button")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("refine-button"));
|
||||
fireEvent.click(screen.getByTestId("refine-clarify"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.addToast).toHaveBeenCalledWith("Too many refinement requests. Please wait an hour.", "error");
|
||||
});
|
||||
|
||||
// Original text should be preserved
|
||||
expect(textarea).toHaveValue("Original text");
|
||||
});
|
||||
|
||||
it("shows loading state during refinement", async () => {
|
||||
const { refineText } = await import("../../api");
|
||||
// Slow down the promise to see loading state
|
||||
vi.mocked(refineText).mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve("Refined"), 100)));
|
||||
|
||||
renderNewTaskModal();
|
||||
const textarea = screen.getByLabelText(/Description/i);
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Original text" } });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("refine-button")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("refine-button"));
|
||||
fireEvent.click(screen.getByTestId("refine-expand"));
|
||||
|
||||
// Button should show loading text
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Refining...")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,6 +65,8 @@ vi.mock("../../api", () => ({
|
||||
contextWindow: 128_000,
|
||||
},
|
||||
]),
|
||||
refineText: vi.fn(),
|
||||
getRefineErrorMessage: vi.fn((err) => err?.message || "Failed to refine text. Please try again."),
|
||||
}));
|
||||
|
||||
// Mock lucide-react
|
||||
@@ -73,6 +75,7 @@ vi.mock("lucide-react", () => ({
|
||||
Brain: () => null,
|
||||
Lightbulb: () => null,
|
||||
ListTree: () => null,
|
||||
Sparkles: () => null,
|
||||
}));
|
||||
|
||||
function renderQuickEntryBox(props = {}) {
|
||||
@@ -713,4 +716,211 @@ describe("QuickEntryBox", () => {
|
||||
expect(localStorage.getItem("kb-quick-entry-text")).toBe("Task with dropdown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AI Refine feature", () => {
|
||||
it("shows refine button when text is entered", () => {
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
// Initially, refine button is not visible
|
||||
expect(screen.queryByTestId("refine-button")).toBeNull();
|
||||
|
||||
// Focus and type something
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Task to refine" } });
|
||||
|
||||
// Now the refine button should be visible
|
||||
expect(screen.getByTestId("refine-button")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("refine button is hidden when textarea is empty", () => {
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
// Focus and type something
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Some text" } });
|
||||
expect(screen.getByTestId("refine-button")).toBeTruthy();
|
||||
|
||||
// Clear the input
|
||||
fireEvent.change(textarea, { target: { value: "" } });
|
||||
|
||||
// Button should be hidden/disabled (might be hidden when controls collapse)
|
||||
const refineButton = screen.queryByTestId("refine-button");
|
||||
if (refineButton) {
|
||||
expect((refineButton as HTMLButtonElement).disabled).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("opens refine menu on button click", () => {
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Task to refine" } });
|
||||
fireEvent.click(screen.getByTestId("refine-button"));
|
||||
|
||||
// Menu should be visible with all options
|
||||
expect(screen.getByTestId("refine-clarify")).toBeTruthy();
|
||||
expect(screen.getByTestId("refine-add-details")).toBeTruthy();
|
||||
expect(screen.getByTestId("refine-expand")).toBeTruthy();
|
||||
expect(screen.getByTestId("refine-simplify")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("closes refine menu on Escape key", () => {
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Task to refine" } });
|
||||
fireEvent.click(screen.getByTestId("refine-button"));
|
||||
|
||||
// Menu should be open
|
||||
expect(screen.getByTestId("refine-clarify")).toBeTruthy();
|
||||
|
||||
// Press Escape
|
||||
fireEvent.keyDown(textarea, { key: "Escape" });
|
||||
|
||||
// Menu should be closed but input preserved
|
||||
expect(screen.queryByTestId("refine-clarify")).toBeNull();
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe("Task to refine");
|
||||
});
|
||||
|
||||
it("closes refine menu when option is selected", async () => {
|
||||
const { refineText } = await import("../../api");
|
||||
vi.mocked(refineText).mockResolvedValueOnce("Refined description");
|
||||
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Original text" } });
|
||||
fireEvent.click(screen.getByTestId("refine-button"));
|
||||
|
||||
// Menu should be open
|
||||
expect(screen.getByTestId("refine-clarify")).toBeTruthy();
|
||||
|
||||
// Click on an option
|
||||
fireEvent.click(screen.getByTestId("refine-clarify"));
|
||||
|
||||
// Menu should close
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("refine-clarify")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("successful refinement updates textarea content", async () => {
|
||||
const { refineText } = await import("../../api");
|
||||
vi.mocked(refineText).mockResolvedValueOnce("Refined description");
|
||||
|
||||
const { props } = renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Original text" } });
|
||||
fireEvent.click(screen.getByTestId("refine-button"));
|
||||
fireEvent.click(screen.getByTestId("refine-clarify"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(refineText).toHaveBeenCalledWith("Original text", "clarify");
|
||||
});
|
||||
|
||||
// Textarea should be updated
|
||||
await waitFor(() => {
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe("Refined description");
|
||||
});
|
||||
|
||||
// Success toast should be shown
|
||||
await waitFor(() => {
|
||||
expect(props.addToast).toHaveBeenCalledWith("Description refined with AI", "success");
|
||||
});
|
||||
});
|
||||
|
||||
it("failed refinement shows toast and preserves original text", async () => {
|
||||
const { refineText } = await import("../../api");
|
||||
vi.mocked(refineText).mockRejectedValueOnce(new Error("Rate limit exceeded"));
|
||||
|
||||
const { getRefineErrorMessage } = await import("../../api");
|
||||
|
||||
const { props } = renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Original text" } });
|
||||
fireEvent.click(screen.getByTestId("refine-button"));
|
||||
fireEvent.click(screen.getByTestId("refine-clarify"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.addToast).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Original text should be preserved
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe("Original text");
|
||||
});
|
||||
|
||||
it("loading state disables button during refinement", async () => {
|
||||
const { refineText } = await import("../../api");
|
||||
// Slow down the promise to see loading state
|
||||
vi.mocked(refineText).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
|
||||
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Original text" } });
|
||||
fireEvent.click(screen.getByTestId("refine-button"));
|
||||
fireEvent.click(screen.getByTestId("refine-clarify"));
|
||||
|
||||
// Button should show loading text
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Refining...")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Button should be disabled
|
||||
const refineButton = screen.getByTestId("refine-button");
|
||||
expect((refineButton as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("auto-resizes textarea after refinement", async () => {
|
||||
const { refineText } = await import("../../api");
|
||||
vi.mocked(refineText).mockResolvedValueOnce("Refined description with much more content here");
|
||||
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Short" } });
|
||||
fireEvent.click(screen.getByTestId("refine-button"));
|
||||
fireEvent.click(screen.getByTestId("refine-expand"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(refineText).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("resets refine state when form is reset after creation", async () => {
|
||||
const { refineText } = await import("../../api");
|
||||
vi.mocked(refineText).mockResolvedValueOnce("Refined text");
|
||||
|
||||
const { props } = renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
// Open refine menu but don't select anything
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Task" } });
|
||||
fireEvent.click(screen.getByTestId("refine-button"));
|
||||
|
||||
expect(screen.getByTestId("refine-clarify")).toBeTruthy();
|
||||
|
||||
// Submit the form
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// After reset, refine menu should be closed
|
||||
expect(screen.queryByTestId("refine-clarify")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2355,6 +2355,86 @@ body {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* === AI Refine Menu === */
|
||||
.refine-trigger-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.refine-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: 4px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
min-width: 200px;
|
||||
max-width: 280px;
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.refine-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.refine-button--loading {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.refine-menu-item {
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s ease;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.refine-menu-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.refine-menu-item:hover {
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.refine-menu-item-title {
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.refine-menu-item-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* NewTaskModal description with refine button */
|
||||
.description-with-refine {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.description-with-refine .refine-button {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
.description-with-refine textarea {
|
||||
padding-right: 70px; /* Make room for the refine button */
|
||||
}
|
||||
|
||||
.refine-menu--modal {
|
||||
right: 0;
|
||||
left: auto;
|
||||
top: calc(100% + 4px);
|
||||
}
|
||||
|
||||
.inline-create-model-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
|
||||
285
packages/dashboard/src/ai-refine.test.ts
Normal file
285
packages/dashboard/src/ai-refine.test.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import {
|
||||
refineText,
|
||||
validateRefineRequest,
|
||||
checkRateLimit,
|
||||
getRateLimitResetTime,
|
||||
__resetRefineState,
|
||||
ValidationError,
|
||||
InvalidTypeError,
|
||||
AiServiceError,
|
||||
VALID_REFINEMENT_TYPES,
|
||||
MIN_TEXT_LENGTH,
|
||||
MAX_TEXT_LENGTH,
|
||||
MAX_REQUESTS_PER_HOUR,
|
||||
RATE_LIMIT_WINDOW_MS,
|
||||
} from "./ai-refine.js";
|
||||
|
||||
describe("ai-refine module", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
__resetRefineState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("VALID_REFINEMENT_TYPES", () => {
|
||||
it("contains all four refinement types", () => {
|
||||
expect(VALID_REFINEMENT_TYPES).toEqual([
|
||||
"clarify",
|
||||
"add-details",
|
||||
"expand",
|
||||
"simplify",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateRefineRequest", () => {
|
||||
it("accepts valid text and 'clarify' type", () => {
|
||||
const result = validateRefineRequest("Some text", "clarify");
|
||||
expect(result).toEqual({ text: "Some text", type: "clarify" });
|
||||
});
|
||||
|
||||
it("accepts valid text and 'add-details' type", () => {
|
||||
const result = validateRefineRequest("Some text", "add-details");
|
||||
expect(result).toEqual({ text: "Some text", type: "add-details" });
|
||||
});
|
||||
|
||||
it("accepts valid text and 'expand' type", () => {
|
||||
const result = validateRefineRequest("Some text", "expand");
|
||||
expect(result).toEqual({ text: "Some text", type: "expand" });
|
||||
});
|
||||
|
||||
it("accepts valid text and 'simplify' type", () => {
|
||||
const result = validateRefineRequest("Some text", "simplify");
|
||||
expect(result).toEqual({ text: "Some text", type: "simplify" });
|
||||
});
|
||||
|
||||
it("throws ValidationError for missing text", () => {
|
||||
expect(() => validateRefineRequest(undefined, "clarify")).toThrow(ValidationError);
|
||||
expect(() => validateRefineRequest(undefined, "clarify")).toThrow("text is required");
|
||||
});
|
||||
|
||||
it("throws ValidationError for null text", () => {
|
||||
expect(() => validateRefineRequest(null, "clarify")).toThrow(ValidationError);
|
||||
expect(() => validateRefineRequest(null, "clarify")).toThrow("text is required");
|
||||
});
|
||||
|
||||
it("throws ValidationError for non-string text", () => {
|
||||
expect(() => validateRefineRequest(123, "clarify")).toThrow(ValidationError);
|
||||
expect(() => validateRefineRequest(123, "clarify")).toThrow("text must be a string");
|
||||
});
|
||||
|
||||
it("throws ValidationError for empty text", () => {
|
||||
expect(() => validateRefineRequest("", "clarify")).toThrow(ValidationError);
|
||||
expect(() => validateRefineRequest("", "clarify")).toThrow(
|
||||
`text must be at least ${MIN_TEXT_LENGTH} character`
|
||||
);
|
||||
});
|
||||
|
||||
it("throws ValidationError for text exceeding MAX_TEXT_LENGTH", () => {
|
||||
const longText = "a".repeat(MAX_TEXT_LENGTH + 1);
|
||||
expect(() => validateRefineRequest(longText, "clarify")).toThrow(ValidationError);
|
||||
expect(() => validateRefineRequest(longText, "clarify")).toThrow(
|
||||
`text must not exceed ${MAX_TEXT_LENGTH} characters`
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts text at exactly MAX_TEXT_LENGTH", () => {
|
||||
const maxText = "a".repeat(MAX_TEXT_LENGTH);
|
||||
const result = validateRefineRequest(maxText, "clarify");
|
||||
expect(result.text).toHaveLength(MAX_TEXT_LENGTH);
|
||||
});
|
||||
|
||||
it("accepts text at exactly MIN_TEXT_LENGTH", () => {
|
||||
const result = validateRefineRequest("a", "clarify");
|
||||
expect(result.text).toBe("a");
|
||||
});
|
||||
|
||||
it("throws ValidationError for missing type", () => {
|
||||
expect(() => validateRefineRequest("some text", undefined)).toThrow(ValidationError);
|
||||
expect(() => validateRefineRequest("some text", undefined)).toThrow("type is required");
|
||||
});
|
||||
|
||||
it("throws ValidationError for null type", () => {
|
||||
expect(() => validateRefineRequest("some text", null)).toThrow(ValidationError);
|
||||
expect(() => validateRefineRequest("some text", null)).toThrow("type is required");
|
||||
});
|
||||
|
||||
it("throws InvalidTypeError for invalid type string", () => {
|
||||
expect(() => validateRefineRequest("some text", "invalid")).toThrow(InvalidTypeError);
|
||||
expect(() => validateRefineRequest("some text", "invalid")).toThrow(
|
||||
"type must be one of: clarify, add-details, expand, simplify"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws InvalidTypeError for numeric type", () => {
|
||||
expect(() => validateRefineRequest("some text", 123)).toThrow(InvalidTypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkRateLimit", () => {
|
||||
it("allows first request from an IP", () => {
|
||||
expect(checkRateLimit("192.168.1.1")).toBe(true);
|
||||
});
|
||||
|
||||
it("allows up to MAX_REQUESTS_PER_HOUR requests", () => {
|
||||
const ip = "192.168.1.1";
|
||||
for (let i = 0; i < MAX_REQUESTS_PER_HOUR; i++) {
|
||||
expect(checkRateLimit(ip)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks request beyond MAX_REQUESTS_PER_HOUR", () => {
|
||||
const ip = "192.168.1.1";
|
||||
// Use up the quota
|
||||
for (let i = 0; i < MAX_REQUESTS_PER_HOUR; i++) {
|
||||
checkRateLimit(ip);
|
||||
}
|
||||
// 11th request should be blocked
|
||||
expect(checkRateLimit(ip)).toBe(false);
|
||||
});
|
||||
|
||||
it("tracks different IPs independently", () => {
|
||||
const ip1 = "192.168.1.1";
|
||||
const ip2 = "192.168.1.2";
|
||||
|
||||
// Use up quota for ip1
|
||||
for (let i = 0; i < MAX_REQUESTS_PER_HOUR; i++) {
|
||||
checkRateLimit(ip1);
|
||||
}
|
||||
expect(checkRateLimit(ip1)).toBe(false);
|
||||
|
||||
// ip2 should still have full quota
|
||||
expect(checkRateLimit(ip2)).toBe(true);
|
||||
});
|
||||
|
||||
it("resets rate limit after RATE_LIMIT_WINDOW_MS", () => {
|
||||
const ip = "192.168.1.1";
|
||||
|
||||
// Use up the quota
|
||||
for (let i = 0; i < MAX_REQUESTS_PER_HOUR; i++) {
|
||||
checkRateLimit(ip);
|
||||
}
|
||||
expect(checkRateLimit(ip)).toBe(false);
|
||||
|
||||
// Advance time by 1 hour + 1ms
|
||||
vi.advanceTimersByTime(RATE_LIMIT_WINDOW_MS + 1);
|
||||
|
||||
// Should be allowed again
|
||||
expect(checkRateLimit(ip)).toBe(true);
|
||||
});
|
||||
|
||||
it("resets count but tracks new window after expiry", () => {
|
||||
const ip = "192.168.1.1";
|
||||
|
||||
// Make 5 requests
|
||||
for (let i = 0; i < 5; i++) {
|
||||
checkRateLimit(ip);
|
||||
}
|
||||
|
||||
// Advance time by 1 hour + 1ms
|
||||
vi.advanceTimersByTime(RATE_LIMIT_WINDOW_MS + 1);
|
||||
|
||||
// First request in new window should work
|
||||
expect(checkRateLimit(ip)).toBe(true);
|
||||
|
||||
// Use up remaining quota in new window
|
||||
for (let i = 0; i < MAX_REQUESTS_PER_HOUR - 1; i++) {
|
||||
expect(checkRateLimit(ip)).toBe(true);
|
||||
}
|
||||
|
||||
// Next request should be blocked
|
||||
expect(checkRateLimit(ip)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRateLimitResetTime", () => {
|
||||
it("returns null for unknown IP", () => {
|
||||
expect(getRateLimitResetTime("unknown-ip")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns reset time after a request is made", () => {
|
||||
const ip = "192.168.1.1";
|
||||
const beforeRequest = Date.now();
|
||||
|
||||
checkRateLimit(ip);
|
||||
|
||||
const resetTime = getRateLimitResetTime(ip);
|
||||
expect(resetTime).not.toBeNull();
|
||||
expect(resetTime!.getTime()).toBe(beforeRequest + RATE_LIMIT_WINDOW_MS);
|
||||
});
|
||||
|
||||
it("returns updated reset time after window resets", () => {
|
||||
const ip = "192.168.1.1";
|
||||
|
||||
checkRateLimit(ip);
|
||||
const firstResetTime = getRateLimitResetTime(ip);
|
||||
|
||||
// Advance time past the window
|
||||
vi.advanceTimersByTime(RATE_LIMIT_WINDOW_MS + 1000);
|
||||
|
||||
// Make another request
|
||||
checkRateLimit(ip);
|
||||
const secondResetTime = getRateLimitResetTime(ip);
|
||||
|
||||
// Second reset time should be later than first
|
||||
expect(secondResetTime!.getTime()).toBeGreaterThan(firstResetTime!.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe("error classes", () => {
|
||||
it("ValidationError has correct name", () => {
|
||||
const error = new ValidationError("test");
|
||||
expect(error.name).toBe("ValidationError");
|
||||
expect(error.message).toBe("test");
|
||||
});
|
||||
|
||||
it("InvalidTypeError has correct name", () => {
|
||||
const error = new InvalidTypeError("test");
|
||||
expect(error.name).toBe("InvalidTypeError");
|
||||
expect(error.message).toBe("test");
|
||||
});
|
||||
|
||||
it("AiServiceError has correct name", () => {
|
||||
const error = new AiServiceError("test");
|
||||
expect(error.name).toBe("AiServiceError");
|
||||
expect(error.message).toBe("test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("refineText", () => {
|
||||
// Note: refineText requires the AI engine which is not available in tests.
|
||||
// These tests verify error handling when the engine is unavailable.
|
||||
|
||||
it("throws AiServiceError when AI engine is not available", async () => {
|
||||
await expect(refineText("some text", "clarify", "/some/path")).rejects.toThrow(
|
||||
AiServiceError
|
||||
);
|
||||
await expect(refineText("some text", "clarify", "/some/path")).rejects.toThrow(
|
||||
"AI engine not available"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("__resetRefineState", () => {
|
||||
it("clears all rate limit entries", () => {
|
||||
const ip = "192.168.1.1";
|
||||
|
||||
// Make requests to populate rate limits
|
||||
for (let i = 0; i < 5; i++) {
|
||||
checkRateLimit(ip);
|
||||
}
|
||||
expect(getRateLimitResetTime(ip)).not.toBeNull();
|
||||
|
||||
// Reset state
|
||||
__resetRefineState();
|
||||
|
||||
// Should be like starting fresh
|
||||
expect(getRateLimitResetTime(ip)).toBeNull();
|
||||
expect(checkRateLimit(ip)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
379
packages/dashboard/src/ai-refine.ts
Normal file
379
packages/dashboard/src/ai-refine.ts
Normal file
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* AI Text Refinement Service
|
||||
*
|
||||
* Provides AI-powered text refinement for task descriptions.
|
||||
* Supports multiple refinement types: clarify, add-details, expand, simplify.
|
||||
*
|
||||
* Features:
|
||||
* - Rate limiting per IP (10 requests per hour)
|
||||
* - Dynamic import of @kb/engine for AI agent creation
|
||||
* - Text length validation (1-2000 characters)
|
||||
*/
|
||||
|
||||
// Dynamic import for @kb/engine to avoid resolution issues in test environment
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports, @typescript-eslint/no-explicit-any
|
||||
type AgentResult = any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let createKbAgent: any;
|
||||
|
||||
// Initialize the import (this runs in actual server, mocked in tests)
|
||||
async function initEngine() {
|
||||
if (!createKbAgent) {
|
||||
try {
|
||||
// Use dynamic import with variable to prevent static analysis
|
||||
const engineModule = "@kb/engine";
|
||||
const engine = await import(/* @vite-ignore */ engineModule);
|
||||
createKbAgent = engine.createKbAgent;
|
||||
} catch {
|
||||
// Allow failure in test environments - agent functionality will be stubbed
|
||||
createKbAgent = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on module load (will be awaited in actual usage)
|
||||
const engineReady = initEngine();
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Available refinement types */
|
||||
export type RefinementType = "clarify" | "add-details" | "expand" | "simplify";
|
||||
|
||||
/** Valid refinement types for validation */
|
||||
export const VALID_REFINEMENT_TYPES: RefinementType[] = [
|
||||
"clarify",
|
||||
"add-details",
|
||||
"expand",
|
||||
"simplify",
|
||||
];
|
||||
|
||||
/** Request body for text refinement */
|
||||
export interface RefineTextRequest {
|
||||
text: string;
|
||||
type: RefinementType;
|
||||
}
|
||||
|
||||
/** Response body for text refinement */
|
||||
export interface RefineTextResponse {
|
||||
refined: string;
|
||||
}
|
||||
|
||||
// ── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** System prompt for text refinement */
|
||||
export const REFINE_SYSTEM_PROMPT = `You are a text refinement assistant for a task management system.
|
||||
|
||||
Your job is to refine task descriptions based on the user's selected refinement type.
|
||||
|
||||
## Refinement Types
|
||||
|
||||
1. **clarify**: Make the description clearer and more specific
|
||||
- Remove ambiguity
|
||||
- Add specific details where vague
|
||||
- Ensure the goal is well-defined
|
||||
- Keep approximately the same length
|
||||
|
||||
2. **add-details**: Add implementation details and context
|
||||
- Add technical considerations
|
||||
- Include edge cases to consider
|
||||
- Mention related files/components if apparent
|
||||
- Expand moderately (1.5-2x length)
|
||||
|
||||
3. **expand**: Expand into a more comprehensive description
|
||||
- Add background context
|
||||
- Include acceptance criteria
|
||||
- List specific sub-tasks or steps
|
||||
- Significantly expand (2-3x length)
|
||||
|
||||
4. **simplify**: Simplify and make more concise
|
||||
- Remove redundant words
|
||||
- Use concise language
|
||||
- Keep core meaning intact
|
||||
- Reduce length significantly (0.5-0.7x)
|
||||
|
||||
## Guidelines
|
||||
- Maintain the original intent and meaning
|
||||
- Keep the tone professional and actionable
|
||||
- Output ONLY the refined text, no markdown formatting, no explanations
|
||||
- The output should be a direct replacement for the input text`;
|
||||
|
||||
/** Maximum text length in characters */
|
||||
export const MAX_TEXT_LENGTH = 2000;
|
||||
|
||||
/** Minimum text length in characters */
|
||||
export const MIN_TEXT_LENGTH = 1;
|
||||
|
||||
/** Rate limit: max requests per IP per hour */
|
||||
export const MAX_REQUESTS_PER_HOUR = 10;
|
||||
|
||||
/** Rate limit window in milliseconds (1 hour) */
|
||||
export const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
/** Cleanup interval in milliseconds (5 minutes) */
|
||||
export const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
// ── Rate Limiting ─────────────────────────────────────────────────────────
|
||||
|
||||
interface RateLimitEntry {
|
||||
count: number;
|
||||
firstRequestAt: Date;
|
||||
}
|
||||
|
||||
/** Rate limiting state indexed by IP */
|
||||
const rateLimits = new Map<string, RateLimitEntry>();
|
||||
|
||||
/**
|
||||
* Check if IP can make a refinement request.
|
||||
* Returns true if allowed, false if rate limited.
|
||||
*/
|
||||
export function checkRateLimit(ip: string): boolean {
|
||||
const now = Date.now();
|
||||
const entry = rateLimits.get(ip);
|
||||
|
||||
if (!entry) {
|
||||
// First request from this IP
|
||||
rateLimits.set(ip, {
|
||||
count: 1,
|
||||
firstRequestAt: new Date(),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if window has expired
|
||||
if (now - entry.firstRequestAt.getTime() > RATE_LIMIT_WINDOW_MS) {
|
||||
// Reset window
|
||||
rateLimits.set(ip, {
|
||||
count: 1,
|
||||
firstRequestAt: new Date(),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// Within window - check limit
|
||||
if (entry.count >= MAX_REQUESTS_PER_HOUR) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Increment count
|
||||
entry.count++;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rate limit reset time for an IP.
|
||||
* Returns null if no rate limit entry exists.
|
||||
*/
|
||||
export function getRateLimitResetTime(ip: string): Date | null {
|
||||
const entry = rateLimits.get(ip);
|
||||
if (!entry) return null;
|
||||
|
||||
return new Date(entry.firstRequestAt.getTime() + RATE_LIMIT_WINDOW_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove expired rate limit entries.
|
||||
* Runs periodically via setInterval.
|
||||
*/
|
||||
function cleanupExpiredRateLimits(): void {
|
||||
const now = Date.now();
|
||||
let cleanedRateLimits = 0;
|
||||
|
||||
for (const [ip, entry] of rateLimits) {
|
||||
if (now - entry.firstRequestAt.getTime() > RATE_LIMIT_WINDOW_MS) {
|
||||
rateLimits.delete(ip);
|
||||
cleanedRateLimits++;
|
||||
}
|
||||
}
|
||||
|
||||
if (cleanedRateLimits > 0) {
|
||||
console.log(`[ai-refine] Cleanup: removed ${cleanedRateLimits} rate limit entries`);
|
||||
}
|
||||
}
|
||||
|
||||
// Start cleanup interval
|
||||
const cleanupInterval = setInterval(cleanupExpiredRateLimits, CLEANUP_INTERVAL_MS);
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on("beforeExit", () => {
|
||||
clearInterval(cleanupInterval);
|
||||
});
|
||||
|
||||
// ── Validation ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate refinement request.
|
||||
* Throws appropriate errors for invalid input.
|
||||
*/
|
||||
export function validateRefineRequest(
|
||||
text: unknown,
|
||||
type: unknown
|
||||
): { text: string; type: RefinementType } {
|
||||
// Validate text exists
|
||||
if (text === undefined || text === null) {
|
||||
throw new ValidationError("text is required");
|
||||
}
|
||||
|
||||
// Validate text is a string
|
||||
if (typeof text !== "string") {
|
||||
throw new ValidationError("text must be a string");
|
||||
}
|
||||
|
||||
// Validate text length
|
||||
if (text.length < MIN_TEXT_LENGTH) {
|
||||
throw new ValidationError(
|
||||
`text must be at least ${MIN_TEXT_LENGTH} character${MIN_TEXT_LENGTH === 1 ? "" : "s"}`
|
||||
);
|
||||
}
|
||||
if (text.length > MAX_TEXT_LENGTH) {
|
||||
throw new ValidationError(
|
||||
`text must not exceed ${MAX_TEXT_LENGTH} characters`
|
||||
);
|
||||
}
|
||||
|
||||
// Validate type exists
|
||||
if (type === undefined || type === null) {
|
||||
throw new ValidationError("type is required");
|
||||
}
|
||||
|
||||
// Validate type is a valid refinement type
|
||||
if (!VALID_REFINEMENT_TYPES.includes(type as RefinementType)) {
|
||||
throw new InvalidTypeError(
|
||||
`type must be one of: ${VALID_REFINEMENT_TYPES.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
return { text, type: type as RefinementType };
|
||||
}
|
||||
|
||||
// ── AI Integration ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Refine text using AI agent.
|
||||
* @param text - The text to refine
|
||||
* @param type - The type of refinement to apply
|
||||
* @param rootDir - Project root directory for AI agent context
|
||||
* @returns The refined text
|
||||
*/
|
||||
export async function refineText(
|
||||
text: string,
|
||||
type: RefinementType,
|
||||
rootDir: string
|
||||
): Promise<string> {
|
||||
// Ensure engine is loaded before using createKbAgent
|
||||
await engineReady;
|
||||
|
||||
if (!createKbAgent) {
|
||||
throw new AiServiceError("AI engine not available");
|
||||
}
|
||||
|
||||
const agentResult = await createKbAgent({
|
||||
cwd: rootDir,
|
||||
systemPrompt: REFINE_SYSTEM_PROMPT,
|
||||
tools: "readonly",
|
||||
});
|
||||
|
||||
if (!agentResult?.session) {
|
||||
throw new AiServiceError("Failed to initialize AI agent");
|
||||
}
|
||||
|
||||
// Build the prompt with type instruction
|
||||
const prompt = `Refinement type: ${type}\n\nText to refine:\n${text}`;
|
||||
|
||||
try {
|
||||
// Send message to agent and get response
|
||||
await agentResult.session.prompt(prompt);
|
||||
|
||||
// Get the response text from the agent's state
|
||||
interface AgentMessage {
|
||||
role: string;
|
||||
content?: string | Array<{ type: string; text: string }>;
|
||||
}
|
||||
const lastMessage = (agentResult.session.state.messages as AgentMessage[])
|
||||
.filter((m: AgentMessage) => m.role === "assistant")
|
||||
.pop();
|
||||
|
||||
let refinedText = "";
|
||||
if (lastMessage?.content) {
|
||||
// Handle both string and array content types
|
||||
if (typeof lastMessage.content === "string") {
|
||||
refinedText = lastMessage.content.trim();
|
||||
} else if (Array.isArray(lastMessage.content)) {
|
||||
// Extract text from content blocks
|
||||
refinedText = lastMessage.content
|
||||
.filter((c: { type: string; text: string }): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c: { type: string; text: string }) => c.text)
|
||||
.join("")
|
||||
.trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (!refinedText) {
|
||||
throw new AiServiceError("AI returned empty response");
|
||||
}
|
||||
|
||||
// Dispose the agent session
|
||||
try {
|
||||
agentResult.session.dispose?.();
|
||||
} catch {
|
||||
// Ignore disposal errors
|
||||
}
|
||||
|
||||
return refinedText;
|
||||
} catch (err) {
|
||||
// Ensure session is disposed even on error
|
||||
try {
|
||||
agentResult.session.dispose?.();
|
||||
} catch {
|
||||
// Ignore disposal errors
|
||||
}
|
||||
|
||||
if (err instanceof AiServiceError) {
|
||||
throw err;
|
||||
}
|
||||
throw new AiServiceError(
|
||||
err instanceof Error ? err.message : "AI processing failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Custom Errors ───────────────────────────────────────────────────────────
|
||||
|
||||
export class ValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidTypeError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "InvalidTypeError";
|
||||
}
|
||||
}
|
||||
|
||||
export class RateLimitError extends Error {
|
||||
resetTime: Date | null;
|
||||
|
||||
constructor(message: string, resetTime: Date | null = null) {
|
||||
super(message);
|
||||
this.name = "RateLimitError";
|
||||
this.resetTime = resetTime;
|
||||
}
|
||||
}
|
||||
|
||||
export class AiServiceError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "AiServiceError";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Reset all refinement state. Used for testing only.
|
||||
*/
|
||||
export function __resetRefineState(): void {
|
||||
rateLimits.clear();
|
||||
}
|
||||
@@ -3800,6 +3800,72 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/ai/refine-text
|
||||
* AI-powered text refinement for task descriptions.
|
||||
* Body: { text: string, type: string }
|
||||
* Returns: { refined: string }
|
||||
*
|
||||
* Refinement types: clarify, add-details, expand, simplify
|
||||
* Rate limited: 10 requests per hour per IP
|
||||
*/
|
||||
router.post("/ai/refine-text", async (req, res) => {
|
||||
try {
|
||||
const { text, type } = req.body;
|
||||
const ip = req.ip || req.socket.remoteAddress || "unknown";
|
||||
const rootDir = store.getRootDir();
|
||||
|
||||
const {
|
||||
validateRefineRequest,
|
||||
checkRateLimit,
|
||||
getRateLimitResetTime,
|
||||
refineText,
|
||||
RateLimitError,
|
||||
ValidationError,
|
||||
InvalidTypeError,
|
||||
AiServiceError,
|
||||
} = await import("./ai-refine.js");
|
||||
|
||||
// Check rate limit first
|
||||
if (!checkRateLimit(ip)) {
|
||||
const resetTime = getRateLimitResetTime(ip);
|
||||
res.status(429).json({
|
||||
error: `Rate limit exceeded. Maximum 10 refinement requests per hour. Reset at ${resetTime?.toISOString() || "unknown"}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate request body
|
||||
let validated;
|
||||
try {
|
||||
validated = validateRefineRequest(text, type);
|
||||
} catch (err) {
|
||||
if (err instanceof ValidationError) {
|
||||
res.status(400).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
if (err instanceof InvalidTypeError) {
|
||||
res.status(422).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Process refinement
|
||||
const refined = await refineText(validated.text, validated.type, rootDir);
|
||||
res.json({ refined });
|
||||
} catch (err: any) {
|
||||
// Check error by name since error classes are from dynamic import
|
||||
if (err?.name === "RateLimitError") {
|
||||
res.status(429).json({ error: err.message });
|
||||
} else if (err?.name === "AiServiceError") {
|
||||
res.status(500).json({ error: err.message || "AI service error" });
|
||||
} else {
|
||||
res.status(500).json({ error: err?.message || "Failed to refine text" });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/usage
|
||||
* Fetch AI provider subscription usage (Claude, Codex, Gemini).
|
||||
|
||||
Reference in New Issue
Block a user