FN-5850: add AI goal description drafting
Add AI-assisted goal description drafting from the Goals view add form. - add a readonly AI drafting flow for goal descriptions with title validation and shared rate limiting - expose a new /api/ai/draft-goal-description endpoint and dashboard API helper for requesting drafts - add Goals view UI, styling, tests, and dashboard docs covering the draft workflow Files changed: docs/dashboard-guide.md | 6 + packages/dashboard/app/api/legacy.ts | 24 +++- packages/dashboard/app/components/GoalsView.css | 14 ++ packages/dashboard/app/components/GoalsView.tsx | 46 +++++- packages/dashboard/app/components/__tests__/GoalsView.test.tsx | 50 +++++++ packages/dashboard/src/__tests__/ai-refine.test.ts | 75 ++++++++++ packages/dashboard/src/__tests__/routes-agents.test.ts | 92 ++++++++++++ packages/dashboard/src/ai-refine.ts | 155 ++++++++++++++++++--- packages/dashboard/src/routes.ts | 58 ++++++++ 9 files changed, 489 insertions(+), 31 deletions(-) Fusion-Task-Id: FN-5850 Fusion-Task-Lineage: $#% !
This commit is contained in:
@@ -4973,6 +4973,10 @@ export interface RefineTextResponse {
|
||||
refined: string;
|
||||
}
|
||||
|
||||
export interface DraftGoalDescriptionResponse {
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refine task description text using AI.
|
||||
* @param text - The text to refine (1-2000 characters)
|
||||
@@ -5023,11 +5027,13 @@ export function getRefineErrorMessage(error: unknown): string {
|
||||
return REFINE_ERROR_MESSAGES.INVALID_TYPE;
|
||||
}
|
||||
|
||||
// Text validation errors (400) - pass through from backend
|
||||
// Validation errors (400) - pass through from backend
|
||||
if (
|
||||
message.startsWith("text must") ||
|
||||
message.startsWith("title must") ||
|
||||
message.includes("text is required") ||
|
||||
message.includes("type is required")
|
||||
message.includes("type is required") ||
|
||||
message.includes("title is required")
|
||||
) {
|
||||
return error.message;
|
||||
}
|
||||
@@ -5036,6 +5042,20 @@ export function getRefineErrorMessage(error: unknown): string {
|
||||
return REFINE_ERROR_MESSAGES.NETWORK;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draft a goal description using AI from a goal title.
|
||||
* @param title - The goal title to expand into a draft description
|
||||
* @param projectId - Optional project ID for scoped settings resolution
|
||||
* @returns The drafted goal description
|
||||
* @throws Error with message for rate limit (429), validation (400), or server errors
|
||||
*/
|
||||
export async function draftGoalDescription(title: string, projectId?: string): Promise<string> {
|
||||
const response = await api<DraftGoalDescriptionResponse>(withProjectId("/ai/draft-goal-description", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
return response.description;
|
||||
}
|
||||
|
||||
export function startSubtaskBreakdown(description: string, projectId?: string): Promise<{ sessionId: string }> {
|
||||
return api<{ sessionId: string }>(withProjectId("/subtasks/start-streaming", projectId), {
|
||||
|
||||
@@ -63,6 +63,20 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.goals-form-label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.goals-form-draft-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.goals-form textarea.input {
|
||||
min-height: calc(var(--space-2xl) * 2);
|
||||
resize: vertical;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { Goal } from "@fusion/core";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Plus, Sparkles } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { draftGoalDescription, getRefineErrorMessage } from "../api";
|
||||
import "./GoalsView.css";
|
||||
|
||||
export interface GoalsViewProps {
|
||||
@@ -29,6 +30,7 @@ export function GoalsView({ initialGoals }: GoalsViewProps) {
|
||||
const [addDescription, setAddDescription] = useState("");
|
||||
const [addError, setAddError] = useState<string | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isDraftingDescription, setIsDraftingDescription] = useState(false);
|
||||
|
||||
const [editGoalId, setEditGoalId] = useState<string | null>(null);
|
||||
const [editTitle, setEditTitle] = useState("");
|
||||
@@ -104,6 +106,26 @@ export function GoalsView({ initialGoals }: GoalsViewProps) {
|
||||
setAddTitle("");
|
||||
setAddDescription("");
|
||||
setAddError(null);
|
||||
setIsDraftingDescription(false);
|
||||
}
|
||||
|
||||
async function draftAddGoalDescription() {
|
||||
const title = addTitle.trim();
|
||||
if (!title) {
|
||||
setAddError("Title is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsDraftingDescription(true);
|
||||
setAddError(null);
|
||||
const description = await draftGoalDescription(title);
|
||||
setAddDescription(description);
|
||||
} catch (error) {
|
||||
setAddError(getRefineErrorMessage(error));
|
||||
} finally {
|
||||
setIsDraftingDescription(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitAddGoal() {
|
||||
@@ -269,9 +291,21 @@ export function GoalsView({ initialGoals }: GoalsViewProps) {
|
||||
onChange={(event) => setAddTitle(event.target.value)}
|
||||
data-testid="goals-form-title"
|
||||
/>
|
||||
<label className="goals-form-label" htmlFor="goals-form-description">
|
||||
Description
|
||||
</label>
|
||||
<div className="goals-form-label-row">
|
||||
<label className="goals-form-label" htmlFor="goals-form-description">
|
||||
Description
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn goals-form-draft-button"
|
||||
onClick={() => void draftAddGoalDescription()}
|
||||
disabled={!addTitle.trim() || isDraftingDescription}
|
||||
data-testid="goals-form-draft-ai"
|
||||
>
|
||||
<Sparkles aria-hidden="true" />
|
||||
{isDraftingDescription ? "Drafting…" : "Draft with AI"}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
id="goals-form-description"
|
||||
className="input"
|
||||
@@ -286,10 +320,10 @@ export function GoalsView({ initialGoals }: GoalsViewProps) {
|
||||
</p>
|
||||
) : null}
|
||||
<div className="goals-form-actions">
|
||||
<button type="button" className="btn btn-primary" onClick={() => void submitAddGoal()} disabled={isCreating} data-testid="goals-form-submit">
|
||||
<button type="button" className="btn btn-primary" onClick={() => void submitAddGoal()} disabled={isCreating || isDraftingDescription} data-testid="goals-form-submit">
|
||||
Save
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={closeAddForm} disabled={isCreating} data-testid="goals-form-cancel">
|
||||
<button type="button" className="btn" onClick={closeAddForm} disabled={isCreating || isDraftingDescription} data-testid="goals-form-cancel">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type { Goal } from "@fusion/core";
|
||||
import { draftGoalDescription } from "../../api";
|
||||
import { GoalsView } from "../GoalsView";
|
||||
|
||||
vi.mock("../../api", async () => ({
|
||||
draftGoalDescription: vi.fn(),
|
||||
getRefineErrorMessage: (error: unknown) => (error instanceof Error ? error.message : "Failed to refine text. Please try again."),
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
Plus: () => <span data-testid="icon-plus" />,
|
||||
Sparkles: () => <span data-testid="icon-sparkles" />,
|
||||
}));
|
||||
|
||||
const mockDraftGoalDescription = vi.mocked(draftGoalDescription);
|
||||
|
||||
function makeGoal(overrides: Partial<Goal> & Pick<Goal, "id" | "title">): Goal {
|
||||
return {
|
||||
id: overrides.id,
|
||||
@@ -21,6 +30,7 @@ function makeGoal(overrides: Partial<Goal> & Pick<Goal, "id" | "title">): Goal {
|
||||
describe("GoalsView", () => {
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
mockDraftGoalDescription.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -139,6 +149,46 @@ describe("GoalsView", () => {
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Title is required.");
|
||||
});
|
||||
|
||||
it("keeps the draft button disabled until a title is provided", () => {
|
||||
render(<GoalsView initialGoals={[]} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("goals-add-button"));
|
||||
|
||||
const draftButton = screen.getByTestId("goals-form-draft-ai");
|
||||
expect(draftButton).toBeDisabled();
|
||||
|
||||
fireEvent.change(screen.getByTestId("goals-form-title"), { target: { value: "Grow ecosystem" } });
|
||||
|
||||
expect(screen.getByTestId("goals-form-draft-ai")).toBeEnabled();
|
||||
});
|
||||
|
||||
it("drafts a description from the goal title", async () => {
|
||||
mockDraftGoalDescription.mockResolvedValueOnce("Expand the extension ecosystem with better support and adoption goals.");
|
||||
|
||||
render(<GoalsView initialGoals={[]} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("goals-add-button"));
|
||||
fireEvent.change(screen.getByTestId("goals-form-title"), { target: { value: "Grow ecosystem" } });
|
||||
fireEvent.click(screen.getByTestId("goals-form-draft-ai"));
|
||||
|
||||
await waitFor(() => expect(mockDraftGoalDescription).toHaveBeenCalledWith("Grow ecosystem"));
|
||||
expect(screen.getByTestId("goals-form-description")).toHaveValue(
|
||||
"Expand the extension ecosystem with better support and adoption goals."
|
||||
);
|
||||
});
|
||||
|
||||
it("shows an error when AI drafting fails", async () => {
|
||||
mockDraftGoalDescription.mockRejectedValueOnce(new Error("Too many refinement requests. Please wait an hour."));
|
||||
|
||||
render(<GoalsView initialGoals={[]} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("goals-add-button"));
|
||||
fireEvent.change(screen.getByTestId("goals-form-title"), { target: { value: "Grow ecosystem" } });
|
||||
fireEvent.click(screen.getByTestId("goals-form-draft-ai"));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Too many refinement requests. Please wait an hour.");
|
||||
});
|
||||
|
||||
it("creates goal via API and closes form", async () => {
|
||||
const created = makeGoal({ id: "g3", title: "Created Goal", description: "new description" });
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import {
|
||||
refineText,
|
||||
draftGoalDescription,
|
||||
validateRefineRequest,
|
||||
validateGoalDraftRequest,
|
||||
checkRateLimit,
|
||||
getRateLimitResetTime,
|
||||
__resetRefineState,
|
||||
@@ -11,6 +13,7 @@ import {
|
||||
VALID_REFINEMENT_TYPES,
|
||||
MIN_TEXT_LENGTH,
|
||||
MAX_TEXT_LENGTH,
|
||||
MAX_GOAL_TITLE_LENGTH,
|
||||
MAX_REQUESTS_PER_HOUR,
|
||||
RATE_LIMIT_WINDOW_MS,
|
||||
} from "../ai-refine.js";
|
||||
@@ -132,6 +135,34 @@ describe("ai-refine module", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateGoalDraftRequest", () => {
|
||||
it("accepts a valid goal title and trims whitespace", () => {
|
||||
expect(validateGoalDraftRequest(" Improve plugin ecosystem ")).toBe("Improve plugin ecosystem");
|
||||
});
|
||||
|
||||
it("throws ValidationError for missing title", () => {
|
||||
expect(() => validateGoalDraftRequest(undefined)).toThrow(ValidationError);
|
||||
expect(() => validateGoalDraftRequest(undefined)).toThrow("title is required");
|
||||
});
|
||||
|
||||
it("throws ValidationError for non-string title", () => {
|
||||
expect(() => validateGoalDraftRequest(123)).toThrow(ValidationError);
|
||||
expect(() => validateGoalDraftRequest(123)).toThrow("title must be a string");
|
||||
});
|
||||
|
||||
it("throws ValidationError for empty trimmed title", () => {
|
||||
expect(() => validateGoalDraftRequest(" ")).toThrow(ValidationError);
|
||||
expect(() => validateGoalDraftRequest(" ")).toThrow("title is required");
|
||||
});
|
||||
|
||||
it("throws ValidationError for titles exceeding the max length", () => {
|
||||
expect(() => validateGoalDraftRequest("a".repeat(MAX_GOAL_TITLE_LENGTH + 1))).toThrow(ValidationError);
|
||||
expect(() => validateGoalDraftRequest("a".repeat(MAX_GOAL_TITLE_LENGTH + 1))).toThrow(
|
||||
`title must not exceed ${MAX_GOAL_TITLE_LENGTH} characters`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkRateLimit", () => {
|
||||
it("allows first request from an IP", () => {
|
||||
expect(checkRateLimit("192.168.1.1")).toBe(true);
|
||||
@@ -276,6 +307,50 @@ describe("ai-refine module", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("draftGoalDescription", () => {
|
||||
function createDraftMockAgent(content: string | Array<{ type: string; text: string }>) {
|
||||
return {
|
||||
session: {
|
||||
state: {
|
||||
messages: [{ role: "assistant", content }],
|
||||
},
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("drafts a goal description from the last assistant message", async () => {
|
||||
const mockAgent = createDraftMockAgent([
|
||||
{ type: "text", text: "Grow the integration ecosystem with clearer extension pathways." },
|
||||
{ type: "text", text: " Success means partners can discover and ship supported integrations faster." },
|
||||
]);
|
||||
mockCreateFnAgent.mockResolvedValueOnce(mockAgent);
|
||||
|
||||
await expect(draftGoalDescription("Grow plugin ecosystem", "/tmp/project")).resolves.toBe(
|
||||
"Grow the integration ecosystem with clearer extension pathways. Success means partners can discover and ship supported integrations faster."
|
||||
);
|
||||
expect(mockCreateFnAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cwd: "/tmp/project",
|
||||
tools: "readonly",
|
||||
})
|
||||
);
|
||||
expect(mockAgent.session.prompt).toHaveBeenCalledWith("Goal title: Grow plugin ecosystem");
|
||||
expect(mockAgent.session.dispose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("throws AiServiceError when AI returns an empty response", async () => {
|
||||
const mockAgent = createDraftMockAgent(" ");
|
||||
mockCreateFnAgent.mockResolvedValueOnce(mockAgent);
|
||||
|
||||
await expect(draftGoalDescription("Grow plugin ecosystem", "/tmp/project")).rejects.toThrow(
|
||||
"AI returned empty response"
|
||||
);
|
||||
expect(mockAgent.session.dispose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("__resetRefineState", () => {
|
||||
it("clears all rate limit entries", () => {
|
||||
const ip = "192.168.1.1";
|
||||
|
||||
@@ -38,6 +38,7 @@ import { get as performGet, request as performRequest } from "../test-request.js
|
||||
import { resetRuntimeLogSink, setRuntimeLogSink } from "../runtime-logger.js";
|
||||
import { resetDiagnosticsSink, setDiagnosticsSink, type LogEntry } from "../ai-session-diagnostics.js";
|
||||
import * as updateCheckModule from "../update-check.js";
|
||||
import * as aiRefineModule from "../ai-refine.js";
|
||||
import { __setAgentReflectionServiceForTests } from "../routes/register-agent-reflection-rating-routes.js";
|
||||
|
||||
// Mock @fusion/core for gh CLI auth checks
|
||||
@@ -3223,6 +3224,97 @@ describe("POST /api/ai/refine-text with projectId scoping", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/ai/draft-goal-description", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore({
|
||||
getRootDir: vi.fn().mockReturnValue("/test/project"),
|
||||
});
|
||||
aiRefineModule.__resetRefineState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
aiRefineModule.__resetRefineState();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("returns a drafted description for a valid title", async () => {
|
||||
const draftSpy = vi
|
||||
.spyOn(aiRefineModule, "draftGoalDescription")
|
||||
.mockResolvedValueOnce("Grow the ecosystem with clear extension support and measurable adoption.");
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/ai/draft-goal-description",
|
||||
JSON.stringify({ title: "Grow plugin ecosystem" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
description: "Grow the ecosystem with clear extension support and measurable adoption.",
|
||||
});
|
||||
expect(draftSpy).toHaveBeenCalledWith("Grow plugin ecosystem", "/test/project", undefined);
|
||||
});
|
||||
|
||||
it("returns 400 when title is missing or empty", async () => {
|
||||
const missing = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/ai/draft-goal-description",
|
||||
JSON.stringify({}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
expect(missing.status).toBe(400);
|
||||
expect(missing.body.error).toContain("title is required");
|
||||
|
||||
const empty = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/ai/draft-goal-description",
|
||||
JSON.stringify({ title: " " }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
expect(empty.status).toBe(400);
|
||||
expect(empty.body.error).toContain("title is required");
|
||||
});
|
||||
|
||||
it("returns 429 when draft requests are rate limited", async () => {
|
||||
const app = buildApp();
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const res = await REQUEST(
|
||||
app,
|
||||
"POST",
|
||||
"/api/ai/draft-goal-description",
|
||||
JSON.stringify({ title: `Goal ${i}` }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
}
|
||||
|
||||
const rateLimited = await REQUEST(
|
||||
app,
|
||||
"POST",
|
||||
"/api/ai/draft-goal-description",
|
||||
JSON.stringify({ title: "Goal 11" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(rateLimited.status).toBe(429);
|
||||
expect(rateLimited.body.error).toContain("Rate limit exceeded");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Messaging Routes", () => {
|
||||
let rootDir: string;
|
||||
let store: TaskStore;
|
||||
|
||||
@@ -86,12 +86,28 @@ Your job is to refine task descriptions based on the user's selected refinement
|
||||
- Output ONLY the refined text, no markdown formatting, no explanations
|
||||
- The output should be a direct replacement for the input text`;
|
||||
|
||||
/** System prompt for drafting goal descriptions */
|
||||
export const GOAL_DRAFT_SYSTEM_PROMPT = `You are a strategic planning assistant for a goal tracking system.
|
||||
|
||||
Given a goal title, draft a concise goal description that helps a team understand the intent, scope, and success signal of the goal.
|
||||
|
||||
Guidelines:
|
||||
- Write plain text only
|
||||
- No markdown headings, bullets, or preamble
|
||||
- Use a professional, actionable tone
|
||||
- Return 1 to 3 short paragraphs
|
||||
- Capture the goal's purpose, likely scope, and how success could be recognized
|
||||
- Do not invent unrelated product names, timelines, metrics, or implementation specifics that are not implied by the title`;
|
||||
|
||||
/** Maximum text length in characters */
|
||||
export const MAX_TEXT_LENGTH = 2000;
|
||||
|
||||
/** Minimum text length in characters */
|
||||
export const MIN_TEXT_LENGTH = 1;
|
||||
|
||||
/** Maximum goal title length in characters */
|
||||
export const MAX_GOAL_TITLE_LENGTH = 200;
|
||||
|
||||
/** Rate limit: max requests per IP per hour */
|
||||
export const MAX_REQUESTS_PER_HOUR = 10;
|
||||
|
||||
@@ -235,6 +251,61 @@ export function validateRefineRequest(
|
||||
return { text, type: type as RefinementType };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate goal-description drafting request.
|
||||
* Throws ValidationError for invalid input.
|
||||
*/
|
||||
export function validateGoalDraftRequest(title: unknown): string {
|
||||
if (title === undefined || title === null) {
|
||||
throw new ValidationError("title is required");
|
||||
}
|
||||
|
||||
if (typeof title !== "string") {
|
||||
throw new ValidationError("title must be a string");
|
||||
}
|
||||
|
||||
const trimmedTitle = title.trim();
|
||||
if (!trimmedTitle) {
|
||||
throw new ValidationError("title is required");
|
||||
}
|
||||
|
||||
if (trimmedTitle.length > MAX_GOAL_TITLE_LENGTH) {
|
||||
throw new ValidationError(`title must not exceed ${MAX_GOAL_TITLE_LENGTH} characters`);
|
||||
}
|
||||
|
||||
return trimmedTitle;
|
||||
}
|
||||
|
||||
function extractLastAssistantText(messages: unknown): string {
|
||||
interface AgentMessage {
|
||||
role: string;
|
||||
content?: string | Array<{ type: string; text: string }>;
|
||||
}
|
||||
|
||||
const lastMessage = (Array.isArray(messages) ? messages : [])
|
||||
.filter((message): message is AgentMessage => Boolean(message) && typeof message === "object" && "role" in message)
|
||||
.filter((message) => message.role === "assistant")
|
||||
.pop();
|
||||
|
||||
if (!lastMessage?.content) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (typeof lastMessage.content === "string") {
|
||||
return lastMessage.content.trim();
|
||||
}
|
||||
|
||||
if (Array.isArray(lastMessage.content)) {
|
||||
return lastMessage.content
|
||||
.filter((content): content is { type: "text"; text: string } => content.type === "text")
|
||||
.map((content) => content.text)
|
||||
.join("")
|
||||
.trim();
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
// ── AI Integration ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -277,29 +348,7 @@ export async function refineText(
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
const refinedText = extractLastAssistantText(agentResult.session.state.messages);
|
||||
|
||||
if (!refinedText) {
|
||||
throw new AiServiceError("AI returned empty response");
|
||||
@@ -330,6 +379,66 @@ export async function refineText(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draft a goal description using a readonly AI agent.
|
||||
* @param title - Goal title to expand into a description
|
||||
* @param rootDir - Project root directory for AI agent context
|
||||
* @param promptOverrides - Optional prompt overrides (unused for this inline prompt)
|
||||
* @returns Drafted goal description text
|
||||
*/
|
||||
export async function draftGoalDescription(
|
||||
title: string,
|
||||
rootDir: string,
|
||||
_promptOverrides?: PromptOverrideMap,
|
||||
): Promise<string> {
|
||||
await ensureEngineReady();
|
||||
|
||||
if (!createFnAgent) {
|
||||
throw new AiServiceError("AI engine not available");
|
||||
}
|
||||
|
||||
const agentResult = await createFnAgent({
|
||||
cwd: rootDir,
|
||||
systemPrompt: GOAL_DRAFT_SYSTEM_PROMPT,
|
||||
tools: "readonly",
|
||||
});
|
||||
|
||||
if (!agentResult?.session) {
|
||||
throw new AiServiceError("Failed to initialize AI agent");
|
||||
}
|
||||
|
||||
const prompt = `Goal title: ${title}`;
|
||||
|
||||
try {
|
||||
await agentResult.session.prompt(prompt);
|
||||
|
||||
const description = extractLastAssistantText(agentResult.session.state.messages);
|
||||
if (!description) {
|
||||
throw new AiServiceError("AI returned empty response");
|
||||
}
|
||||
|
||||
try {
|
||||
agentResult.session.dispose?.();
|
||||
} catch {
|
||||
// Ignore disposal errors
|
||||
}
|
||||
|
||||
return description;
|
||||
} catch (err) {
|
||||
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 {
|
||||
|
||||
@@ -1784,6 +1784,64 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/ai/draft-goal-description
|
||||
* AI-powered goal description drafting from a goal title.
|
||||
* Body: { title: string }
|
||||
* Returns: { description: string }
|
||||
*
|
||||
* Rate limited: 10 requests per hour per IP
|
||||
*/
|
||||
router.post("/ai/draft-goal-description", async (req, res) => {
|
||||
try {
|
||||
const { title } = req.body;
|
||||
const ip = req.ip || req.socket.remoteAddress || "unknown";
|
||||
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const settings = await scopedStore.getSettings();
|
||||
|
||||
const {
|
||||
validateGoalDraftRequest,
|
||||
checkRateLimit,
|
||||
getRateLimitResetTime,
|
||||
draftGoalDescription,
|
||||
RateLimitError: _RateLimitError4,
|
||||
ValidationError,
|
||||
AiServiceError: _AiServiceError2,
|
||||
} = await import("./ai-refine.js");
|
||||
|
||||
if (!checkRateLimit(ip)) {
|
||||
const resetTime = getRateLimitResetTime(ip);
|
||||
throw rateLimited(`Rate limit exceeded. Maximum 10 draft requests per hour. Reset at ${resetTime?.toISOString() || "unknown"}`);
|
||||
}
|
||||
|
||||
let validatedTitle: string;
|
||||
try {
|
||||
validatedTitle = validateGoalDraftRequest(title);
|
||||
} catch (err) {
|
||||
if (err instanceof ValidationError) {
|
||||
throw badRequest(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const description = await draftGoalDescription(validatedTitle, rootDir, settings.promptOverrides);
|
||||
res.json({ description });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
if (err instanceof Error && err.name === "RateLimitError") {
|
||||
throw rateLimited(err.message);
|
||||
} else if (err instanceof Error && err.name === "AiServiceError") {
|
||||
rethrowAsApiError(err, "AI service error");
|
||||
} else {
|
||||
rethrowAsApiError(err, "Failed to draft goal description");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/ai/summarize-title
|
||||
* AI-powered title generation from task descriptions.
|
||||
|
||||
Reference in New Issue
Block a user