Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 35 additions & 32 deletions src/components/chat/ToolCallDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,38 +106,41 @@ export function ToolCallDisplay({
{formatOutput(toolCall.output)}
</pre>
) : (
<>
{toolCall.input !== undefined && toolCall.input !== null && (
<div>
<div className="font-medium text-muted-foreground mb-1">
Input:
</div>
<pre className="bg-muted p-2 rounded overflow-x-auto max-h-32">
{formatOutput(toolCall.input)}
</pre>
</div>
)}
{toolCall.output !== undefined && toolCall.output !== null && (
<div>
<div className="font-medium text-muted-foreground mb-1">
Output:
</div>
<pre className="bg-muted p-2 rounded overflow-x-auto max-h-32">
{formatOutput(toolCall.output).slice(0, 500)}
</pre>
</div>
)}
{toolCall.error !== undefined && (
<div>
<div className="font-medium text-status-error mb-1">
Error:
</div>
<pre className="bg-status-error-light p-2 rounded overflow-x-auto max-h-32 text-status-error">
{formatOutput(toolCall.error)}
</pre>
</div>
)}
</>
(() => {
const details = toolInfo.getDetails?.({
input: toolCall.input,
output: toolCall.output,
status: toolCall.status,
});
return (
<>
{details && (
<pre className="whitespace-pre-wrap text-muted-foreground">
{details}
</pre>
)}
{toolCall.error !== undefined && (
<pre className="whitespace-pre-wrap text-status-error">
{formatOutput(toolCall.error)}
</pre>
)}
{!details && toolCall.error === undefined && (
<>
{toolCall.output !== undefined &&
toolCall.output !== null ? (
<pre className="whitespace-pre-wrap text-muted-foreground text-xs opacity-60">
{formatOutput(toolCall.output).slice(0, 600)}
</pre>
) : (
<div className="text-muted-foreground italic">
No additional details.
</div>
)}
</>
)}
</>
);
})()
)}
</div>
)}
Expand Down
53 changes: 53 additions & 0 deletions src/components/chat/tools/registry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ export interface ToolInfo {
openFileOnClick?: boolean;
/** Extract file path from tool input for openFileOnClick */
getFilePath?: (input: unknown) => string | null;
/**
* Short text shown when the tool call is expanded. Should be a few short
* lines at most. Return null/empty to render nothing extra (errors are
* always shown by the host).
*/
getDetails?: (args: {
input: unknown;
output: unknown;
status: "running" | "success" | "error";
}) => string | null;
}

