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:
5
.changeset/add-ai-refine-option.md
Normal file
5
.changeset/add-ai-refine-option.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@dustinbyrne/kb": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add AI text refinement to quick task entry and new task dialog. Users can now refine task descriptions with options to clarify, add details, expand, or simplify the text before creating tasks.
|
||||||
@@ -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",
|
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 { useState, useCallback, useEffect, useRef, useMemo } from "react";
|
||||||
import type { Task, TaskCreateInput, ModelPreset, Settings, WorkflowStep } from "@kb/core";
|
import type { Task, TaskCreateInput, ModelPreset, Settings, WorkflowStep } from "@kb/core";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
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 type { ModelInfo } from "../api";
|
||||||
import { filterModels } from "../utils/modelFilter";
|
import { filterModels } from "../utils/modelFilter";
|
||||||
import { applyPresetToSelection, getRecommendedPresetForSize } from "../utils/modelPresets";
|
import { applyPresetToSelection, getRecommendedPresetForSize } from "../utils/modelPresets";
|
||||||
import { ProviderIcon } from "./ProviderIcon";
|
import { ProviderIcon } from "./ProviderIcon";
|
||||||
|
import { Sparkles } from "lucide-react";
|
||||||
|
|
||||||
const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
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 [workflowSteps, setWorkflowSteps] = useState<WorkflowStep[]>([]);
|
||||||
const [selectedWorkflowSteps, setSelectedWorkflowSteps] = useState<string[]>([]);
|
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 depDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const descTextareaRef = useRef<HTMLTextAreaElement>(null);
|
const descTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -392,6 +398,18 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
|||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
}, [showDepDropdown]);
|
}, [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
|
// Handle paste for images
|
||||||
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
||||||
const items = e.clipboardData?.items;
|
const items = e.clipboardData?.items;
|
||||||
@@ -462,6 +480,8 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
|||||||
setPresetMode("default");
|
setPresetMode("default");
|
||||||
setEnablePlanningMode(false);
|
setEnablePlanningMode(false);
|
||||||
setSelectedWorkflowSteps([]);
|
setSelectedWorkflowSteps([]);
|
||||||
|
setIsRefineMenuOpen(false);
|
||||||
|
setIsRefining(false);
|
||||||
setHasDirtyState(false);
|
setHasDirtyState(false);
|
||||||
onClose();
|
onClose();
|
||||||
}, [hasDirtyState, onClose, pendingImages]);
|
}, [hasDirtyState, onClose, pendingImages]);
|
||||||
@@ -568,6 +588,30 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
|||||||
el.style.height = el.scrollHeight + "px";
|
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;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const availableDeps = tasks
|
const availableDeps = tasks
|
||||||
@@ -608,15 +652,69 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
|||||||
{/* Description field */}
|
{/* Description field */}
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="new-task-description">Description</label>
|
<label htmlFor="new-task-description">Description</label>
|
||||||
<textarea
|
<div className="description-with-refine" ref={refineMenuRef}>
|
||||||
ref={descTextareaRef}
|
<textarea
|
||||||
id="new-task-description"
|
ref={descTextareaRef}
|
||||||
value={description}
|
id="new-task-description"
|
||||||
onChange={handleDescriptionChange}
|
value={description}
|
||||||
placeholder="What needs to be done?"
|
onChange={handleDescriptionChange}
|
||||||
rows={3}
|
placeholder="What needs to be done?"
|
||||||
disabled={isSubmitting}
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Dependencies */}
|
{/* Dependencies */}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useState, useCallback, useRef, useEffect } from "react";
|
import { useState, useCallback, useRef, useEffect } from "react";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
import type { Task, TaskCreateInput } from "@kb/core";
|
import type { Task, TaskCreateInput } from "@kb/core";
|
||||||
import type { ModelInfo } from "../api";
|
import type { ModelInfo, RefinementType } from "../api";
|
||||||
import { fetchModels } from "../api";
|
import { fetchModels, refineText, getRefineErrorMessage } from "../api";
|
||||||
import { Link, Brain, Lightbulb, ListTree } from "lucide-react";
|
import { Link, Brain, Lightbulb, ListTree, Sparkles } from "lucide-react";
|
||||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||||
|
|
||||||
const STORAGE_KEY = "kb-quick-entry-text";
|
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 [modelsError, setModelsError] = useState<string | null>(null);
|
||||||
const [loadedModels, setLoadedModels] = useState<ModelInfo[]>(availableModels ?? []);
|
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
|
// If onCreate is not provided, the component is disabled
|
||||||
const isDisabled = !onCreate;
|
const isDisabled = !onCreate;
|
||||||
|
|
||||||
@@ -173,6 +178,20 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
if (!showDeps) setDepSearch("");
|
if (!showDeps) setDepSearch("");
|
||||||
}, [showDeps]);
|
}, [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(() => {
|
const resetForm = useCallback(() => {
|
||||||
setDescription("");
|
setDescription("");
|
||||||
setDependencies([]);
|
setDependencies([]);
|
||||||
@@ -182,6 +201,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
setValidatorModelId(undefined);
|
setValidatorModelId(undefined);
|
||||||
setShowDeps(false);
|
setShowDeps(false);
|
||||||
setShowModels(false);
|
setShowModels(false);
|
||||||
|
setIsRefineMenuOpen(false);
|
||||||
|
setIsRefining(false);
|
||||||
setIsExpanded(false);
|
setIsExpanded(false);
|
||||||
justResetRef.current = true;
|
justResetRef.current = true;
|
||||||
if (textareaRef.current) {
|
if (textareaRef.current) {
|
||||||
@@ -246,9 +267,10 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
} else if (e.key === "Escape") {
|
} else if (e.key === "Escape") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// Close dropdowns first if open
|
// Close dropdowns first if open
|
||||||
if (showDeps || showModels) {
|
if (showDeps || showModels || isRefineMenuOpen) {
|
||||||
setShowDeps(false);
|
setShowDeps(false);
|
||||||
setShowModels(false);
|
setShowModels(false);
|
||||||
|
setIsRefineMenuOpen(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Clear non-empty input on Escape and clear localStorage
|
// Clear non-empty input on Escape and clear localStorage
|
||||||
@@ -273,7 +295,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
textareaRef.current?.blur();
|
textareaRef.current?.blur();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[handleSubmit, description, isExpanded, showDeps, showModels, resetForm],
|
[handleSubmit, description, isExpanded, showDeps, showModels, isRefineMenuOpen, resetForm],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFocus = useCallback(() => {
|
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 after a short delay to allow click events on dropdowns
|
||||||
// Collapse regardless of content - only check if dropdowns are open
|
// Collapse regardless of content - only check if dropdowns are open
|
||||||
blurTimeoutRef.current = setTimeout(() => {
|
blurTimeoutRef.current = setTimeout(() => {
|
||||||
if (!showDeps && !showModels) {
|
if (!showDeps && !showModels && !isRefineMenuOpen) {
|
||||||
setIsExpanded(false);
|
setIsExpanded(false);
|
||||||
// Reset height when collapsing
|
// Reset height when collapsing
|
||||||
if (textareaRef.current) {
|
if (textareaRef.current) {
|
||||||
@@ -303,7 +325,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
}
|
}
|
||||||
blurTimeoutRef.current = null;
|
blurTimeoutRef.current = null;
|
||||||
}, 200);
|
}, 200);
|
||||||
}, [showDeps, showModels]);
|
}, [showDeps, showModels, isRefineMenuOpen]);
|
||||||
|
|
||||||
const toggleDep = useCallback((id: string) => {
|
const toggleDep = useCallback((id: string) => {
|
||||||
setDependencies((prev) =>
|
setDependencies((prev) =>
|
||||||
@@ -372,6 +394,29 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
resetForm();
|
resetForm();
|
||||||
}, [description, onSubtaskBreakdown, addToast, 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) =>
|
const truncate = (s: string, len: number) =>
|
||||||
s.length > len ? s.slice(0, len) + "…" : s;
|
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" }} />
|
<ListTree size={12} style={{ verticalAlign: "middle" }} />
|
||||||
Subtask
|
Subtask
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ vi.mock("../../api", () => ({
|
|||||||
defaultPresetBySize: {},
|
defaultPresetBySize: {},
|
||||||
}),
|
}),
|
||||||
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
|
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 {
|
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,
|
contextWindow: 128_000,
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
|
refineText: vi.fn(),
|
||||||
|
getRefineErrorMessage: vi.fn((err) => err?.message || "Failed to refine text. Please try again."),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock lucide-react
|
// Mock lucide-react
|
||||||
@@ -73,6 +75,7 @@ vi.mock("lucide-react", () => ({
|
|||||||
Brain: () => null,
|
Brain: () => null,
|
||||||
Lightbulb: () => null,
|
Lightbulb: () => null,
|
||||||
ListTree: () => null,
|
ListTree: () => null,
|
||||||
|
Sparkles: () => null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function renderQuickEntryBox(props = {}) {
|
function renderQuickEntryBox(props = {}) {
|
||||||
@@ -713,4 +716,211 @@ describe("QuickEntryBox", () => {
|
|||||||
expect(localStorage.getItem("kb-quick-entry-text")).toBe("Task with dropdown");
|
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;
|
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 {
|
.inline-create-model-dropdown {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 100%;
|
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
|
* GET /api/usage
|
||||||
* Fetch AI provider subscription usage (Claude, Codex, Gemini).
|
* Fetch AI provider subscription usage (Claude, Codex, Gemini).
|
||||||
|
|||||||
Reference in New Issue
Block a user