/**
Expand Down Expand Up @@ -321,3 +331,46 @@ registerTool("context7_get-library-docs", {
return typeof obj.topic === "string" ? obj.topic : null;
},
});

// Doce Preview tools (internal)
function readField(value: unknown, key: string): string | null {
if (!value || typeof value !== "object") return null;
const v = (value as Record<string, unknown>)[key];
return typeof v === "string" && v.trim() ? v.trim() : null;
}

registerTool("get_doce_preview_status", {
name: "Preview Status",
icon: Search,
getDetails: ({ output }) =>
readField(output, "summary") ?? readField(output, "error"),
});

registerTool("read_doce_preview_logs", {
name: "Preview Logs",
icon: FileText,
getContext: (input) => {
if (!input || typeof input !== "object") return null;
const mode = (input as Record<string, unknown>).mode as string;
return mode ?? "summary";
},
getDetails: ({ output }) => {
const signal = readField(output, "extractedSignal");
const summary = readField(output, "summary");
const error = readField(output, "error");
if (signal && summary) return `${summary}\n${signal}`;
return summary ?? signal ?? error;
},
});

registerTool("restart_doce_preview", {
name: "Restart Preview",
icon: RefreshCw,
getDetails: ({ input, output }) => {
const reason = readField(input, "reason");
const summary = readField(output, "summary");
const error = readField(output, "error");
const parts = [reason, summary ?? error].filter(Boolean);
return parts.length ? parts.join("\n") : null;
},
});
42 changes: 42 additions & 0 deletions src/pages/api/internal/ai-tools/preview/logs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { APIRoute } from "astro";
import { readDocePreviewLogs } from "@/server/ai-tools/doce-preview";
import { readDocePreviewLogsInput } from "@/server/ai-tools/doce-preview/schemas";
import {
authorizeInternalCall,
jsonResponse,
parseInternalRequest,
} from "@/server/ai-tools/internalRequest";
import { logger } from "@/server/logger";

export const POST: APIRoute = async ({ request }) => {
const parsed = await parseInternalRequest(request);
if (!parsed.ok) return parsed.response;

const auth = await authorizeInternalCall(parsed.body);
if (!auth.ok) return auth.response;

const inputResult = readDocePreviewLogsInput.safeParse({
projectId: auth.result.projectId,
mode: parsed.body.mode,
maxBytes: parsed.body.maxBytes,
offset: parsed.body.offset,
});
if (!inputResult.success) {
return jsonResponse(400, { error: inputResult.error.message });
}

try {
const output = await readDocePreviewLogs(
inputResult.data,
auth.result.ownerUserId,
);
return jsonResponse(200, output);
} catch (error) {
const message = error instanceof Error ? error.message : "Internal error";
logger.error(
{ projectId: auth.result.projectId, error: message },
"read_doce_preview_logs failed",
);
return jsonResponse(500, { error: message });
}
};
40 changes: 40 additions & 0 deletions src/pages/api/internal/ai-tools/preview/restart.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { APIRoute } from "astro";
import { restartDocePreview } from "@/server/ai-tools/doce-preview";
import { restartDocePreviewInput } from "@/server/ai-tools/doce-preview/schemas";
import {
authorizeInternalCall,
jsonResponse,
parseInternalRequest,
} from "@/server/ai-tools/internalRequest";
import { logger } from "@/server/logger";

export const POST: APIRoute = async ({ request }) => {
const parsed = await parseInternalRequest(request);
if (!parsed.ok) return parsed.response;

const auth = await authorizeInternalCall(parsed.body);
if (!auth.ok) return auth.response;

const inputResult = restartDocePreviewInput.safeParse({
projectId: auth.result.projectId,
reason: parsed.body.reason,
});
if (!inputResult.success) {
return jsonResponse(400, { error: inputResult.error.message });
}

try {
const output = await restartDocePreview(
inputResult.data,
auth.result.ownerUserId,
);
return jsonResponse(200, output);
} catch (error) {
const message = error instanceof Error ? error.message : "Internal error";
logger.error(
{ projectId: auth.result.projectId, error: message },
"restart_doce_preview failed",
);
return jsonResponse(500, { error: message });
}
};
39 changes: 39 additions & 0 deletions src/pages/api/internal/ai-tools/preview/status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { APIRoute } from "astro";
import { getDocePreviewStatus } from "@/server/ai-tools/doce-preview";
import { getDocePreviewStatusInput } from "@/server/ai-tools/doce-preview/schemas";
import {
authorizeInternalCall,
jsonResponse,
parseInternalRequest,
} from "@/server/ai-tools/internalRequest";
import { logger } from "@/server/logger";

export const POST: APIRoute = async ({ request }) => {
const parsed = await parseInternalRequest(request);
if (!parsed.ok) return parsed.response;

const auth = await authorizeInternalCall(parsed.body);
if (!auth.ok) return auth.response;

const inputResult = getDocePreviewStatusInput.safeParse({
projectId: auth.result.projectId,
});
if (!inputResult.success) {
return jsonResponse(400, { error: inputResult.error.message });
}

try {
const output = await getDocePreviewStatus(
inputResult.data,
auth.result.ownerUserId,
);
return jsonResponse(200, output);
} catch (error) {
const message = error instanceof Error ? error.message : "Internal error";
logger.error(
{ projectId: auth.result.projectId, error: message },
"get_doce_preview_status failed",
);
return jsonResponse(500, { error: message });
}
};
23 changes: 23 additions & 0 deletions src/server/ai-tools/doce-preview/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export {
getDocePreviewStatus,
readDocePreviewLogs,
restartDocePreview,
} from "./service";

export {
getDocePreviewStatusInput,
getDocePreviewStatusOutput,
readDocePreviewLogsInput,
readDocePreviewLogsOutput,
restartDocePreviewInput,
restartDocePreviewOutput,
} from "./schemas";

export type {
GetDocePreviewStatusInput,
GetDocePreviewStatusOutput,
ReadDocePreviewLogsInput,
ReadDocePreviewLogsOutput,
RestartDocePreviewInput,
RestartDocePreviewOutput,
} from "./schemas";
91 changes: 91 additions & 0 deletions src/server/ai-tools/doce-preview/schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { z } from "zod";

// ============================================================================
// get_doce_preview_status
// ============================================================================

export const getDocePreviewStatusInput = z.object({
projectId: z.string().min(1),
});

export const getDocePreviewStatusOutput = z.object({
ok: z.boolean(),
projectId: z.string(),
projectStatus: z.string(),
preview: z.object({
reachable: z.boolean(),
url: z.string().optional(),
httpStatus: z.number().int().optional(),
}),
containers: z.array(
z.object({
service: z.string(),
state: z.string(),
health: z.string().optional(),
}),
),
logStreamingActive: z.boolean().optional(),
summary: z.string(),
});

export type GetDocePreviewStatusInput = z.infer<
typeof getDocePreviewStatusInput
>;
export type GetDocePreviewStatusOutput = z.infer<
typeof getDocePreviewStatusOutput
>;

// ============================================================================
// read_doce_preview_logs
// ============================================================================

export const readDocePreviewLogsInput = z.object({
projectId: z.string().min(1),
mode: z.enum(["summary", "tail", "sinceOffset"]).default("summary"),
maxBytes: z.number().int().min(256).max(16384).optional(),
offset: z.number().int().min(0).optional(),
});

export const readDocePreviewLogsOutput = z.object({
ok: z.boolean(),
projectId: z.string(),
mode: z.enum(["summary", "tail", "sinceOffset"]),
content: z.string().optional(),
nextOffset: z.number().int().optional(),
truncated: z.boolean().optional(),
extractedSignal: z.string().nullable().optional(),
summary: z.string(),
});

export type ReadDocePreviewLogsInput = z.infer<
typeof readDocePreviewLogsInput
>;
export type ReadDocePreviewLogsOutput = z.infer<
typeof readDocePreviewLogsOutput
>;

// ============================================================================
// restart_doce_preview
// ============================================================================

export const restartDocePreviewInput = z.object({
projectId: z.string().min(1),
reason: z.string().max(300).optional(),
});

export const restartDocePreviewOutput = z.object({
ok: z.boolean(),
projectId: z.string(),
restarted: z.boolean(),
command: z.literal("docker compose restart preview"),
previewReachableAfterRestart: z.boolean().optional(),
summary: z.string(),
error: z.string().optional(),
});

export type RestartDocePreviewInput = z.infer<
typeof restartDocePreviewInput
>;
export type RestartDocePreviewOutput = z.infer<
typeof restartDocePreviewOutput
>;
Loading
Loading